【Java集合框架总述】面试中的集合框架,能考的东西不多,组织好语言,背下来就好

面试中,对于集合框架能够被问到内容好像只有这六个:
1、整个集合框架类图 源码层面
2、hashcode()和equals() 源码层面
3、hashMap hashtable concurrenthashmap 源码层面
4、hashmap1.8实现 源码层面
5、ArrayList扩容 源码层面
6、泛型三种(泛型接口+泛型类+泛型方法)+集合框架的泛型擦除+堆污染 使用层面
PS:前五个使用容易,要说就要说源码层面的,第六个直接说使用层面就好了

博客列表:

Java集合框架001 为什么重写equals就要重写hashcode?

Java集合框架002 当面试中遇到HashMap,面试语言组织?

Java集合框架003 JDK8 ArrayList源码解析(重点:底层数组结构、扩容机制)

Java集合框架004 泛型类

Java集合框架005 泛型接口+泛型方法

Java集合框架006 泛型上下限+泛型擦除

Java集合框架007 堆污染

Java集合框架008 泛型与多态

金手指1:
1、面试回答问题的都是语言描述,所以博客内容要使用语言描述(代码段都去掉,语言多用对比阐述)。
2、开放性试题,回答的越多越好,这里给出答案。

一、各个集合框架底层数据结构(数组+链表/红黑树)

首先要把握的一点是,底层数据结构只有两种:
首先要把握的一点是,无论集合框架怎样变化 list set map,底层数据结构只有两种:数组(涉及扩容:ArrayList和HashMap)和链表(不涉及扩容:LinkedList)

在这里插入图片描述

金手指:List家底层数据结构
1、ArrayList
底层:数组;
特点(由底层数据结构决定):查询速度很快,但是增删稍慢。线程不同步。
扩容:默认长度10,超过再new一个数组延长50%,扩容的方式是复制数组,元素重新拷贝。

如何看出ArrayList底层是数组,add操作最后是 elementData[index] = element; 就是数组了,链表会设置next对象;
2、LinkedList
底层:双向链表;
特点(由底层数据结构决定):增删速度很快,查询速度稍慢,线程不安全。
扩容:底层链表结构,不存在容量限制,不存在扩容。

只有数组才存在容量限制,才需要扩容;链表不存在容量限制,不需要扩容。
相对应的LinkedList底层是双向链表
在这里插入图片描述
3、Vector
底层:数组;
特点(由底层数据结构决定):查询速度很快,但是增删稍慢。线程同步。
扩容(从add开始找):两倍扩容; int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);

Vector是线程安全的数组,看add就知道了
第一,使用synchronized关键字,保证同步操作原子性,所以线程安全
第二,elementData[elementCount++]=e; 所以底层是数组
在这里插入图片描述

Map家族底层数据结构
hashmap hashtable hashtree 底层都是数组+单链表基本元素只有next hashmap jdk8 以后是
数组+单链表/红黑树 linkedhashmap 底层 数组+双向链表

Collection接口和Collections类
Collection是一个接口,Collection是一个类。
Collection接口是存储数据的容器,向上继承于Iterable可迭代接口,向下是Queue接口、List接口、Set接口的父类
Collections类是一个方法类,封装了Set,List,Map的操作的工具方法,大多数都是static方法,可以类名直接调用,非常方便。
例如,常用的集合类: HashSet/ArrayList/HashMap都是线程不安全的,在多线程环境下不安全.
在Collections类中有获取线程安全的集合方法:
List list = Collections.synchronizedList(new ArrayList());
Set set = Collections.synchronizedSet(new HashSet());
Map map = Collections.synchronizedMap(new HashMap());

二、源码解析:hashcode()和equals()

介绍一下hashcode和equals,解释一下为什么重写equals一定要重写hashcode

1、不会创建“类对应的散列表”,不存在重写equals要重写hashcode

1、不会创建“类对应的散列表”,hashcode除了打印引用所指向的对象地址看一看,没有任何调用,重写hashcode逻辑也没有用,反正没有调用,equals用来比较,可以自定义比较逻辑,hashcode和equals是两个独立方法,没有任何关系,不存在重写equals要重写hashcode

1、当我们不在HashSet, HashTable, HashMap等等这些本质是散列表的数据结构中,用到这个类作为泛型,此时,这个类的hashCode() 和 equals()没有任何关系;
1.1 equals()方法
其中,equals未被重写就直接进行引用比较,
public boolean equals(Object obj) {
return (this == obj);
}
若equals已被重写,就按照自己的重写equals逻辑来。
equals() 用来比较该类的两个对象是否相等。
1.2 hashCode()
而hashCode(),默认的hashcode()就是返回哈希值,但是此时返回的哈希值根本没有任何作用,不用理会hashCode()。
1.3 小结:
当我们不在HashSet, HashTable, HashMap等等这些本质是散列表的数据结构中,用到这个类作为泛型,这种情况下,

