本文将详细介绍 ConcurrentHashMap
构造方法、添加值方法和扩容操作等源码实现。ConcurrentHashMap
是线程安全的哈希表,此哈希表的设计主要目的是在最小化更新操作对哈希表的占用,以保持并发可读性,次要目的是保持空间消耗与 HashMap
相同或更好,并支持利用多线程在空表上高效地插入初始值。在 Java 8 及之后的版本,使用 CAS 操作、 synchronized
关键字、合适的 自旋重试 和 volatile
关键字(保证可见性和禁止指令重排)来保证并发安全,并对节点进行了优化:采用了链表和红黑树的实现,在链表节点数量大于等于 8 且数组(在后文中会称每个元素位置为桶)大小大于等于 64 时会转变为红黑树,在扩容逻辑中,当树节点小于等于 6 时又会转换成链表(删除逻辑中链表转换红黑树的逻辑并不严格按照大小为 6 的阈值),优化空间利用并提高查询效率。它的默认大小为 16,负载因子为 0.75F,负载因子不支持指定其他值,这是与 HashMap
的不同点,在讲解构造方法的源码时,会提到这一点,大家需要留意,接下来步入正文:
构造方法
首先我们来看它的构造方法,重点关注注释信息:
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {private transient volatile int sizeCtl;private static final int MAXIMUM_CAPACITY = 1 << 30;/*** 构造方法,其他构造函数最终都会调用该方法,但实际上在构造方法中并没有完成初始化* * @param initialCapacity 指定初始化大小* @param loadFactor 负载因子,注意该值并没有被任何字段记录下来,而是只参与了 size 的计算* @param concurrencyLevel 指定并发线程数,用于校正大小*/public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)throw new IllegalArgumentException();// 如果指定的并发线程数大于初始化容量,那么以并发线程数为准if (initialCapacity < concurrencyLevel)initialCapacity = concurrencyLevel;long size = (long) (1.0 + (long) initialCapacity / loadFactor);int cap = (size >= (long) MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int) size);this.sizeCtl = cap;}// 向上取整 2 的n次幂private static final int tableSizeFor(int c) {// Integer.numberOfLeadingZeros(c - 1) 用于计算 c-1 的二进制表示中最高位 1 之前有多少个 0// -1 的二级制表示为 11111111111111111111111111111111(32个1),无符号右移则会得到某 2的n次幂-1 的结果int n = -1 >>> Integer.numberOfLeadingZeros(c - 1);// 限制最大值的同时,结果永远为 2 的 n 次幂return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;}}
负载因子 loadFactor
作为局部变量计算完 size
后,并没有被记录下来,后续有关该值的逻辑,如扩容阈值的计算均使用了默认值 0.75F。这么做的原因在源码 JavaDoc 中有详细的解释:
理想情况下,容器中的节点遵循泊松分布,一个桶中有 k 个元素的概率分布如下:
0:0.60653066
1:0.30326533
2:0.07581633
3:0.01263606
4:0.00157952
5:0.00015795
6:0.00001316
7:0.00000094
8:0.00000006
更多:不到千万分之一
在随机散列下,两个线程访问不同元素的锁争用概率约为 1 / (8 * 元素数量)。
在负载因子为 0.75F 时,能够较好的防止多个元素发生碰撞,在随机散列的情况下,多线程发生锁争抢的概率较低。
ConcurrentHashMap#tableSizeFor
方法计算结果会将数组大小固定为 2 的 n 次幂,这样做是为了 提高性能和简化实现,以下为详细解释:
-
位运算优化:当哈希表的大小是 2 的 n 次幂时,可以使用位运算来代替取模运算(
%
),从而提高哈希表操作的性能。比如计算某元素在数组中的位置,index = hash % table.length
可以简化为index = hash & (table.length - 1)
,位运算&
通常比取模运算更快 -
哈希分布均匀性:2 的 n 次幂减 1 的 2 进制表示中低位均为 1,哈希值与它进行位与计算可直接获取索引值,这样可以减少哈希冲突的概率,使分布更加均匀
-
简化扩容逻辑:在扩容时,直接指定新表的大小是旧表的两倍(也是 2 的 n 次幂),元素的重新分配变得更加简便,要么元素的位置要么保持不变,要么移动到新位置
index + oldCapacity
,这种移动逻辑可以通过简单的位运算实现
put 方法
put
方法是核心方法,需要重点关注添加值时使用到的 CAS + synchronized
的同步机制。更新元素计数的 addCount
方法采用了非常巧妙的实现,后文中我们会详细介绍。除此之外,扩容操作也会专门进行说明,它协调多线程共同完成扩容的解决方案也很值得学习,这些内容掌握之后,ConcurrentHashMap
中也再没有更难的内容。我们以 put
为切入点,重点关注注释信息:
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {static final int TREEIFY_THRESHOLD = 8;// 最高位为 0,其余位为 1static final int HASH_BITS = 0x7fffffff;private static final int DEFAULT_CAPACITY = 16;transient volatile Node<K,V>[] table;private transient volatile int sizeCtl;public V put(K key, V value) {return putVal(key, value, false);}final V putVal(K key, V value, boolean onlyIfAbsent) {// key 和 value 均不能为 nullif (key == null || value == null) throw new NullPointerException();// hash 扰动,用于使 hash 结果均匀分布int hash = spread(key.hashCode());int binCount = 0;for (Node<K,V>[] tab = table;;) {Node<K,V> f; int n, i, fh; K fk; V fv;// 懒加载实现初始化if (tab == null || (n = tab.length) == 0)tab = initTable();// 如果元素 hash (数组大小 - 1 & hash)到桶的位置没有元素(为 null)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)tab = helpTransfer(tab, f);// onlyIfAbsent 是入参,默认为 false,true 表示键不存在时才插入新值,存在相同键值不能覆盖;false 为一直覆盖逻辑// 该逻辑满足在此特定条件下,避免获取锁,从而提高性能