关于ConcurrentHashMap,我保证这是最全的面试题!
1、Java中常用的map
在Java中,Map是一个存储键值对(key-value pairs)的接口,常用于存储元素集合,其中每个键映射到一个值。Java提供了几种不同的实现,每种都有其特定的用例和特性。以下是一些最常用的Map实现:
1. HashMap
- 特点:基于哈希表实现,允许null键和null值。不保证映射的顺序;随着时间的推移,这个顺序可能会改变。
- 时间复杂度:提供常数时间的性能,对基本操作(get和put)。
- 线程安全:不是线程安全的,如果需要在多线程环境中使用,可以通过Collections.synchronizedMap方法来同步。
2. LinkedHashMap
- 特点:基于哈希表和链表实现,保持插入顺序或者访问顺序(取决于构造函数的一个参数)。
- 时间复杂度:略低于HashMap,因为需要维护元素的顺序。
- 线程安全:不是线程安全的。
3. TreeMap
- 特点:基于红黑树实现。按照键的自然排序或者构造时提供的Comparator进行排序。
- 时间复杂度:提供对数时间的性能,对于包含了大量元素的Map来说,这个时间可能会很关键。
- 线程安全:不是线程安全的。
4. Hashtable
- 特点:是早期的Java集合框架的一部分。与HashMap类似,但是它是同步的。
- 时间复杂度:由于同步,性能可能比HashMap要低。
- 线程安全:是线程安全的,但是现在较少使用,因为有更好的选择(如ConcurrentHashMap)。
5. ConcurrentHashMap
- 特点:是一个线程安全的HashMap。使用分段锁提供更高的并发度。
- 时间复杂度:在多线程环境下,性能高于Hashtable。
- 线程安全:是线程安全的。
6. WeakHashMap
- 特点:键是弱键(weak keys),当没有其他引用存在时,键/值对可以被垃圾回收器回收。
- 用途:主要用于缓存实现,其中键是外部对象,不阻止这些键被垃圾收集器回收。
7. EnumMap
- 特点:键是枚举类型,内部使用数组实现。非常快速和紧凑。
- 用途:当键是枚举类型时,这是一个优秀的选择。
8. IdentityHashMap
- 特点:使用==比较键,而不是equals()方法。
- 用途:其特殊行为使得它适用于特定的技术用途,如拓扑排序或对象图遍历。
使用场景选择
- 性能:如果不需要排序并且不是在多线程环境,通常首选HashMap。
- 排序:如果需要键排序,应使用TreeMap。
- 插入顺序:如果你想基于插入顺序或最近最少使用(LRU)策略来访问元素,可以使用LinkedHashMap。
- 并发性:在多线程环境中,ConcurrentHashMap是更好的选择。
- 弱引用键:如果需要键是弱引用,可以使用WeakHashMap。
- 枚举键:当键是枚举类型时,使用EnumMap。
Java的集合框架提供了各种各样的Map实现,以满足不同的需求。在选择合适的Map实现时,应该考虑你的具体需求,如线程安全、排序、插入顺序保持、键的比较方式等。
2、为什么会hash冲突
Hash冲突,也称为哈希碰撞,是指不同的输入值经过哈希函数处理后得到了相同的哈希值。这种现象在使用哈希表的数据结构时尤其常见。
哈希函数原理
哈希函数是将输入(通常是字符串)转换为一定范围内的整数,这个整数被称为哈希值。理想的哈希函数具有以下性质:
- 高效计算:哈希函数应该能够快速计算出输入值的哈希值。
- 均匀分布:哈希函数应该将输入值均匀分布在所有可能的哈希值中,以减少冲突的可能性。
- 确定性:相同的输入值每次计算得到的哈希值都应该相同。
- 不可逆:从哈希值不应能够反推出原始输入值。
哈希冲突的原因
哈希冲突发生的主要原因是由于“鸽巢原理”(Pigeonhole Principle)所描述的现象。鸽巢原理指出,如果你有更多的鸽子(输入值)比鸽巢(哈希值范围)多,至少有一个鸽巢里会有多于一个的鸽子。同样地,如果哈希表有N个可能的哈希值,一旦我们尝试放入超过N个不同的键值对,就必然会发生至少一个哈希冲突。
冲突的影响及解决
哈希冲突会影响哈希表的性能,特别是影响查找、插入和删除操作的速度,因为当哈希值相同时,需要通过其他手段区分不同的元素。
1. 链接法(Chaining)
链接法是一种通过在每个哈希桶中存储一个链表来解决冲突的方法。当一个哈希值对应多个元素时,这些元素会被存储在同一个桶的链表中。查找一个元素时,首先计算其哈希值定位到相应的桶,然后在链表中顺序搜索。
2. 开放寻址法(Open Addressing)
开放寻址法是另一种解决冲突的策略。当发生冲突时,不是在同一个桶中以链表形式存储多个元素,而是寻找另一个空闲的桶来存放新元素。这种方法包括线性探测、二次探测和双重散列等策略。
3. 双重散列(Double Hashing)
双重散列是开放寻址法的一种形式,其中使用两个哈希函数来计算元素的存储位置。当第一个哈希函数导致冲突时,使用第二个哈希函数计算一个新的哈希值。
4. 完全哈希(Perfect Hashing)
完全哈希是指构建一个无冲突的哈希函数,这通常需要知道所有可能的键值对,并构建一个能够完美适应这些键的哈希函数。这种方法在实践中很少使用,因为它不适用于动态或未知的键集合。
5. 一致性哈希(Consistent Hashing)
尽管一致性哈希不是直接解决单个哈希表冲突的方法,但它是分布式系统中处理扩展和收缩数据集的一种方式,通过确保哈希值的均匀分布来减少需要重新映射的键的数量。
哈希函数设计
为了减少哈希冲突的发生,哈希函数需要被设计为能够将输入均匀分布在输出的哈希表中。选择合适的哈希函数和表大小对性能影响很大。例如,使用素数作为哈希表大小可以减少模运算带来的模式,从而减少冲突。
总结
哈希冲突是哈希表这种数据结构的固有特性,不可避免。为了最小化哈希冲突带来的影响,一方面需要设计好的哈希函数来均匀分布键,另一方面需要选用适当的冲突解决策略来管理发生冲突时的数据。通过这些方法,可以确保哈希表即便在冲突发生时也能保持良好的性能。
3、ConcurrentHashMap在1.8做了哪些优化
在Java 8之前,ConcurrentHashMap的性能之所以出色,是因为它将内部的数据结构分成了多个段(Segment),每个段都由一个锁保护。这种设计允许多个线程同时更新map,只要它们操作的是不同的段。这种做法称为分段锁技术(Segmentation),每个段基本上是一个小的hash table,它们有自己的锁。这种方式的缺点是并行级别与段的数量直接相关,而段的数量在创建时固定,并且每个段消耗额外的内存。
在Java 8中,ConcurrentHashMap的实现进行了全面的重新设计,目的是提高性能,尤其是在高度并发的情况下。下面是Java 8对ConcurrentHashMap所做的一些关键优化:
1. 锁分离技术取代了分段锁
Java 8中的ConcurrentHashMap去掉了Segment类,转而使用一个节点数组(Node[])加上链表和红黑树。每个节点(Node)都是一个键值对,类似于HashMap中的Entry。锁定是在节点级别进行的,不再是在段级别。这意味着并发的粒度更细了。
2. 链表转红黑树
在旧版本中,所有的冲突都是通过链表解决的。在Java 8中,如果一个桶的节点太多(默认是转换阈值 TREEIFY_THRESHOLD,即链表长度大于8),链表会转换成红黑树,这大大减少了搜索时间。
3. 使用CAS操作提高性能
Java 8的ConcurrentHashMap在内部大量使用了比锁更轻的CAS(Compare-And-Swap)操作,这些操作在很多更新场景下避免了锁的使用,进一步提高了性能。例如,在扩容操作(resize)和添加新节点到链表或树时,只有在冲突时才会锁定。
4. 计数器优化
在旧版本的ConcurrentHashMap中,为了获取size,需要遍历所有的段并进行加锁,以保证得到准确的计数。在Java 8中,使用了一种叫LongAdder的新数据结构来优化计数器的性能。LongAdder使用了一个变量的数组来分散热点,使得更新操作可以在数组的不同部分并行进行。
5. 增强的迭代器
Java 8的ConcurrentHashMap迭代器使用了一种延迟绑定技术,它可以在遍历时动态检测到桶的变化,并相应地调整迭代器的状态,这样就不需要在迭代器创建时锁定所有桶。
源码层面深入分析
在Java 8的ConcurrentHashMap中,主要的数据结构就是一个volatile的Node数组。其中Node是一个静态内部类,每个Node对象都含有一个volatile的hash值、一个key、一个value和一个指向下一个节点的next引用。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
以下是所做优化的一些关键源码:
- CAS操作:例如在putVal方法中,为了避免锁,使用了unsafe.compareAndSwapInt来尝试写入新值。
- 红黑树转换:在TreeNode类中,如果节点过多,链表就会转换成红黑树,相关的方法如treeifyBin。
- 计数器优化:使用LongAdder代替原先的计数方式,避免了对全局count的竞争。
private final LongAdder counter = new LongAdder();
- 迭代器的延迟绑定:ConcurrentHashMap的迭代器在迭代时能适应underlying map的动态变化。
通过以上优化,Java 8的ConcurrentHashMap在保持线程安全的同时,进一步提升了性能和并发水平。
4、ConcurrentHashMap的散列算法
在Java中,ConcurrentHashMap使用一种高效的散列算法来决定一个键(key)应该存储在内部数据结构的哪个位置。在Java 8及更高版本中,ConcurrentHashMap的散列算法涉及几个关键步骤:
- 初始哈希:首先对键使用hashCode()方法来获取初始的哈希码。Object类的hashCode()方法返回的是一个整型(int)值。
- 哈希码的扰动(Spread):之后,ConcurrentHashMap通过一个扰动函数对这个哈希码进行处理,以减少碰撞(即尽量将数据均匀分布在各个桶中)。Java 8的ConcurrentHashMap使用了一种优化过的扰动函数,通过位运算符来实现。
源码中哈希扰动的方法是这样的:
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
这里的HASH_BITS是一个常量值0x7fffffff(最高位是0,其余位是1),用于确保哈希值为非负数,因为最高位是符号位。h >>> 16是无符号的右移16位操作,这样做可以将原始哈希码的高位信息混合到低位,增加了低位的随机性,从而降低了碰撞的概率。
- 确定桶位置:得到扰动后的哈希值之后,ConcurrentHashMap通过这个值与数组的长度减1进行位与运算(&),来确定键的桶位置:
Node<K,V>[] tab; int n; int i;
if ((tab = table) != null && (n = tab.length) > 0 &&
(i = (n - 1) & hash) >= 0) {
// ...
}
这里的n是数组的长度,hash是前面扰动过的哈希值。由于n是2的幂次方,n-1的二进制表示将会是所有低位都是1,这样可以保证与操作&后的结果落在数组的有效索引范围内。
- 冲突解决:如果计算出的桶位置已经被占用,ConcurrentHashMap需要处理冲突。在Java 8中,如果冲突的数量少,会使用链表;如果冲突的数量多,链表会转换为红黑树,以保持较高的查找效率。
在处理写操作时,为了减少锁的粒度和提高并发度,ConcurrentHashMap会使用CAS操作和synchronized关键字来保护节点的更新,从而避免整个结构的锁定。这样,只有在对相同桶进行更新操作时,线程才会竞争锁,大大提高了并发写入的性能。
以上是ConcurrentHashMap的散列算法的关键部分和源码级别的解释。这种设计使得ConcurrentHashMap在保持线程安全的同时,提供了较高的并发性能。
5、ConcurrentHashMap的扩容的流程
在ConcurrentHashMap中,扩容(resizing)是一个重要的操作,它确保了map在面对持续增长的数据量时仍然能够维持良好的性能。Java 8对ConcurrentHashMap的扩容机制进行了重大改进,使得多线程环境下的扩容更为高效。
扩容触发条件:
扩容通常在以下情况下触发:
- 当向ConcurrentHashMap中添加元素,且当前桶(bucket)的数量达到了阈值(load factor * 当前数组大小);
- 如果在一个桶中链表的长度超过了一定的阈值(TREEIFY_THRESHOLD,默认是8),并且当前数组的大小少于最大阈值(MAXIMUM_CAPACITY),也会尝试扩容来减少链表的长度,以便能够转换为红黑树。
Java 8中的扩容流程:
- 初始化扩容:
首先,一个线程发现数组需要扩容,并且没有其他线程正在进行扩容,它会通过CAS操作尝试更新sizeCtl字段来控制扩容的启动。
- 转移节点(Transfer):
然后,该线程会创建一个新的节点数组,其容量是原数组的两倍。在Java 8中,数据的转移是逐个桶进行的,每个桶可以独立完成转移,这就允许多个线程并发地执行扩容操作,每个线程处理部分桶的转移。
- 转移过程:
在转移节点时,每个桶内的节点要么保持在原来的位置,要么移动到原索引加上旧数组长度的位置。这是由每个节点的哈希值的高位决定的,因为数组的大小总是2的幂次。
- 调整sizeCtl:
当一个线程完成了它负责桶的转移后,它会尝试减少sizeCtl的值。当所有的线程完成转移后,sizeCtl会被设置为新数组的容量。
以下是一些关键的源码片段:
初始化扩容:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
// ...
}
转移过程:
final Node<K,V>[] nextTable;
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
// ...
for (int j = nextIndex; --j >= bound || advance;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--nextIndex < nextBound || nextIndex < 0)
advance = false;
else if ((nextTab = nextTable) == null ||
(nextIndex = transferIndex) <= 0)
advance = false;
else {
if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
j = nextIndex;
advance = false;
}
}
}
// ...
}
}
这只是transfer方法的一部分。整个转移过程包括了多个步骤,并涉及到一系列复杂的同步控制,以确保多线程环境下的正确性和高效性。
扩容的整个过程相当复杂,需要仔细处理索引的计算、节点的复制和移动,以及并发控制。通过这样的设计,Java 8的ConcurrentHashMap能够在运行时根据内容的增长动态调整大小,同时保持良好的并发性能。
6、ConcurrentHashMap的读取数据流程
在Java 8及其后续版本中,ConcurrentHashMap的读取操作是非常高效的,它不需要锁定,并且大部分情况下能以常数时间复杂度完成。下面分步骤解析ConcurrentHashMap读取数据的流程,并结合源码进行说明:
读取数据的基本流程:
- 计算哈希值:首先,通过调用key对象的hashCode()方法计算出其哈希值。
- 扰动哈希值:使用与插入操作相同的扰动函数来处理哈希值,以减少哈希碰撞。
- 定位桶位置:通过哈希值与数组长度减一取模,计算出key应该在数组中的位置。
- 遍历链表或树:如果该位置上是链表,就遍历链表寻找节点;如果转变为红黑树,就按照树的查找规则进行检索。
- 返回结果:如果找到对应的节点,则返回其value;如果没有找到,则返回null。
下面是读取操作的一段典型源码示例:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) { // always check first node without locking
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (e.hash < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
源码解析:
- 计算哈希与定位:
这是通过spread()方法将key.hashCode()的结果扰动后,并且利用这个哈希和数组长度减一进行位与操作,找到数组中的具体位置。
- 快速检查:
如果在桶的第一个节点就找到了匹配的哈希值,并且键相等(引用相同或equals()方法相等),那么直接返回对应的值。
- 处理特殊节点:
如果第一个节点的哈希值小于0,这表示这个桶已经处于特殊状态,可能正在扩容,或者是转换成了红黑树。如果是红黑树或其它特殊结构,通过调用find()方法搜索键。
- 遍历链表:
如果不是上述情况,那么就可能是一个正常的链表或者红黑树的节点,接下来会遍历链表,使用相同的哈希值检查和键比较逻辑,直到找到相应的节点。
- 返回值:
如果找到了对应的节点,直接返回节点的val字段,也就是对应的值。
此外,ConcurrentHashMap的读取操作利用了volatile字段的读写特性来实现无锁的线程安全。Node中的val和next字段都是volatile的,这确保了对这些字段的读取总是能看到最新的写入,这是实现并发读取安全性的关键。
7、ConcurrentHashMap中计数器的实现
在Java 8中,ConcurrentHashMap的大小计数采用了一种高度优化的方法。计数的实现不再是基于像之前版本的Segment数组那样的分段锁机制,而是使用了一种新的数据结构来允许多个线程并发更新计数,从而减少冲突和提高性能。
在Java 8的ConcurrentHashMap中,采用了一种叫做CounterCell的数据结构来存储计数器的值。当多个线程尝试更新计数器时,这些CounterCell可以帮助减少线程之间的争用。如果只有少量争用,那么所有更新操作可以直接作用于一个基础计数值上。但是,如果检测到高争用(例如多个线程同时更新计数),则会动态地扩展到一个CounterCell数组,其中每个线程可以尝试更新数组中的不同元素,从而减少了冲突。
以下是一个简化的版本,用以说明ConcurrentHashMap中计数器的实现方式:
class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {
// 省略了其他不相关的部分
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;
private transient volatile int cellsBusy;
// 省略了其他不相关的部分
private static final Unsafe U = Unsafe.getUnsafe();
private static final long BASECOUNT = U.objectFieldOffset(ConcurrentHashMap.class, "baseCount");
private static final long CELLSBUSY = U.objectFieldOffset(ConcurrentHashMap.class, "cellsBusy");
private static final long CELLVALUE = U.objectFieldOffset(CounterCell.class, "value");
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
// 省略了重新检查大小和其他的一些操作
}
}
// 省略了其他不相关的部分
}
class CounterCell {
volatile long value;
// 省略了其他不相关的部分
}
在上述代码中,baseCount是用于小规模并发更新时快速计数的基础字段。counterCells数组是当高并发更新时使用的。每一个线程都尝试更新一个特定的CounterCell来减少与其他线程的竞争。
方法addCount负责更新计数。如果没有竞争,它会尝试使用CAS(比较并交换)操作直接更新baseCount。如果失败,表示有竞争,那么它会尝试更新counterCells数组中的相应CounterCell。如果所有的CounterCell都在使用,则调用fullAddCount来处理更新。
fullAddCount方法内部会处理CounterCell数组的初始化和扩容,并在必要时重试更新操作。
通过这种计数器结构,ConcurrentHashMap可以在保持高并发性的同时,提供准确的大小计数。
8、为什么ConcurrentHashMap是线程安全的?
ConcurrentHashMap之所以是线程安全的,是因为其内部采用了多项技术来确保并发时数据的一致性和完整性,尤其是在多线程环境下的读写操作。以下是结合底层源码的一些关键特性和技术:
Java 8及以上版本的ConcurrentHashMap的线程安全特性:
- 分离锁(锁分段):
- CAS操作:
- 无锁的读操作:
- 节点的synchronized关键字:
- 树化:
- 计数器:
- 转发节点:
- 弱一致性的迭代器:
- 高效的迭代:
这些技术和设计选择共同工作,确保了在并发环境中进行读写操作时,ConcurrentHashMap的行为是可预测和一致的。这是通过一系列精心设计的锁策略和原子操作来实现的,它们共同提供了所需的线程安全性,同时也保证了高性能。
9、ConcurrentHashMap在使用中需要注意什么
ConcurrentHashMap是Java中的一个线程安全的哈希表,它是专为在多线程环境中使用而设计的。以下是一些ConcurrentHashMap的典型使用场景:
- 高并发访问:
- 实时查询:
- 数据共享:
- 事件驱动的应用:
- 频繁更新的数据集:
- 状态管理:
- 构建线程安全的复合操作:
考虑到ConcurrentHashMap的设计和内部机制,以下是一些深入的实现细节和原理:
- 分段锁定:
- CAS操作和无锁编程:
- 树化:
- 扩容:
- 延迟数据结构初始化:
在使用ConcurrentHashMap时,你应当意识到它是为了解决并发和多线程编程中的特定问题而设计的。它不仅仅是一个线程安全的HashMap,而是一个为了高并发性能而优化的数据结构。因此,在单线程应用程序中使用ConcurrentHashMap可能会导致不必要的额外开销,这时候使用HashMap可能是一个更好的选择。
10、ConcurrentHashMap和HashMap
ConcurrentHashMap和HashMap都是Java集合框架中的一部分,并且都实现了Map接口。尽管它们在某些方面相似,但也有一些显著的不同之处。以下是它们的相同点和不同点:
相同点:
- 基本接口:
- 内部结构:
- 哈希函数:
不同点:
- 线程安全:
- 内部并发控制:
- 迭代行为:
- 空键和空值:
- 性能:
- 元素计数:
- 计算模式:
小结:
HashMap是一个快速的、非线程安全的键值对集合,适合于单线程应用场景。而ConcurrentHashMap是为多线程环境优化的,它在提供较高并发性能的同时保持线程安全。选择哪一个取决于你的应用是否需要在多线程环境中共享和修改映射。
11、ConcurrentHashMap触发扩容的条件?
在 Java 8 及之后的版本中,ConcurrentHashMap 的扩容机制与 Java 7 中的实现有了显著的不同。以下是 Java 8 及以上版本中 ConcurrentHashMap 触发扩容的条件以及扩容的基本过程。
触发扩容的条件:
- 阈值检查: 在 Java 8 中,每个桶(bucket)实际上是一个链表或红黑树的头节点。当向 ConcurrentHashMap 添加元素时,如果某个桶的节点数达到了一定的阈值(默认为 TREEIFY_THRESHOLD,在 Java 8 中这个值是 8),并且当前的数组(table)长度大于等于 MIN_TREEIFY_CAPACITY(默认为 64),则会尝试将该桶中的链表转换为红黑树以提高搜索效率。
- 实际大小检查: 当转换为红黑树不是一个合理的选择,或者当桶的数量本身就非常多时,ConcurrentHashMap 会考虑整体扩容。扩容还会根据 ConcurrentHashMap 的 sizeCtl 字段来决定,该字段包含了大小控制信息,包括下一个要创建的表大小的阈值。
- 并发级别: 扩容也会考虑到并发级别(concurrencyLevel),这是在初始化 ConcurrentHashMap 时可以设置的一个参数,它影响内部用于并发控制的数据结构的数量。
扩容的基本过程:
- 初始化扩容: 当确定需要进行扩容时,首先会初始化一个新的数组,这个数组的长度是原来的两倍。
- 转移节点: 接下来,开始将每个桶中的所有节点从旧数组转移到新数组中。这一过程是线程安全的,并且是分步进行的。每个桶内的节点会被重新定位到新数组的两个位置之一,这取决于它们在旧数组中的位置以及它们的哈希值。
- 并发转移: 因为 ConcurrentHashMap 支持并发操作,所以多个线程可以同时参与扩容过程,各自转移不同桶(或链表、红黑树)中的节点到新数组中。
- 转移完成: 当所有的桶都被转移完毕后,扩容操作完成。新的 ConcurrentHashMap 会开始使用新的数组,而旧数组将会被垃圾回收。
底层源码示例:
扩容的触发点通常是在插入操作中检测到。在 Java 8 及以上版本中,以下是一段简化的伪代码,说明了扩容触发的条件检查:
public V put(K key, V value) {
// ...省略一些参数校验和初始化代码...
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0) {
tab = initTable(); // 初始化 table
} else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果当前桶为空,尝试使用 CAS 操作插入节点
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) {
break; // 无冲突插入成功,跳出循环
}
} else if ((fh = f.hash) == MOVED) {
// 如果发现节点的 hash 是 MOVED,说明当前正在进行扩容
tab = helpTransfer(tab, f);
} else {
// ...省略与现有节点有冲突时的处理代码...
// 在这里,如果链表长度达到一定阈值,可能会转为红黑树或触发扩容
}
// ...省略插入节点后的一些操作...
// 检查节点计数,决定是否需要扩容
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) {
// 转换为红黑树前的扩容检查
treeifyBin(tab, i);
}
if (binCount >= 0) {
if (binCount >= MIN_TREEIFY_CAPACITY) {
// 如果桶中的节点数达到了树化阈值,但表太小不能树化
// 则整体扩容
resize();
}
break;
}
}
}
// ...省略后续代码...
}
这段代码并不是实际的源码,而是为了说明 ConcurrentHashMap 触发扩容的大致过程所做的简化。实际的 ConcurrentHashMap 实现更复杂,包含了更多的细节和优化,但基本原理是一致的。
12、ConcurrentHashMap触发缩容
截至目前我的知识库中的最新信息,ConcurrentHashMap并没有提供自动缩容(shrink)或者减小其内部数组大小的功能。这与HashMap相似,其设计并不支持在容器使用减少时自动缩小内存占用。在ConcurrentHashMap的设计中,一旦内部数组(table)扩容,它将保持这个大小,除非显式地通过重新创建ConcurrentHashMap对象来减小其大小。
主要原因是,缩容操作在并发环境中会非常复杂和代价昂贵。缩容涉及到重新哈希和重新分配大量元素,与扩容类似,这意味着在缩容期间所有的写操作都需要等待这一过程完成,这可能会引入大量的线程争用和性能瓶颈。由于ConcurrentHashMap主要用于高并发场景,因此其设计优先考虑性能和线程安全,而不是内存使用的最优化。
如果你需要手动缩减ConcurrentHashMap的大小,你可以通过创建一个新的、更小的ConcurrentHashMap实例,并将当前ConcurrentHashMap的内容复制到新实例中来实现。然而,这通常是由开发者在确定内存占用过大时根据应用的具体需求手动进行的。
下面是一个简单的示例,说明了如何手动缩减ConcurrentHashMap的大小:
ConcurrentHashMap<K, V> originalMap = ...; int newCapacity = ...; // 新的较小容量 // 创建一个新的 ConcurrentHashMap 实例 ConcurrentHashMap<K, V> smallerMap = new ConcurrentHashMap<>(newCapacity); // 将旧实例的所有元素复制到新实例中 smallerMap.putAll(originalMap); // 现在 smallerMap 包含了所有元素,但具有较小的内部容量 // 可以将 originalMap 的引用指向 smallerMap,或者继续使用 smallerMap originalMap = smallerMap;
请注意,此操作不是原子的,也不是线程安全的。如果在执行此操作的过程中,原始ConcurrentHashMap仍在被多线程访问,你可能需要在操作执行期间锁定整个ConcurrentHashMap,或者在不影响业务逻辑的情况下暂停其他线程的访问。
查看10道真题和解析