1.3.1 保持默认:不重写equals,不重写hashCode()
equals直接比较引用,hashCode()也是直接返回对象地址,
所以,equals()与hashCode()完全对应,
对于两个引用,
(1)equals为true,hashCode()一定相等
(2)equals为false,hashCode()一定不相等,
(3)hashCode()相等,equals一定为true
(4)hashCode()不相等,equals一定为false

1.3.2 重写equals为比较对象的属性,不重写hashCode() 对照博客中程序1
(1)equals相等,表示两个引用相等或两个引用指向的对象各个属性(基本类型+String)相等,hashcode()不一定相等(理由:因为一定要两个引用相等,指向的对象地址才相等)。
(2)如果hashcode相等,表示两个引用指向的对象哈希地址相等,则引用相等(因为哈对象哈希地址是任意分配的),equals()一定相等(未重写比较引用相等,已重写)。
(3)equals不相等,表示两个引用一定不相等,hashcode()两个引用所指向的对象地址一定不相等(因为哈希地址随机分配)。
(4)hashcode不相等,两个引用指向的对象地址不相等,equals()可以相等,可以不相等。
小结:不能说明equals和hashcode有关系,只是因为重写equals把return true;的条件放宽了,只要两个引用指向的对象中属性相等就好,不一定引用相等,但是hashcode还是返回对象地址。

2、创建“类对应的散列表”,重写equals一定要重写hashcode

2、当我们在HashSet, HashTable, HashMap等等这些本质是散列表的数据结构中,用到这个类,就是这个类作为集合框架的泛型,此时,这个类的hashCode() 和 equals()紧密相关;因为这些散列表数据结构,对其泛型,要求两个引用所指向的对象hashCode() 和 equals()均相同,才认为是同一个对象

2.1 重写equals为比较对象的属性,不重写hashCode() 问题代码,对照博客中程序2
2.1.1 equals相等,hashcode()可以不相等。 对照博客中程序2
HashSet中放入两个相同就有相同属性的Person对象,两个Person对象属性相同,所以equals比较两个引用得到的结果相等,但是底层指向不同的对象地址,所以hashcode不相等, (但是,HashSet中仍然有重复元素:p1 和 p2。这是因为虽然p1 和 p2的内容相等,但是它们的hashCode()不等;所以,HashSet在添加p1和p2的时候,是根据hashCode()来判断的,认为它们不相等,这说明默认的hashcode()不够好,一个好的哈希算法不应该让HashSet中有重复元素,为什么说重写equals就要重写hashcode?因为要对应equals的判断为true要和hashcode的判断完全对应,默认的equals和hashcode就是比较引用和对象地址的,我们重写的equals和hashcode,是比较引用所指向的对象的个数属性的,总之,equals和hashcode要一一对应,所以重写equals就要重写hashcode)

2.1.2 hashcode()相等,但是重写的equals()比较对象可以相等,可以不相等。 比较难构造demo形成哈希,所有没有程序,知道哈希冲突这种情况就好
因为在散列表中,hashCode()相等,只是表示即两个键值对key-value的哈希值相等。然而哈希值相等,并不一定能得出键值对key-value相等,此时就出现所谓的哈希冲突场景。
造成的一个错误:
(1)equals相等,hashcode()不相等:HashSet集合中的内容相同的元素(这就是重写equals不重写hashcode带来的问题)

2.2 重写equals为比较对象的属性,并且重写hashCode() 对照博客中程序3,此时equals和hash
重写的equals:两个引用相等或者两个引用所指向的对象的属性相等,返回true,其余返回为false。
重写的hashcode:
在HashSet看来:比较p1和p2,我们发现:它们的hashCode()相等,通过equals()比较它们也返回true。所以,p1和p2被视为相等(good)。 已经不存在equals相等,hashcode不相等造成的HashSet重复元素问题,因为这个时候重写的hashcode return 哈希值的范围比equals还要大
比较p1和p4,我们发现:虽然它们的hashCode()相等;但是,通过equals()比较它们返回false。所以,p1和p4被视为不相等,这就是哈希冲突(正是因为这个时候重写的hashcode return 哈希值的范围比equals还要大,造成哈希冲突)。

2.3 equals和hashcode完全对应
处理HashSet存放重复元素错误和哈希冲突

3、hashCode()底层实现,一个好的哈希算法

一个好的hashCode的方法的目标:为不相等的对象(equals为false)产生不相等的散列码,同样的,相等的对象(equals为true)必须拥有相等的散列码,即equals和hashcode对应,向默认的那样,既不会出现hashcode相等,equals不相等的哈希冲突,也不会出现equals相等,hashcode不相等造成HashSet存放equals为true的元素

一般来说,hashcode相等,equals不相等的哈希冲突还能忍受,但是equals相等造成hashcode不相等,造成HashSet存放相同是一定不能忍受的,就是说,重写equals放宽return true;一定要重写hashcode放宽return 哈希码,验证本文中心问题,一定要hashcode范围和equals范围一样大,不能保证的化,就让hashcode范围比equals范围大,允许哈希冲突不允许HashSet存放重复元素

