Java 集合面试题总结(2022最新版)
关于作者
🐶 程序猿周周
⌨️ 短视频小厂BUG攻城狮
🤺 如果文章对你有帮助,记得关注、点赞、收藏,一键三连哦,你的支持将成为我最大的动力
关于本文
本文是 Java 面试总结系列的第⑧篇文章,该专栏将整理和梳理笔者作为 Java 后端程序猿在日常工作以及面试中遇到的实际问题,通过这些问题的系统学习,也帮助笔者顺利拿到阿里、字节、华为、快手等Offer,也祝愿大家能够早日斩获自己心仪的Offer。由于笔者能力有限,如若有错误或疏忽还望各位大佬们不吝指出…
标题 | 地址 |
---|---|
MySQL数据库面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122910606 |
Redis面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122934938 |
计算机网络面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122973684 |
操作系统面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122994599 |
Linux面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122994862 |
Spring面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123016872 |
Java基础面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123080189 |
Java集合面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123171501 |
Java并发面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123266624 |
文章目录
- 1 容器概述
-
-
- 1、什么是集合以及使用的好处?
- 2、Java 有哪些常见的集合类?
- 3、数组和集合的区别?
- 4、集合是否可以存储 NULL?
-
- 2 Collection
-
- 2.1 List
-
- 1、ArrayList 和 LinkedList 的区别?
- 2、ArrayList 和 Vector 的区别?
- 3、如何边遍历边删除集合中的元素?
- 4、Iterator 和 ListIterator 的区别?
- 5、什么是 RandomAccess 接口?
- 6、什么是 Fail-Fast 和 Fail-Safe?
-
- fail-fast 机制
- fail-safe 机制
- 7、ArrayList 中 elementData 为何被 transient 修饰?
- 8、ArrayList 添加元素的流程或扩容机制?
- 2.2 Set
- 3 Map
-
- 3.1 HashMap
-
- 1、底层数据结构是什么?
- 2、链表和红黑树之间的转换?
- 3、阈值为何是 8 和 6?
- 4、为何使用红黑树而非二叉树或平衡树?
- 5、put 操作的执行流程?
- 6、hash 值如何计算?
- 7、常用的 Hash 函数?
- 8、HashMap 容量为何是 2 的倍数?
- 9、为何负载因子是 0.75?
- 10、HashMap 是如何进行扩容的?
- 11、JDK 1.8 中有哪些优化?
- 12、HashMap 是否线程安全以及如何解决?
- 13、如何设计一个 HashMap?
- 3.2 ConcurrentHashMap
-
- 1、说一下 ConcurrentHashMap 的实现?
-
- 1.7 分段锁
- 1.8 CAS+Synchronized
- 2、为什么使用 Synchronized 替换 ReentrantLock?
- 3、put 操作逻辑?
- 4、get 操作是否需要加锁?
- 5、Key 和 Value 不支持 null 的原因?
- 6、size 方法如何计算大小?
- 3.3 Others
-
- 1、HashMap 节点是否有序?
- 2、LinkedHashMap 如何实现有序?
- 3、TreeMap 如何实现有序?
1 容器概述
1、什么是集合以及使用的好处?
集合是用于存放对象的容器,而集合类是 Java 的一种数据结构,常用的集合类定义在 java.util 包中。
需要注意的是,集合类只能存放对象,不能存放基本数据类型,且是对象的引用,而非对象本身。
好处
- 提供高效的的数据结构和算法,挺高程序运行效率;
- 提供通用 API 能力,降低开发和维护成本;
2、Java 有哪些常见的集合类?
Java 中的容器集合类主要包括 Collection(集合) 和 Map(映射) 两大类,而 Collection 接口又包括 Set(无序不重复) 和 List(有序可重复) 两个子接口。
1)List 接口的实现类主要有:ArrayList、LinkedList、Stack 以及 Vector 等;
2)Set 接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等;
3)Map 接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap 以及 Properties 等。
3、数组和集合的区别?
-
数组固定长度,集合成都可变;
-
数组可以存放基本数据类型,集合只能存储引用数据类型;
-
数组存储的元素必须是同一个数据类型,集合支持存储不同数据类型。
4、集合是否可以存储 NULL?
1)AList 接口 ArrayList、LinkedList 以及 Vector 等都可以存储多个 null;
2)Set 接口中 HashSet、LinkedSet 可以存储一个 null,TreeSet 不能存储 null;
3)Map 接口中 HashMap、LinkedHashMa 的 key 与 value 均可以为 null。Treemap 的 key 不可以为 null,value 可以为 null。HashTable、ConcurrentHashMap 的 key 与 value 均不能为 null。
2 Collection
2.1 List
1、ArrayList 和 LinkedList 的区别?
二者都是 List 接口的实现,顾名思义,从数据结构上看,它们底层一个是数组,一个是链表的数据结构。前者是数组队列,相当于动态数组;后者为双向链表结构,也可当作堆栈、队列、双端队列。
1)访问效率的不同
- 对于随机访问(get 和 set)操作,ArrayList 效率比 LinkedList 更高,因为 LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找。
- 对于数据增加删除(add 和 remove)操作,LinkedList 的效率又会比 ArrayList 更高,因为数组在其中进行增删操作时,会对操作点之后所有数据的下标索引造成影响,需要进行数据的移动。
2)利用效率的不同
从利用效率来看,ArrayList 自由性较低,因为它需要有手动或默认一个固定大小的容量,但是它的使用比较方便,只需要创建,然后添加数据,通过调用下标进行使用;而 LinkedList 自由性较高,能够动态的随数据量的变化而变化,但是它不便于使用。
3)控件开销的不同
ArrayList 控件开销在于底层数组中预留了一定空间;而LinkList主要控件开销在于需要存储结点信息以及结点指针信息。
2、ArrayList 和 Vector 的区别?
- 同步性不同
Vector 是线程安全的,它的方法之间都加了 synchronized 关键字修饰,而 ArrayList 是非线程安全的。因此在不考虑线程安全情况下使用 ArrayList 的效率更高。
- 数据增长不同
ArrayList 和 Vector 都有一个初始的容量大小,并在必要时对数组进行扩容。但 Vector 每次增长两倍,而 ArrayList 增长 1.5 倍。
3、如何边遍历边删除集合中的元素?
唯一正确的做法是使用迭代器 Iterator.remove() 方法。
Iterator<Integer> it=list.iterator();
while(it.hasNext()){
it.remove();
}
如果直接使用 foreach 循环遍历时删除时,该语法糖会自动生成一个 Iterator 进行遍历集合,但 Iterator 不允许遍历中的元素被修改,故会抛出 ConcurrentModificationException 异常。
for(Integer i : list){
list.remove(I);
}
4、Iterator 和 ListIterator 的区别?
ListIterator 是一个继承于 Iterator 接口,功能更加强大的迭代器,但只能用于各种 List 类型的访问。
故二者的主要区别:
- Iterator 支持 List 和 Set,而 ListIterator 只支持 List;
- Iterator 只能单向访问,而 ListIterator 可以双向前后遍历;
- ListIterator 继承了 Iterator 的所有方法,并新增了如添加 add()、替换 set() 等更多好用方法。
5、什么是 RandomAccess 接口?
RandomAccess 接口是 Java 集合框架中为了标记 List 集合是否支持随机访问而设计。
即只要 List 集合实现这个接口,就能支持快速随机访问,按位置读取元素的平均时间复杂度是 o(1),如 ArrayList。而没有实现这个接口,表示不支持随机访问,如 LinkedList。
通过源码和测试,我们可以发现:支持 RandomAccess 接口的集合使用 for 循环比使用迭代器 Iterator 更快,反之,不支持 RandomAccess 接口的集合使用 Iterator 或 foreach 更优。
6、什么是 Fail-Fast 和 Fail-Safe?
在 Collection 集合类中,有线程安全和线程不安全这两大类型。对于线程不安全的类,并发情况下可能会出现快速失败(fail-fast) 情况,而线程安全的类,可能出现安全失败(fail-safe) 的情况。
这里需要注意的是,以上情况都是针对并发而言。
fail-fast 机制
当遍历一个集合对象时,如果集合对象的结构被修改了,就会抛出 ConcurrentModificationExcetion 异常。但需要注意的是,迭代器的快速失败行为无法得到保证,因为一般来说,不可能对是否出现不同步并发修改做出任何硬性保证。快速失败迭代器则会尽最大努力抛出 ConcurrentModificationException。
我们以 ArrayList 为例,ArrayList 继承自 AbstractList 类,AbstractList 内部有一个 modCount 属性,代表修改的次数。且每一次 add、remove 操作都会使得 modCount 自增。
public boolean add(E e) {
modCount++; // here
add(e, elementData, size);
return true;
}
迭代器对象有一个属性 expectedModCount 属性,它被赋值为该方法调用时 modCount 值。这也就意味着 expectedModCount 是 modCount 在这个时间点的快照值,且这个值在 iterator 内部是不会再发送变化的。
private class Itr implements Iterator<E> {
int cursor = 0; // Index of next elemnet to be returned.
int lastRet = -1; // Index of last elemnet returned.
int expectedModCount = modCount;
}
我们在使用迭代器调用 next() 方法时,第一步就是检查 modCount 和 expectedModCount 是否相等,如果不相等就立即抛出 ConcurrentModificationException。到这就很好理解了,如果我们使用迭代器的同时使用 add、remove 操作了集合,则会导致 modCount 发生变化,从而导致抛出异常。
public E next() {
checkForComodification();
// ......
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
至于为什么快速失败行为无法得到保证也就不难理解,因为虽然集合发生了变化,但如果没有调用 next() 方法,则也不会抛出异常。
即这个机制的主要目的是告诉你,当前迭代器要进行操作是有问题的,因为集合对象现在的状态发生了改变。
那没为何使用迭代器 remove() 方法不会触发该机制?
通过源码不难看出,remove() 方法没有进行 modCount 的检查,并手动把 expectedModCount 值修改成了 modCount,这又保证了下一次迭代的正确。
ref
fail-safe 机制
Fail-Safe 机制主要是针对线程安全的集合类(如 ConcurrentHashMap),其的出现的目的是为了解决 fail-fast 抛出异常处理起来不方便的问题。
并发容器的 iterator() 方法返回的迭代器内部都是保存了该集合对象的一个快照副本,并且没有 modCount 等数值做检查。这也造成了并发容器迭代器读取的数据是某个时间点的快照版本。你可以并发读取,不会抛出异常,但无法不保证遍历读取的值和当前集合对象的状态是一致的。 同时创建集合拷贝还需要时间和空间上的额外开销。
7、ArrayList 中 elementData 为何被 transient 修饰?
8、ArrayList 添加元素的流程或扩容机制?
private int newCapacity(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity <= 0) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
return Math.max(DEFAULT_CAPACITY, minCapacity);
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return minCapacity;
}
return (newCapacity - MAX_ARRAY_SIZE <= 0)
? newCapacity : hugeCapacity(minCapacity);
}
上面是 ArrayList 扩容时计算新容量的方法,其中 minCapacity 是当前 size + 1,通过 oldCapacity + (oldCapacity >> 1)
我们可以知道新容量是老容量的 1.5 倍。
同时通过 MAX_ARRAY_SIZE 常量可知 ArrayList 的最大长度是 Integer.MAX_VALUE。
ref
2.2 Set
3 Map
3.1 HashMap
1、底层数据结构是什么?
在 JDK 1.7 中,HashMap 底层使用数组+链表的形式进行存储,在 JDK 1.8 之后,增加了红黑树,即数组+链表+红黑树的形式存储元素。
其中桶数组是用来存储元素,链表是用来解决哈希冲突,而红黑树是用来提高查询效率的。
PS 可能会被问到解决 Hash 冲突的常见方法?
- 链地址法
- 开放定址法:
- 再哈希法
- 建立公共溢出区
2、链表和红黑树之间的转换?
- 链表长度大于 8 且数组长度大于 64,则将链表转换成红黑树;
- 链表长度小于 6 时会将红黑树转换成链表。
3、阈值为何是 8 和 6?
作者在源码中注释到:TreeNodes 占用空间是普通 Nodes 的两倍, 所以只有当普通桶包含足够多的节点时才会转成 TreeNodes。
当 hashCode 离散性足够好时,树型桶用到的概率非常小,因为数据均匀分布在每个桶中,几乎不会有链表长度会达到阈值。但是在随机 hashCode 情况下,离散性可能会变差,然而又不能阻止用户使用这种不好的 hash 算法,因此就可能导致不均匀的数据分布。不过理想情况下随机 hashCode 算法下所有桶中节点的分布频率会遵循泊松分布,作者还计算出相关概率,即当链表长度达到 8 个元素的概率为 0.00000006,几乎是不可能事件。
因此,源码定义了在 binCount>=7(从 0 开始共 8 个数),即该槽插入第 9 个节点(超过 8 个)时转为红黑树。
至于为何树转链表的阈值选为 6?主要还是为了避免红黑树和链表之间频繁转换带来的性能损耗。
4、为何使用红黑树而非二叉树或平衡树?
相比普通二叉树,红黑树是一棵平衡树,它的添加、删除和查找操作最差时间复杂度为 o(logn),避免了普通二叉树最差情况下 o(n) 的复杂度。
平衡二叉树是比红黑树更加严格的平衡树,为了达到平衡需要进行更多的旋转次数,所以红黑树插入删除操作效率更高。
ref
5、put 操作的执行流程?
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
从上边的源码我们可以总结出 HashMap 在进行 put 操作时的一个大体流程:
1)首先通过 hash() 函数方法计算出 key 的 hash 值;
2)判断桶数组是否为空或长度为 0,是则使用 resize() 方法进行初始化;
3)根据 tab[i = (n - 1) & hash]
(n 是数组长度)计算 key 在数组中对应的下标值,如果该位置没有存放数据,则使用 newNode() 方法直接插入当前数据;
4)如果对应下标已存在数据,则需要分三种情况处理碰撞:
- 对应数组当前 node 节点与新元素 hash 值相等,且 key 值也相等,则使用新元素 value 值覆盖旧值;
- 对应数组当前 node 节点是树型节点,则将新元素直接插入红黑树中;
- 对应数组当前 node 节点是链表节点,则遍历此链表到尾部,并将新元素插入到链表尾部。插入成功之后再根据链表长度与树化阈值的比较判断是否需要进行树化;
5)最后在所有元素处理完毕后,还需判断当前元素大小是否超过扩容阈值,超过则调用 resize() 方法扩容。
6、hash 值如何计算?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
当 key 为 null 时 hash 值为 0,否则,hash 值就是 key 的 hashCode 值异或 key 的 hashCode 无符号右移 16 位的结果。即 hash 值是 hashCode 值的高 16 位和低 16 位的异或结果。通过异或处理,避免了只靠低位数据计算 hash 值时导致的冲突,计算结果由高低位共同决定,可以使元素分布更均匀。
至于为什么扰动函数可以降低哈希碰撞发生的概率,这是因为 hash 值是一个 int 类型整数,拥有 40 量级的映射空间。由于 HasMap 桶数组初始长度才 16,就需要使用 hash 值对数组长度进行取模计算,通过前文源码可知下标计算方式是 (n-1) & hash
,该运算的意义是保留 hash 值二进制中的最后 logn 位,高位直接清零。当只保留 hash 值最后几位的信息时,如果遇到 hash 函数不太理想时,如等差数列等漏洞,导致最后几位成规律重复,则将面临严重的碰撞问题。
故 HashMap 通过 hashCode 的高低 16 位进行异或处理,增大最终 hash 值的随机性,以解决上述问题。
7、常用的 Hash 函数?
- 除留余数法
也是 HashMap 中使用的方法,计算公式为 H(key) = key % p (p<=n)
,关键字除以一个不大于哈希表长度的正整数 p,所得余数为地址。
- 直接定址法
直接根据 key 映射到对应哈希表位置,如 i 就直接放到下标 i 的位置。
- 数字分析法
根据 key 的某些数字(如十位或者百位)作为映射的位置。
- 平方取中法
根据 key 平方的中间几位作为映射的位置。
- 折叠法
把 key 分割成位数相同的几段,然后将其叠加和作为映射的位置。
8、HashMap 容量为何是 2 的倍数?
主要有两个原因:
1)为了方便哈希取余,因为当除数是 2 的倍数时,可以将 %
取模运算将换成 &
位运算,以达到更高的运算效率;
2)扩容后容量也是 2 的倍数,则每个桶中的节点要么保留在当前位置,要么移动 2 次幂的位置(即 原位置+原数组容量),这样就可以快速简洁的完成数据迁移。
9、为何负载因子是 0.75?
为了减少哈希冲突发生的概率,当 HashMap 中的元素个数达到一个阈值 threshold 的时候就会触发扩容,把所有元素 rehash 之后再放在扩容后的容器中,这是一个相当耗时的操作。
这个阈值 threshold 又与当前元素个数(newCap)以及负载因子(loadFactor)正相关。至于为何选址 0.75 作为负载因子,简单来讲这是出于对时间和空间成本综合考量。
1)如果我们设的负载因子比较大,元素比较多时,扩容时桶数组没有发生碰撞的位置较少,查找的时间成本就增加了。
2)如果设置的比较小,元素又比较少时,数组还有足够空位的时候就发生了扩容,发生哈希碰撞的概率就降低了,查找时间成本降低,但是就需要更多的空间去存储元素,空间成本就增加了。
10、HashMap 是如何进行扩容的?
我们在 HashMap 是如何进行 put 操作中讲到,map 在插入新元素之后还会进行容量检查,当超过阈值时,就是调用 resize()
进行扩容处理。其主要作用:初始化桶数组或对数组进行 2 倍的扩容。
if (e.next == null)
// 数组当前位置未发生碰撞,直接分配到新位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 分为 lo、hi 两个新链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 遍历节点,放到对应的新链表中
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// lo 链表放到新数组现有位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// hi 链表放到 +oldCap 的位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
由上面一段扩容方法中的核心代码可知,由于数组扩容前后长度均是 2 的次幂,则看节点 hash 值新增的那一位是 0 还是 1,若是 0 则在新数组索引下标不变,是 1 则变成当前下标 j + oldCap
。
如此巧妙的设计不仅能把冲突节点随机分配到数组不同位置上,又省略了重新计算节点位置下标的时间,该设计思路也常用于数据库的分库分表中。
11、JDK 1.8 中有哪些优化?
- 数据结构
将原来数组+链表
改成了数组+链表或红黑树
的结构,用于降低查找操作的时间复杂度。
- 插入方式
put 操作链表的插入方式从头插法改成了尾插法,简单说法就是 1.7 将新元素直接放到链表头部,而 1.8 插入到链表尾部。这是因为头插法扩容时会使链表发生反转,多线程环境下会产生环。
ref 并发下HashMap头插会造成死循环情况说明
- reHash
扩容时 1.7 需要对数组中所有节点重新 hash 计算在新数组中的位置,而 1.8 采用了更简单的位运算方式,提高扩容的效率。
- 扩容时机
1.7 在插入时会先判断是否需要扩容,再插入,而 1.8 会先进行插入,再判断是否需要扩容。
- hash函数
1.7 做了四次移位和四次异或,而 1.8 只需要一次。因为 4 次的边际效用也不大,改为一次可提升效率。
12、HashMap 是否线程安全以及如何解决?
HashMap 是非线程安全的,可能会发生这些问题:
- 多线程下扩容死循环。由 JDK 1.7 的头插法导致,1.8 中以解决。
- 多线程的 put 可能导致元素的丢失。
如果两个线程计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致前一个元素的丢失。
- put 和 get 并发时,可能导致 get 为 null。
线程 1 执行 put 时,因为元素个数超出 threshold 而导致 rehash,线程 2 此时执行 get,有可能导致这个问题。这个问题在 JDK 1.7 和 JDK 1.8 中都存在。
如何解决 HashMap 非线程安全问题
我们可以使用 Java 中线程安全的 Map,如 HashTable、Collections.SynchronizedMap 以及 ConcurrentHashMap 等。
- HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大;
- Collections.SynchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
- ConcurrentHashMap 在 JDK 1.7 中使用分段锁,在 JDK 1.8 中使用 CAS + Synchronized。
13、如何设计一个 HashMap?
无需慌张,这题本质是考察 Hash 冲突的处理。
3.2 ConcurrentHashMap
1、说一下 ConcurrentHashMap 的实现?
首先 ConcurrentHashMap 在 JDK 1.7 和 1.8 中实现方式是完全不同的。其中 1.7 四基于分段锁实现,而 1.8 是基于CAS+Synchronized实现。
1.7 分段锁
在 JDK1.7 中,ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成(Segment 继承了 ReentrantLock,是一种可重入锁。HashEntry 则用于存储键值对数据)。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组 ,每个 HashEntry 是一个链表结构的元素,因此 JDK1.7 的 ConcurrentHashMap 依旧是一种数组+链表结构。
当对 HashEntry 数组的数据进行修改时,整个流程和 HashMap 非常类似,只不过是先定位到具体的 Segment,然后通过 ReentrantLock 去操作而已,后面的流程,就和 HashMap 基本一致。这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全(分段锁)。
1.8 CAS+Synchronized
JDK 1.8 中实现线程安全不是在数据结构上下功夫,它的数据结构和 HashMap 一样的是数组 + 链表 + 红黑树。它实现线程安全的关键点在于 put 流程。
在锁的实现上,采用 CAS 操作和 Synchronized 锁实现更加低粒度的锁,将锁的级别控制在了更细粒度的 table 元素级别,也就是说只需要锁住这个链表的头节点,并不会影响其他的 table 元素的读写,大大提高了并发度。
2、为什么使用 Synchronized 替换 ReentrantLock?
-
Java 开发者从未放弃过 Synchronized 关键字的优化,在 JDK1.8 Synchronized 锁的性能得到了很大的提高,并且 Synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换,因此并非是我们认为的重量级锁。
-
减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
3、put 操作逻辑?
这里主要讲讲 JDK 1.8 中的实现。
4、get 操作是否需要加锁?
不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。
这也是比其它并发集合比如 Hashtable、Collections.synchronizedMap() 效率高的原因之一。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ROOGzLcn-1645970391210)(https://segmentfault.com/img/bVcOaHA)]
5、Key 和 Value 不支持 null 的原因?
我们先说 为何 value 不能为 null。因为 ConcurrentHashMap 是用于多线程的,如果 get(key) 方法到了 null,就无法判断存在的 value 是 null,还是没有找到对应的 key 而为 null,就有了二义性。
6、size 方法如何计算大小?
- 1.7
虽然 count 变量是被 volatile 修饰的,但是并不是简单的把所有 Segment 的 count 值相加。因为有可能在累加过程中 count 值发生了改变,那么此时结果就不正确了。但是也不能直接锁住,这样效率太低。因此在 JDK1.7 中的做法是先尝试 2 次通过不锁住 Segment 的方式来统计各个 Segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有Segment 的大小。
那么 ConcurrentHashMap 是如何判断在统计的时候容器是否发生了变化呢?
使用 modCount 变量,在 put、remove 方法里操作元素前都会将变量 modCount++,那么在统计大小前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。
- 1.8
由于没有 segment 的概念,所以只需要用一个 baseCount 变量来记录 ConcurrentHashMap 当前节点的个数。
1)先尝试通过CAS 更新 baseCount 计数;
2)如果多线程竞争激烈,某些线程 CAS 失败,那就 CAS 尝试将 cellsBusy 置 1,成功则可以把 baseCount 变化的次数暂存到一个数组 counterCells 里,后续数组 counterCells 的值会加到 baseCount 中;
3)如果 cellsBusy 置 1 失败又会反复进行 CAS baseCount 和 CAS counterCells 数组。
3.3 Others
1、HashMap 节点是否有序?
HashMap 是无序的,根据 hash 值随机插入。如果想使用有序的 Map,可以使用 LinkedHashMap 或者 TreeMap。
2、LinkedHashMap 如何实现有序?
LinkedHashMap 维护了一个双向链表以及头尾两节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点。
3、TreeMap 如何实现有序?
TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序,内部是通过红黑树来实现。所以要么 key 所属的类实现 Comparable 接口,或者自定义一个实现了 Comparator 接口的比较器,传给 TreeMap 用于 key 的比较。