集合整体框架
1. List和Set区别
- List:有序的,按对象进入的顺序保存对象,可重复,允许多个为null的元素对象,可以使用
Iterator
取出所有元素,再逐一遍历,还可以使用get(int index)
获取指定下标的元素。 - Set:无序的,不可重复,最多允许一个null元素对象,取元素时只能用
Iterator
接口取得所有元素,再逐一遍历各元素。
2. ArrayList和LinkedList区别
- ArrayList:基于动态数组,连续内存存储,适合下标访问(随机访问);扩容机制:因为数组长度固定,超出长度存数据时需要新建数组,然后将老数组数据拷贝到新数组,如果不是尾部插入数据还会涉及到元素的移动(往后复制一份,插入新元素),使用尾插法并指定初始容量可以极大的提升性能,甚至超过LinkedList(需要创建大量的node对象)。
- LinkedList:基于链表,可以存储在分散的内存中,适合做数据插入以及删除操作,不适合查询:需要逐一遍历。
遍历LinkedList必须使用iterator
不能使用for循环,因为每次for循环体内通过get(i)
取得某一元素时都要对list重新进行遍历,性能消耗极大。
另外不要试图使用indexOf
等返回元素的索引,并利用其进行遍历,使用indexOf
对list进行了遍历,当结果为空时会遍历整个列表。
3. HashMap和HashTable有什么区别?其底层实现是什么?
区别:
HashMap
- 底层是基于数组+链表+红黑树,没有
synchronized
修饰,非线程安全的,默认容量是16,允许有空的键和值。 - 初始容量为16,扩容:
newsize=oldsize<<1
,容量一定为2的n次幂(保证为偶数,降低哈希冲突)。 - 当Map中的元素总数超过Entry数组的75%,触发扩容操作,为了减少链表的长度,元素分配更均匀,计算
index
方法:index=hash&(tab.length-1)
。 - 扩容针对整个Map,每次扩容的时候,原来数组中的元素依次计算存放位置,并重新插入。
- JDK1.8之前HashMap中采用的是头插法,效率高于尾插法,因为不需要遍历一次链表再进行数据插入。JDK1.8之后使用尾插法,之所以采用尾插法是因为要去判断链表的长度是否大于8,这种情况要考虑树化。
- HashMap解决哈希冲突的方法是采用:链表法。
- HashMap是先插入数据再判断是否需要扩容。
HashTable
- 底层是基于数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁(
synchroized
)住整个HashTable,效率低ConcurrentHashMap
做了相关优化。 - 初始容量为11(HashTable 的数组长度采用奇数导致的hash冲突会比较少,采用偶数会导致的冲突会增多!所以初始容量为 11),扩容为:
(tab.length << 1) + 1
(保证每次扩容结果均为奇数)。 - 计算index的方法:
index = (hash & 0x7FFFFFFF) % tab.length
二者区别
- HashMap不是线程安全的,HashTable是线程安全的(使用
synchronized
修饰) - HashMap允许将null作为Entry的key或者value,而HashTable不允许。
- HashMap的hash值重新计算过,HashTable直接使用
hashcode
。
// HashMap中重新计算hash值的算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- HashMap是Map接口的一个实现类,HashTable继承自Dictionary类。
- 两者求index的方式不同:都是为了使每次计算得到index更分散,这样可以降低哈希冲突。
HashMap:index = hash & (tab.length – 1)
HashTable:index = (hash & 0x7FFFFFFF) % tab.length
4. ConcurrentHashMap原理,jdk7和jdk8版本的区别
JDK7
数据结构:ReentrantLock+Segment+HashEntry
,一个Segment
中包含一个HashEntry
数组,每个HashEntry
又是一个链表结构。
元素查询:二次hash,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。
锁:Segment分段锁,Segment继承了ReentrantLock
,锁定操作的Segment,其他的Segment不受影响,并发度为Segment个数,可以通过构造函数指定,数组扩容不会影响其他的Segment。get
方法无需加锁,volatile
保证。
jdk8
数据结构:Synchronized+CAS+Node+红黑树
,Node的val和next都是用volatile
修饰,保证可见性查找、替换、赋值操作都使用CAS。
锁:锁链表的head节点,不影响其他元素的读写,锁粒度更细,效率更高,扩容时,阻塞所有的读写操作、并发扩容。读操作无锁:Node的val和next使用volatile
修饰,读写线程对该变量互相可见;数组用volatile
修饰,保证扩容时被读线程感知。
5. 如何使ArrayList保证线程安全?
- 方式一:
synchronizedList
底层相当于把集合的set、add、remove
方法加上synchronized
锁。
List<Object> list = Collections.synchronizedList(new ArrayList<>());
- 方式二
使用线程安全的CopyOnWriteArrayList
,其底层也是对增删改方法进行加锁
final ReentrantLock lock = this.lock;
- 方式三
自己写一个包装类,继承ArrayList
,根据业务,对set、add、remove
方法进行加锁控制。
6. Vector和ArrayList的区别
- 二者的初始容量均为0,即在调用空参构造函数实例化时,二者容量均为0,即第一次创建数组的时候,如果没有指定大小,容量即为0,只有当第一次添加的时候才扩容,即在第一次加入元素数据时附上初始容量值10(懒加载模式)。
- Vector扩容时,如果未指定扩容递增值
capacityIncrement
,或该值不大于0时,每次扩容为原来的1倍,否则扩容为capacityIncrement
值。 - ArrayList扩容时,每次扩容为原来的1.5倍。
- Vector是线程安全集合,通过对
remove、add
等方法加上synchronized
关键字来实现;ArrayList是非线程安全集合。
7. CopyOnWriteArrayList添加新元素是否需要扩容?具体是如何操作的?
CopyOnWriteArrayList
底层并非动态扩容数组,不能动态扩容,其线程安全是通过加可重入锁ReentrantLock
来保证的。- 当向
CopyOnWriteArrayList
添加元素的时候,线程获取锁的执行权后,add方法会新建一个容量为(旧数组容量+1)的数组,将旧数组数据拷贝到该数组中,并将新加入的数据放到新数组尾部。 CopyOnWriteArrayList
适用于读多写少的情况下(读写分离),因为每次调用修改数组结构的方法都需要重新新建数组,性能低。
8. HashMap和TreeMap的区别
- HashMap参考上面的介绍。
- TreeMap底层是基于平衡二叉树(红黑树),可以自定义排序规则,要实现
Comparator
接口,能便捷的实现内部元素的各种排序TreeMap(Comparetor c)
,但是性能比HashMap差。
9. Set和Map的关系
- 二者核心都是不保存重复的元素,存储一组唯一的对象。
- Set的每一种实现都是对应Map里面的一种封装。例如:HashSet底层对应的就是封装了HashMap,TreeSet底层封装了TreeMap。
10. HashMap底层为什么选择红黑树而不是其他树,比如二叉查找树,为什么不一开始就使用红黑树,而是链表长度到达8时数组容量大于64的时候才树化?
- 二叉查找树在特殊情况下也会变成一条线性结构,和原先的长链表存在一样的深度遍历问题,查找性能慢,如:
- 使用红黑树主要是为了提升查找数据的速度,红黑树是平衡二叉树的一种,插入新数据(新数据初始是红色节点插入)后通过左旋、右旋、变色等操作来保持平衡,解决单链表查询深度问题。
- 之所以一开始不用红黑树是因为当链表数据量少的时候,遍历线性链表比遍历红黑树消耗的资源少(因为少量数据,红黑树本身自旋、变色保持平衡也是需要消耗资源的),所以前期使用线性表。
- 链表的存储地址并不是连续的,当检索数据时,需要通过指针逐一next,直到找到目标数据。
- 如果hash冲突次数较少,那么遍历链表耗费的时间并不多,但是一旦hash冲突次数比较多,导致形成的链表长度很长,那么遍历长链表花费时间就需要很多。
- 将长链表转化为一颗红黑树,是因为红黑树是一种特殊的二叉树,二叉树可以对半查找,理想情况下可以直接将查询耗时折半。
- 之所以一开始不直接使用红黑树,是因为树结构占有的存储空间肯定要比链表大很多,因此当链表长度较短时,没必要树化。
- 链表和红黑树的取舍完全是出于对时间效率和空间大小的一种权衡。
红黑树(知识点补充)
红黑树并不是一个完美平衡二叉查找树,上图中,根节点P的左子树显然比右子树高。但左子树和右子树的黑节点的层数是相等的,也就是说任意一个节点到叶节点的路径都包含数量相同的黑节点,我们称红黑树这种平衡为黑色完美平衡。
红黑树性质:
- 每个节点要么是黑色,要么是红色。
- 根节点是黑色
- 每个叶子结点(NIL)是黑色。
- 每个红色节点的两个子节点一定都是黑色,不能有两个红色节点相连。
- 任意一节点到每个叶子节点的路径都包含数量相同的黑节点,俗称:黑高。
11. 为什么HashMap容量必须是2的N次幂?如果输入值不是2比如10会怎么样?
为什么HashMap容量必须是2的N次幂?
核心目的是为了使插入的节点均匀分布,减少hash冲突。
HashMap构造方法可以指定集合的初始化容量大小,如:
// 构造一个带指定初始容量和默认负载因子(0.75)的空 HashMap。
HashMap(int initialCapacity)
当向HashMap中添加一个元素的时候,需要根据key的hash值,去确定其在数组中的具体桶位(寻址算法)。HashMap为了存取高效,减少碰撞,就是要尽量把数据分布均匀,每个链表长度大致相同,这个实现的关键是把数据存到哪个链表中的算法。
这个算法实际就是取模运算:hash%tab.length
,而计算机中取余运算效率不如位移运算,所以在源码中做了优化,使用hash&(tab.length-1)
来寻找桶位,而实际上hash % length
等于 hash & ( length - 1)
的前提是length必须为2的n次幂。
原因总结:
- 当根据 key 的 hash 值寻址计算确定桶位下标 index 时,如果HashMap的数组长度 tab.length 是 2 的 n 次幂数,那么就可以保证新插入数组中的数据均匀分布,每个桶位都有可能分配到数据,而如果数组长度不是 2 的 n 次幂数,那么就可能导致一些桶位上永远不会被插入到数据,反而有些桶位频繁发生 hash 冲突,导致数组空间浪费,冲hash 突概率增加。
- 一般我们人的逻辑寻找数组桶位下标 index ,往往会采用取模运算的方式来确定 index,即
index = hash % length
,然而计算机进行取模预算的效率远不如位运算,因此需要被改进成hash & (length - 1)
的方式寻址。本质上,两种方式计算得到的结果是相同的,即:hash & (length - 1) = hash % length
。
因此,HashMap 数组容量使用 2 的 n 次幂的原因,就是为了使新插入的数据在寻址算法确定桶位下标时,尽量保证新数据能均匀的分布在每个桶位上,尽量降低某个桶位上频繁发生 hash 冲突的概率。毕竟某个桶位中的 hash 冲突次数越多,桶内的链表长度越长,这样导致数据检索的时候效率大大降低 (因为数组线性查询肯定要比链表快很多)。
如果创建HashMap对象时,输入的数组长度length是10,而不是2的n次幂会怎么样?
这种情况下,HashMap双参构造函数会通过tableSizeFor(initialCapacity)
方法,得到一个最接近length且大于length的2的n次幂。
12. HashMap计算key的hash值,是怎么设计的?为什么要将hashCode的高16位参与运算?
HashMap中重新计算hash值的方式如下:
static final int hash(Object key) {
int h;
// 如果key为null,则hash值为0,
// 否则调用key的hashCode()方法计算出key的哈希值然后赋值给h,
// 然后与 h无符号右移16位后的二进制数进行按位异或 得到最终的hash值,
// 这样做是为了使计算出的hash更分散,
// 为什么要更分散呢?因为越分散,某个桶的链表长度就越短,之后生成的红黑树越少,检索效率越高!
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
将 key 的 hashCode 的高 16 位和 hashCode 低 16 位 进行异或(XOR)运算,最终得到新的 hash 值。
为什么要这样进行操作?
如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突,所以这里把高低位都利用起来,从而解决了这个问题。
13. 说一说对hash算法的理解?以及什么是hash冲突。
hash的基本概念就是把任意长度的输入,经过hash算法之后,映射成固定长度的输出。
在程序中可能会碰到两个value值经过hash算法计算之后,算出了同样的hash值,这种情况就叫hash冲突。
14. 说一说resize扩容时,旧数组元素向新数组中迁移的方式
HashMap进行扩容时,会伴随一次重新hash分配,并且会遍历旧数组中所有的元素,并将其迁移到扩容后的新数组中,旧数组中的数据迁移有三种情况:
- 当前桶中没有发生hash冲突,只有一个元素:
这种情况下,HashMap使用的rehash
方式非常巧妙,因为每次扩容都是翻倍,与原来计算的(n - 1) & hash
的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到原位置+旧容量这个位置。
正是因为这样巧妙的rehash方式,既省去了重新计算hash的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,在resize的扩容过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中。 - 当前桶位中发生了hash冲突,并且形成了链表,但不是红黑树。
这时候,将桶中的链表拆分成高位链和低位链两个链表依次放入扩容后的数组中。
- 桶位中形成了红黑树
感谢并参考:
https://csp1999.blog.youkuaiyun.com/article/details/117192375