1、把某个非零的常数值,比如17,保存在一个int型的result中;

2、对于每个关键域f(equals方法中设计到的每个域),作以下操作:

a.为该域计算int类型的散列码;

i.如果该域是boolean类型,则计算(f?1:0),

ii.如果该域是byte,char,short或者int类型,计算(int)f,

iii.如果是long类型,计算(int)(f^(f>>>32)).

iv.如果是float类型,计算Float.floatToIntBits(f).

v.如果是double类型,计算Double.doubleToLongBits(f),然后再计算long型的hash值

vi.如果是对象引用,则递归的调用域的hashCode,如果是更复杂的比较,则需要为这个域计算一个范式,然后针对范式调用hashCode,如果为null,返回0

vii. 如果是一个数组,则把每一个元素当成一个单独的域来处理。

b.result = 31 * result + c;

3、返回result

4、编写单元测试验证有没有实现所有相等的实例都有相等的散列码。

给个简单的例子(一个好的hashcode):

@Overridepublic int hashCode() {
    
    
    int result = 17;  
    result = 31 * result + name.hashCode();  
    return result;
}

这里再说下2.b中为什么采用31result + c,乘法使hash值依赖于域的顺序,如果没有乘法那么所有顺序不同的字符串String对象都会有一样的hash值,而31是一个奇素数,如果是偶数,并且乘法溢出的话,信息会丢失,31有个很好的特性是31i ==(i<<5)-i,即2的5次方减1,虚拟机会优化乘法操作为移位操作的。

三、源码解析:hashMap hashtable concurrenthashmap

在这里插入图片描述

HashTable和HashMap干货(背下来)

HashTable HashMap
底层数据结构 底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化 底层数组+链表实现,无论key还是value都可以为null,key为null,但是只有一个,value为null可以多个,使用containsKey()判断是否存在某个key,线程不安全 (可以在源码中找到逻辑)
初始容量与扩容逻辑(源码中可以看到) 初始size为11,扩容:newsize = olesize*2+1 (1)初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂;(2)扩容对象:扩容对象是整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入(3)插入后扩容:插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)(4)数组扩容触发:扩容触发是负载极限,当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
计算下标index(源码中可以看到) index = (hash & 0x7FFFFFFF) % tab.length index = hash & (tab.length – 1)
迭代器Iterator Hashtable的enumerator迭代器不是fail-fast的。 HashMap的迭代器(Iterator)是fail-fast迭代器,这里的fail-fast迭代器的意义是:所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。
哈希 实现Map接口,Hashtable的实现是基于Dictionary抽象类 实现Map接口,1. put:根据hashcode()得到bucket数组的下标index;2. get:根据equals找到键值对并返回;3. 哈希冲突:jdk7链路+jdk8红黑树

初始化的扩容
哈希冲突定义、加载因子,策略:降低加载因子,加大初始大小,以降低哈希冲突的概率。

运行中的扩容
size是当前现实的记录数,capacity是理论上的容量,第一次由初始化决定,之后由扩容决定,initial capacity是初始的capacity,第一次的capacity,负载因子等于“size/capacity”,全程应该叫当前负载因子,其作用是当负载因子达到负载极限的时候扩容。
以上专有名词连接起来是:size是记录数,capacity是桶数量,两者size/capacity得到负载因子,当负载因子达到负载极限的时候扩容。
专有名词:size capacity 负载因子 负载极限 扩容(具体扩容逻辑)
所有的这些都可以在源码中找到逻辑

面试问题:ConcurrentHashMap是如何实现锁机制的?(开放性试题,回答出关键字)
1、Segment内部类:ConcurrentHashMap具有一个内部类Segment,Segement extends ReentrantLock implements Serializable,所以说ConcurrentHashMap中的锁就是一种普通的Lock锁
2、分段锁技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
3、一般方法效率提高16倍:与HashTable的替代、16个桶:ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。ConcurrentHashMap是Hashtable的替代,Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。
4、特殊方法:
有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
5、读不加锁、写加锁:
读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。
6、扩容:
段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容

四、源码解析:HashMap1.8红黑树(hashmap1.7和hashmap1.8一起说)

4.1 jdk7 基本元素+put()+扩容

jdk7:(源码不用背,最重要的是组织语言)
1、基本元素使用Entry,Entry包含四个属性:key、value、hash值和用于单向链表的next。
2、put()逻辑:hash()方法、indexFor()方法、equals()方法
金手指:(put操作中的)hashcode方法和equals方法
jdk7 put 子步骤:扩容 + createEntry插入 + 哈希冲突 ( jdk8 put 子步骤:扩容 + createEntry插入 + 哈希冲突链表/树化
第一步,确定数组下标,indexFor使用hash方法计算出来的值得到数组下标;
第二步,插入,如果指定的数组下标无对象存在,不发生哈希冲突,直接插入;
第三步,如果指定的数组下标有对象存在,发生哈希冲突,使用equals对链条上对象比较,全部为false插入,其中一个为true,表示已经存在(hash和equals都相同就是存在)
小结:插入操作中hash方法用来作为计算数组下标的输入,equals用于比较对象是否存在
附:jdk7情况插入情况下的扩容 addEntry
(1)扩容两个条件:addEntry方法中,如果当前 HashMap 大小已经达到了阈值,并且新值要插入的数组位置已经有元素了,那么要扩容(两个条件,即有可能虽然size>=threshold,但是必须等到相应的槽至少有一个Entry时,才会扩容,可以通过上面的代码看到每次resize都会扩大一倍容量)
(2)扩容方式:hashmap扩容方式:两倍扩容
(3)扩容后重新计算要已经插入了的key的数组下标:先hash,然后indexFor
(4)原来数组的位置:扩容就是用一个新的大数组替换原来的小数组,并将原来数组中的值迁移到新的数组中。由于是双倍扩容,迁移过程中,会将原来table[i]中的链表的所有节点,分拆到新的数组的newTable[i]和newTable[i+oldLength]位置上。如原来数组长度是16,那么扩容后,原来table[0]处的链表中的所有元素会被分配到新数组中newTable[0]和newTable[16]这两个位置。
(5)扩容过程中的隐患:扩容期间,由于会新建一个新的空数组,并且用旧的项填充到这个新的数组中去。所以,在这个填充的过程中,如果有线程获取值,很可能会取到 null 值,而不是我们所希望的、原来添加的值。
(6)新元素插入指定数组下标的链头,table[bucketIndex] = new Entry<>(hash, key, value, e); 新建一个Entry就是一个元素

4.2 jdk8

4.2.1 hashmap相关的变量

hashmap相关的变量:
初始化默认大小是16 initial capacity 16 jdk7+jdk8都一样
最大容量,必须为2^30 jdk7+jdk8都一样
默认负载因子为0.75 达到0.75就扩容,jdk7+jdk8都一样
树化阈值为8,链表化阈值为6 jdk8新增
树化的两个条件:
链表节点数达到8,且要求数组长度大于64
put操作中的数组扩容和链表/红黑树哈希冲突
jdk7的put方法:扩容 + hashcode生成index插入 + 哈希冲突;
jdk8的put方法:扩容 + hashcode生成index插入 + 哈希冲突

hashmap中最重要的两个操作是扩容和哈希冲突,但是要注意,扩容是数组扩容,哈希冲突是链表/红黑树的哈希冲突,两者是的对象是不同的,关系是扩容是为了减低负载因子,减少哈希冲突,
减少哈希冲突两个设计:设计一个好的哈希算法、扩容,
对于真正发生了哈希冲突:jdk7是链表,jdk8是树化
无论是数组扩容还是哈希冲突后的链表/红黑树,都是发生在put操作中的,无论jdk7还是jdk8

4.2.2 jdk8基本元素

jdk8:(源码不用背,最重要的是组织语言)
1、基本元素使用Node,意为红黑树的节点,Node包含四个属性:key、value、hash值和用于单向链表的next。

4.2.3 jdk8 putVal()

putVal()操作:(语言组织说出源码怎样做的)
扩容:如果当前map中无数据,执行resize方法。并且返回n jdk8先扩容再插入,jdk7先插入再扩容,可能无效扩容
没有哈希冲突:如果要插入的键值对要存放的这个位置刚好没有元素,那么把他封装成Node对象,放在这个位置上即可,插入的时候没有哈希冲突
这里对p赋值,就是新的要插入的节点
else 表示 否则的话,说明这数组上面有元素,插入的时候发生哈希冲突
if表示 //如果这个元素的key与要插入的一样,那么就替换一下。
else if 表示 1.如果这个元素的key与要插入的不一样,如果当前节点是TreeNode类型的数据,执行putTreeVal方法
else表示如果这个元素的key与要插入的不一样,如果还是遍历这条链子上的数据,跟jdk7没什么区别
// 循环
// 循环找到一个空位置的就插入链表
//2.完成了操作后多做了一件事情,判断,并且可能执行treeifyBin方法
// 插入并判断是否树化,break;
// 如果链表上已经存在了,直接break;这里不用树化了,应该根本没插入
// 不断将e赋值给p,更新p,就是p在链条上不断往后移动
返回oldValue: e 不为null 要么第一个if替换,要么else if树插入,要么链表插入,总之插入成功了,返回oldValue
插入后判断是否扩容:判断阈值,决定是否数组扩容 插入后决定是否扩容
最后 插入之后的操作
完成了。
注意1:树化有个要求就是数组长度必须大于等于MIN_TREEIFY_CAPACITY(64),否则继续采用扩容策略
注意2:resize方法兼顾两个职责创建初始存储表格,或者在容量不满足需求的时候
注意3:在jdk1.8中取消了indefFor()方法,直接用(tab.length-1)&hash,所以看到这个,代表的就是数组的下角标。

4.2.4 jdk8中的hashmap的王牌功能:为什么HashMap为什么要树化?

jdk8中的hashmap的王牌功能:为什么HashMap为什么要树化?
制造哈希碰撞从而造成DOS攻击,树化后优化哈希碰撞产生后的存取
用哈希碰撞发起拒绝服务攻击(DOS,Denial-Of-Service attack),常见的场景是攻击者可以事先构造大量相同哈希值的数据(制造哈希碰撞从而造成DOS攻击,树化后优化哈希碰撞产生后的存取),然后以JSON数据的形式发送给服务器,服务器端在将其构建成为Java对象过程中,通常以Hashtable或HashMap等形式存储,哈希碰撞将导致哈希表发生严重退化,算法复杂度可能上升一个数据级,进而耗费大量CPU资源。

4.2.5 jdk8中的hashmap的王牌功能:链表树化的两个条件?/为什么要将链表中转红黑树的阈值设为8?

jdk8中的hashmap的王牌功能:链表树化的两个条件?/为什么要将链表中转红黑树的阈值设为8?
链表树化的两个条件:当链表长度大于或等于阈值(默认为 8)的时候,如果同时还满足容量大于或等于 MIN_TREEIFY_CAPACITY(默认为 64)的要求
处理哈希冲突两个方法:一个好的哈希算法、链表树化(前者才是根本,后者只是网络安全制造哈希碰撞从而造成DOS攻击和不合理哈希算法的处理),所以设计为8
通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。所以通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值。
如果开发中发现 HashMap 内部出现了红黑树的结构,那可能是我们的哈希算法出了问题,所以需要选用合适的hashCode方法,以便减少冲突

4.2.6 treemap

小结:
HashMap和HashTable类结构:HashMap是继承自AbstractMap类,而HashTable是继承自Dictionary类。不过它们都同时实现了map、Cloneable(可复制)、Serializable(可序列化)这三个接口。存储的内容是基于key-value的键值对映射,不能有重复的key,而且一个key只能映射一个value。HashSet底层就是基于HashMap实现的。
HashMap put()+get():将键值对传递给put方法时,它调用键对象的hashCode()方法来计算hashCode,然后找到相应的bucket位置(即数组)来储存值对象当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象
HashMap和HashSet:HashMap在两个key-value,hashcode相同导致index相同,哈希冲突,equals相同任务同一个,不重复插入,HashSet在hashcode和equals相同,认为同一个,不重复插入
TreeMap1:TreeMap键、值都不能为null
TreeMap2:金手指:TreeMap自定义排序器,底层如何实现排序:树中的每个节点的值都会大于或等于它的左子树中的所有节点的值,并且小于或等于它的右子树中的所有节点的值
TreeMap:与HashMap不同的是它的get、put、remove之类操作都是o(log(n))的时间复杂度

4.2.7 jdk8四个变化

我们可以简单列下HashMap在1.7和1.8之间的变化,四点变化(除了底层结构,都要从源码层面解释):

第一,底层数据结构不同

1.7中采用数组+链表,1.8采用的是数组+链表/红黑树,即在1.7中链表长度超过一定长度后就改成红黑树存储。

第二,扩容方式的实现

1.7扩容时需要重新计算哈希值和索引位置,1.8并不重新计算哈希值,巧妙地采用和扩容后容量进行&操作来计算新的索引位置。

第三,扩容与插入

1.7中是先扩容后插入新值的,1.8中是先插值再扩容

第四,扩容插入

1.7是采用表头插入法插入链表,1.8采用的是尾部插入法。

在1.7中采用表头插入法,在扩容时会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题;在1.8中采用尾部插入法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。

五、源码解析:ArrayList(扩容)

总述:
底层数据结构(简单,面试一般不作为单独考题,开放式考题回答以底层数据结构开始)
ArrayList五个参数和三个构造函数(简单,面试一般不作为单独考题,开放式考题可以接在后面)
ArrayList扩容+add/remove中的扩容操作(重点,面试可以作为单独考题,两个一起回答)
ArrayList快速报错机制/iterator()或foreach中必须使用迭代器来add/remove(重点,面试可以作为单独考题)

1、底层数据结构

底层数组,查询效率高,插入删除效率低(使用整个数组的方式扩容)
ArrayList实现了List接口它是一个**可调整大小的数组(数组插入的时候扩容,删除的时候减少容量,使用复制的方式,效率低,但是查询效率高)**可以用来存放各种形式的数据。并提供了包括CRUD在内的多种方法可以对数据进行操作但是它不是线程安全的,外ArrayList按照插入的顺序来存放数据。

金手指:List家底层数据结构
如何看出ArrayList底层是数组,add操作最后是 elementData[index] = element; 就是数组了,链表会设置next对象;
相对应的LinkedList底层是双向链表
在这里插入图片描述
Vector是线程安全的数组,看add就知道了
第一,使用synchronized关键字,保证同步操作原子性,所以线程安全
第二,elementData[elementCount++]=e; 所以底层是数组
在这里插入图片描述

2、ArrayList五个参数

金手指:ArrayList五个参数,三个初始化时,两个运行时:
第一,三个初始化时:
private static final int DEFAULT_CAPACITY = 10; static类型,定死了的
private static final Object[] EMPTY_ELEMENTDATA = {}; static类型,定死了的
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; staic类型,定死了的

第二,两个运行时:
elementData和size区别,一个是Object数组,实际容量,一个是int,数组容量(数组容量>=实际容量,size>=elementData.length)

3、ArrayList三个构造函数

金手指:ArrayList三个构造函数
第一,无参构造函数,elementData 为 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,且

第二,参数集合的构造函数,分两种情况:
集合为空,elementData 为 EMPTY_ELEMENTDATA;
集合不为空,elementData为传入的集合
第三,int类型容量的构造函数,分两种情况:
int为0,elementData为EMPTY_ELEMENTDATA
int不为0, this.elementData为int个数的Object对象,成为数组Object数组
金手指1:初始化的过程就是设置elementData这个Object数组这个elementData是类变量,是核心存储,后面扩容 add remove都会用到的,要引起注意
金手指2:三个构造函数,五种情况,设计的很优美

4、ArrayList扩容方法grow()

金手指:ArrayList扩容方法grow()修改代码,好理解
//ArrayList扩容的核心方法,此方法用来决定扩容量
private void grow(int minCapacity) {
int oldCapacity = elementData.length; // 实际元素个数记录到oldCapacity
int newCapacity = oldCapacity + (oldCapacity >> 1); // oldCapacity数字上扩大1.5倍 得到 newCapacity
newCapacity = Max (newCapcity,minCapacity); // 扩容 1.5*oldCapacity与传入参数比较,取出较大值
newCapacity = Min(newCapacity,hugeCapacity(minCapacity)) ; // 限制newCapacity 上限
// newCapacity 是局部变量,没什么用,唯一作用是使用newCapacity来设置elementData,elementData才是类变量,实际存储数组
elementData = Arrays.copyOf(elementData, newCapacity); // 复制数组 good 核心一句
}

grow 参数哪里来的?
本质上是从ensureCapacityInternal()方法中来的,add就是 size +1 ,addAll 就是size + 具体长度

扩容机制本质,就一句代码
elementData = Arrays.copyOf(elementData, newCapacity);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
原数组从0开始,目标数组从0开始,原数组复制 Math.min(original.length, newLength)个元素到目标数组中。

下面将针对三种情况对该方法进行解析:

  1. 默认构造方法:当前数组是由默认构造方法生成的空数组并且第一次添加数据。此时minCapacity等于默认的容量(10)那么根据下面逻辑可以看到最后数组的容量会从0扩容成10。而后的数组扩容才是按照当前容量的1.5倍进行扩容;
  2. 自定义容量构造方法+自定义数组的构造方法:当前数组是由自定义初始容量构造方法创建并且指定初始容量为0。此时minCapacity等于1那么根据下面逻辑可以看到最后数组的容量会从0变成1。这边可以看到一个严重的问题,一旦我们执行了初始容量为0,那么根据下面的算法前四次扩容每次都 +1,在第5次添加数据进行扩容的时候才是按照当前容量的1.5倍进行扩容。
    金手指:
    int oldCapacity = elementData.length; oldCapacity 为当前容量
    newCapcity = 0+0>>1 =0
    newCapacity = Max (newCapcity = 0 ,minCapacity = 1); =1 第一次
    1 + 1>>1 =2 第二次
    10 + 10>>1 = 2+ 1 = 3 第三次
    11 + 11>1 = 3+1 =4 第四次
    100 +100>1=4+2=6 第五次,开始1.5扩容

5、ArrayList快速报错机制/iterator()或foreach中必须使用迭代器来add/remove

金手指:ArrayList快速报错机制/iterator()或foreach中必须使用迭代器来add remove(源码解释,核心 int expectedModCount = modCount;)
源码解释:
从下面方法可以看到在迭代遍历的过程中都调用了方法checkForComodification来判断当前ArrayList是否是同步的。现在来举一个栗子,假设你往一个Integer类型的ArrayList插入了10条数据,那么每操作一次modCount(继承自父类AbstractList)就加一所以就变成10,而当你对这个集合进行遍历的时候就把modCount传到expectedModCount这个变量里,然后ArrayList在checkForComodification中通过判断两个变量是否相等来确认当前集合是否是同步的,如果不同步就抛出ConcurrentModificationException。所谓的不同步指的就是,如果你在遍历的过程中对ArrayList集合本身进行add,remove等操作时候就会发生。当然如果你用的是Iterator那么使用它的remove是允许的因为此时你直接操作的不是ArrayList集合而是它的Iterator对象。在代码后面将贴出前面提到的三种情况。此外在多线程也会存在这种情况,但是如果你在多线程中使用CopyOnWriteArrayList就可以避免了。

6、四个add中的扩容

四个Add操作中的扩容:
add(E e)末尾插入
末尾插入两步骤:底层grow多设置一个elementData元素,elementData = Arrays.copyOf(elementData, newCapacity); + 扩容多出来的元素正好存放新值
ensureCapacityInternal(size + 1); 你看 是size+1,底层grow多设置一个elementData元素,elementData = Arrays.copyOf(elementData, newCapacity);
elementData[size++] = e; 末尾插入,扩容多出来的元素正好存放新值,所以对外表现为末尾插入
add(int index,E element) 指定位置插入
//index表示element要插入的位置
public void add(int index, E element) {
//判断插入的位置是否当前数组长度或是小于0,是的话会抛出IndexOutOfBoundsException
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // 扩容
//每一次插入数据都要把相对于当前index后面的数据向后移动一位
System.arraycopy(elementData, index, elementData, index + 1, size - index);
// 原数组elementData从index开始,目标数组elementData从index+1开始,将原数组中从index开始的 size-index 个元素复制到目标数组中去
elementData[index] = element; // 设置中间空出来的这个index
size++; // 变量值修改
}
addAll(Collection<? extends E> c) 插入集合
public boolean addAll(Collection<? extends E> c) {
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew); // elementData数组扩容为size+numNew,先扩容,后插入
System.arraycopy(a, 0, elementData, size, numNew);
// 原数组a从0开始,目标数组elementData从size开始,元素组中numNew个元素复制到目标数组elementData中去
size += numNew; // 修改size记录
return numNew != 0;
}
addAll(int index, Collection<? extends E> c)
public boolean addAll(int index, Collection<? extends E> c) {
rangeCheckForAdd(index);
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew); // elementData扩容到size + numNew
int numMoved = size - index;
if (numMoved > 0)
System.arraycopy(elementData, index, elementData, index + numNew,
numMoved);
// 原数组elementData从index开始,目标数组elementData从index + numNew开始,元素复制numMoved个元素到目标数组中
// 对于中间的,新元素a从0开始,目标数组从index开始,原数组numNew个元素复制到目标数组
System.arraycopy(a, 0, elementData, index, numNew);
// 末尾插入,新元素a从0开始,目标数组从index开始,原数组numNew个元素复制到目标数组
size += numNew;
return numNew != 0;
}

7、四个remove中的扩容

四个remove()方法
remove(int index)
public E remove(int index) {
rangeCheck(index);//防止数组越界
modCount++;//用于快速报错机制
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 向前移动,原数组从index+1开始,目标数组从index开始,原数组中numMoved个元素复制到目标数组中
elementData[–size] = null; // clear to let GC do its work 消除过期对象的引用
return oldValue;
}
remove(Object o)
金手指:
1、如果使用remove(object)就要重写hashcode和equals,但是remove(index)不需要这样做,所以尽量采用remove(int index)
2、无论是remove(object)还是remove(index)源码里面都没有for/while循环,都只删除一个元素。
3、remove(object)
ArrayList在对Object对象删除操作上区分开了Null,
如果Object==null 用 == 比较
如果object != null,用equals比较
removeAll(Collection<?> c)
首先会对集合参数进行NPE判断,接着可以看到ArrayList通过contains方法来对两个集合数据进行循环比较。contains实际上就是调用indexOf方法,而indexOf方法又是调用的equals。紧接着拿到匹配的数据进行删除,值得一提的是这个方法可以删掉所有匹配的数据
public boolean removeAll(Collection<?> c) {
Objects.requireNonNull©;//判空,是的话抛出空指针异常
return batchRemove(c, false); // 传入false
}
private boolean batchRemove(Collection<?> c, boolean complement) {
final Object[] elementData = this.elementData;
int r = 0, w = 0; // r和w都从0开始
boolean modified = false;
try {
for (; r < size; r++)
if (c.contains(elementData[r]) == complement) // c.contains(elementData[r]) == false c表示要删除的,就是遍历所有elementData,对于任何一个elementData[r],不在要删除的里面,就记录下来
elementData[w++] = elementData[r];//拿到所有c容器中跟当前ArrayList匹配的数据,放到elementData里面,r和w都从0开始,重置elementData,包含就记录下面,不包含就不记录,这样就删去了
} finally {
// Preserve behavioral compatibility with AbstractCollection,
// even if c.contains() throws.
if (r != size) {//r!=size 表示上面的for循环遍历没有结束,抛出了异常,那么,elementData从当前的r开始,将后面的size-r元素全部复制到elementData从w开始的地方,表示后面的都是不需要删除的 good
System.arraycopy(elementData, r,elementData, w,size - r);
// 原数组从r开始,目标数组从w开始,原数组size-r个元素复制到目标数组elementData中
w += size - r; // 修改w = w + (size - r), 对于elementData数组,w是之前元素个数,(size-r)是从原数组复制过来的元素个数
}
if (w != size) {
// clear to let GC do its work
for (int i = w; i < size; i++)
elementData[i] = null;
modCount += size - w;
size = w;
modified = true;
}
}
return modified;
}
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null) //
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
金手指:
对于indexOf:
实参Object为null,直接用 == 比较
实参Object不为null,用equals比较
removeIf(Predicate<? super E> filter)
jdk1.8新增方法:
首先可以看到用到了BitSet,它的底层数据结构就是一个long[]数组,而long是64位的可以用来存储大的内容

六、使用层面:泛型

总述:
泛型三种(泛型接口+泛型类+泛型方法)+集合框架的泛型擦除+堆污染
面向面试(最重要的是,一定要易于语言叙述和背诵)

1、泛型接口+泛型类+泛型方法

泛型类+泛型接口+泛型方法
泛型类,是在实例化类的时候指明泛型的具体类型;同理,泛型方法,是在调用方法的时候指明泛型的具体类型 。意义上是一样的,泛型字母都是先声明后使用,只是范围不同,泛型类、泛型接口定义时声明的字母对类或接口内容均有效,泛型方法定义时声明的字母对方法内容有效

泛型方法:
金手指:
泛型方法: 在访问控制符和返回值之间
解释:因为在返回值之前只有关键字,不会使用到泛型T,但是从返回值开始,就开始使用到泛型T了,毕竟返回值自己就使用泛型T
判断真假泛型方法的key:是否在返回值前有尖括号这个标志

2、泛型上下限+泛型擦除

泛型上下限多出在函数参数列表中,既然出现在方法形参中,那么它的方法接收的实参也是一个泛型类对象
泛型上限extends表示当前类及其子类,泛型下限super表示当前类及其父类
原因:泛型上限extends的产生是因为泛型之间没有类似多态的思想,

// 泛型为Number只能接收Number,不能接收Integer,即使Intger是Number子类,所有有了泛型上限
// 泛型下限与泛型上限相对应,但使用super关键字
// 演示通配符 ?是一种类型实参,可以看做所有类的父类

泛型擦除=自动擦除(知道就好)+手动插入(新赋值的无泛型的list就可以同时add Integer和String了)
//泛型擦除包括自动擦除和手动擦除 //自动擦除是指在运行的时候就没有泛型了,知道就好(也称:泛型仅在编译时有效,运行时自动擦除)
//手动擦除:将有泛型的集合赋给不带泛型的集合,这时这个新赋值的无泛型的list就可以同时add Integer和String了
//注意:所有的泛型报错都是编译时报错,因为泛型只在编译时有效

3、堆污染

堆污染定义及其三种情况
Java堆污染的定义:Heap pollution(堆污染), 指的是当把一个不带泛型的对象赋值给一个带泛型的变量时,
就有可能发生堆污染,根据堆污染的定义,后面我们就是要构造堆污染,一共三种:

堆污染第一个情况——方法内部,直接把一个不带泛型的对象赋值给一个带泛型的变量,造成堆污染ClassCastException错误。

堆污染第二种情况——方法内部,将一个带泛型的变量赋值给一个不带泛型的变量,如果后面有操作,要将这个不带泛型的变量赋值给带泛型的变量,造成堆污染ClassCastException错误了。

堆污染第三种情况——方法调用过程中,方法形参为泛型数组时,因为java语言中不允许创建泛型数组,
方法内只能用不带泛型的数组接收带泛型的数组实参,造成泛型手动擦除,所以方法内的这个局部变量就是无泛型的变量,方法内的逻辑,如果有这个变量赋值给带泛型的变量,就造成堆污染ClassCastException错误了。

堆污染和泛型手动擦除关系:

不带泛型的对象赋值给带泛型的变量,堆污染ClassCastException错误;

带泛型的对象赋值给不带泛型的变量,泛型手动擦除。

4、泛型与多态

综上,泛型和多态的区别:
1、绑定对象不同:泛型总是和集合框架联系在一次(当然,程序员也可以自定义泛型类、泛型方法、泛型接口);
多态总是和类与类、类与接口、接口与接口的继承实现联系在一起,是一种向上转型。
2、编译时运行时:泛型是编译时有效,编译后自动擦除;多态一般是运行时绑定,运行时有效。
3、父子概念:任意时刻只能指定一种泛型且泛型没有父子概念,泛型为Number是不可以接收子类Integer类型变量;
任意时候只能指定一种对象类型但是有父子概念,多态保证父类形参可以接收子类实参。

猜你喜欢

转载自blog.csdn.net/qq_36963950/article/details/107898141