首页 / 资讯中心 / 文章详情

ConcurrentHashMap源码解析:从HashMap死循环到CAS无锁并发

ConcurrentHashMap源码解析:从HashMap死循环到CAS无锁并发 ★ FEATURED ARTICLE
1. 开篇ConcurrentHashMap 诞生的底层逻辑1.1 HashMap 在多线程环境下的死结先从一个最常见的场景聊起你在一个订单服务里维护了一份内存缓存多个线程同时读写同一个 Map。如果直接用 HashMap线上环境跑着跑着就出现 CPU 100%、数据丢失、甚至死循环。我当年第一次在生产环境遇到 HashMap 死循环时第一反应是JVM 出 BUG 了后来排查才发现罪魁祸首就是多线程同时 put 的时候链表在扩容迁移过程中形成了环。这个问题的根源在于HashMap 的底层结构是数组加链表扩容时会创建新数组再把旧桶里的节点迁移过去。单线程下这段逻辑没问题但多线程同时触发扩容两个线程对同一个链表执行迁移互相覆盖 next 指针链表就变成环形结构。一旦后续有查询命中这个环形链表就会在遍历时陷入死循环。这不是概率低到可以忽略的极端场景在高并发写入下几乎必然会碰上。所以并发场景不能直接用 HashMap这是 Java 并发编程的一条铁律。1.2 加锁就能解决一切吗既然 HashMap 不安全很多人第一反应是给所有操作加锁也就是用 Hashtable或者自己用 synchronized 包一层。Hashtable 的做法是给 put、get、size 这些方法整体加一把锁所有线程竞争同一把锁。单线程下性能还行一旦并发量上来锁竞争激烈吞吐量直线下降。说白了Hashtable 是把并发问题简单粗暴地串行化了你用上了多核 CPU但实际只有一个线程能干活。Collections.synchronizedMap 本质上也是同样的思路包了一层全局锁。这种方案的优点是实现简单、出错概率低缺点就是性能瓶颈明显。在高并发读多写少的场景里读操作被无谓地阻塞代价非常高昂。并发工具包需要的是一个既能保证线程安全、又能让读操作尽量并发执行的 Map这才是 ConcurrentHashMap 出现的根本原因。1.3 Java 7 分段锁方案与 Java 8 架构跃迁Java 7 时代的 ConcurrentHashMap 采用了分段锁思路把整个 Map 切分成若干 Segment每个 Segment 是一把独立的 ReentrantLock内部各自维护一个 HashEntry 数组。写入时只需要锁住对应的 Segment其他 Segment 上的操作完全不受影响。默认并发级别是 16也就是最多 16 个线程同时写入互不干扰。这个设计在当时已经相当优秀把大锁拆成小锁显著提升了并发度。但分段锁也有明显的天花板。Segment 的数量是初始化时固定的无法根据实际负载动态调整而且 get 操作虽然不加锁但读取一个 key 时要先定位 Segment再定位 HashEntry多了一层间接跳转。更重要的是当并发写入集中落在同一个 Segment 时仍然会退化回串行。Java 8 直接把分段锁方案推倒重做放弃了 Segment改为基于 CAS 加 synchronized 锁桶头节点的方案。数据结构也从 HashEntry 数组换成了 Node 数组加链表/红黑树。核心思想是更细粒度的锁只有发生哈希冲突、需要往已有链表写入时才锁住那个桶的头节点锁的粒度从 Segment 级别细化到单桶级别。空桶插入用 CAS 原子操作完成全程不需要锁。这个设计把并发粒度降到了极致也让 ConcurrentHashMap 在高并发场景下的表现比 Java 7 版本提升了一个量级。顺便说一句如果你工作中维护的是 JDK 8 以上的项目分析的源码是 Java 8 版本的JDK 17 等更高版本在 Java 8 基础上做了些优化比如扩容迁移的步长调整但整体骨架依然是 Java 8 奠定的架构。下面我全部基于 Java 8 的源码展开。2. Java 8 源码级数据结构拆解2.1 核心构成table 数组 Node 链表/红黑树Java 8 ConcurrentHashMap 的全局结构并不复杂核心就是一个 Node 数组 table数组的每个槽位要么为空要么是单个 Node要么是一条 Node 链表要么是一棵红黑树。这个结构跟 HashMap 高度相似。transient volatile NodeK,V[] table;table 数组本身用 volatile 修饰保证数组引用在不同线程间的可见性。但要注意volatile 修饰数组只能保证数组引用本身可见数组元素的可见性则需要靠元素内部的 volatile 字段来保证这个点后面讲 get 时会重点展开。Node 链表就是常规的哈希冲突处理方式新节点插入链表尾部。红黑树则是为了优化极端情况下的查找效率当链表过长时遍历效率从 O(n) 恶化查询会明显变慢转成红黑树后查询效率降为 O(log n)。table 数组的大小永远是 2 的幂次方默认初始容量是 16最大容量是 1 30。之所以要求 2 的幂是为了用位运算 (n - 1) hash 替代取模运算既快又能保证分布均匀。2.2 Node、TreeBin、ForwardingNode 三种节点的关键区别先看最基础的 Node 类它内部定义了四个核心字段static class NodeK,V implements Map.EntryK,V { final int hash; final K key; volatile V val; volatile NodeK,V next; }hash 和 key 都是 final一旦写入不能再变。val 和 next 用 volatile 修饰这是无锁读取的关键。链表结构里只有 next 的指向对读线程可见遍历才能安全进行。TreeBin 是红黑树的容器节点。它不是红黑树中的一个普通节点而是持有红黑树根节点引用、同时包含读写锁协调逻辑的包装类。之所以多包一层是因为红黑树的旋转操作涉及多个节点的指针变更如果完全不加锁读线程在遍历红黑树时可能看到中间态导致读到错误数据。TreeBin 内部通过一个简单的锁状态字段协调读写写操作持锁修改树结构读操作通过 find 方法遍历只有在树结构正在调整时才短暂等待。ForwardingNode 是扩容期间的特殊节点hash 值固定为 MOVED。它的作用是标记某个桶已经完成迁移当其他线程访问到 hash 为 MOVED 的桶时就知道这个桶已经被迁移到新数组需要跳转到新数组继续查找或写入。旧桶里所有节点已经搬走只留一个 ForwardingNode 占位。三种节点的 hash 值含义各不相同普通节点的 hash 是经过 spread 处理后的哈希值可能是任意非负整数TreeBin 的 hash 固定是 -2ForwardingNode 的 hash 固定是 -1。get 方法里通过 hash 值正负就能快速判断节点类型负数走特殊分支非负数走链表遍历。2.3 为什么 Java 8 要把分段锁升级成 CAS 加 synchronized 锁桶Java 7 的分段锁本身没问题但粒度还是太粗。你可以算一笔账假设 Map 里有 100 个桶Segment 是 16 个平均每个 Segment 管 6 个桶左右。两个写线程如果碰巧落在同一个 Segment 管理的不同桶上它们仍然要互斥等待。而在 Java 8 里只要两个线程写的是不同桶它们就完全不冲突即使写同一个桶也只是锁桶头节点这个锁的持有时间非常短。CAS 加 synchronized 这套组合的关键在于分级处理。对于空桶插入使用 CAS 尝试直接放入新节点这是乐观策略绝大多数情况下一次就能成功连锁都不用。对于非空桶的写入才退到悲观策略锁住头节点保证链表结构的一致修改。这种设计符合一个普适思想先尝试无锁的快速路径冲突时才走重量级路径。另外synchronized 在 JDK 8 之后经过锁升级优化偏向锁、轻量级锁、重量级锁锁竞争不激烈时开销很小。JVM 内置锁在细粒度、短持锁场景下的性能已经不比显式锁差。所以 Java 8 选择 synchronized 锁桶既简单又高效。3. put 流程走读从 hash 运算到落桶put 是整个并发设计最集中的体现强烈建议把 putVal 方法完整读几遍。下面按执行顺序逐个环节拆解。3.1 spread 方法高位参与运算的原因static final int spread(int h) { return (h ^ (h 16)) HASH_BITS; }spread 做的事情是把 key 的 hashCode 无符号右移 16 位再与原值异或最后与 HASH_BITS0x7fffffff做与运算确保结果非负。为什么要让高位参与运算因为后续定位桶用的是 (n - 1) hash如果 n 比较小比如默认 16那么 n - 1 的二进制只有低 4 位是 1高位全为 0。这样的话hash 值的高位信息在定位时完全被丢弃只要两个 key 的 hashCode 低 4 位相同不管高位差异多大都落到同一个桶哈希碰撞概率大幅上升。通过把高 16 位异或到低 16 位相当于让高位的随机性扩散到低位。这样即使原始 hashCode 低位分布不均匀经过 spread 之后也能得到更好的分布。与 HASH_BITS 做与运算是为了保证 hash 值为正数因为负数 hash 有特殊含义MOVED、TreeBin 标记不能与业务 hash 冲突。3.2 initTable多线程抢初始化put 时如果 table 为 null会进入 initTable 方法。多个线程同时发现 table 为 null 的情况很常见不能都去初始化否则会互相覆盖。这里用的是 CAS 抢一个 sizeCtl 字段的控制权。private final NodeK,V[] initTable() { NodeK,V[] tab; int sc; while ((tab table) null || tab.length 0) { if ((sc sizeCtl) 0) Thread.yield(); else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab table) null || tab.length 0) { int n (sc 0) ? sc : DEFAULT_CAPACITY; NodeK,V[] nt (NodeK,V[])new Node?,?[n]; table tab nt; sc n - (n 2); } } finally { sizeCtl sc; } break; } } return tab; }sizeCtl 初始值是 0CAS 把它从 0 改成 -1 成功的线程获得初始化权。其他线程看到 sizeCtl 为负数就知道已经有线程在初始化主动让出 CPU。初始化完成后sizeCtl 被设为 n - n/4也就是 0.75n这个值就是扩容阈值。这个字段在后面扩容环节还会用到非常关键。3.3 CAS 插空桶与 synchronized 锁头节点的分支逻辑初始化完成之后进入主循环。这一步有两个分支一个是空桶插入一个是锁桶插入。else if ((f tabAt(tab, i (n - 1) hash)) null) { if (casTabAt(tab, i, null, new NodeK,V(hash, key, value, null))) break; }tabAt 通过 Unsafe.getObjectVolatile 的方式读取数组元素拿到的是最新可见值。如果目标桶为空就尝试用 CAS 把新节点放进去。CAS 失败说明另一个线程抢先写入了重新循环再试。空桶插入不需要加锁这是大量 hash 均匀分布场景下 put 操作的主要路径性能非常出色。如果目标桶不为空先判断头节点 hash 是不是 MOVED。如果是说明这个桶正在扩容迁移当前线程不应该继续在旧桶上操作而是调用 helpTransfer 加入扩容等迁移完成再重试。如果头节点是普通节点就进入 synchronized 块锁对象就是桶头节点 f。锁住之后还要再做一次双检确认 tabAt(tab, i) 还是 f防止锁期间头节点被并发替换。synchronized (f) { if (tabAt(tab, i) f) { if (fh 0) { binCount 1; for (NodeK,V e f;; binCount) { K ek; if (e.hash hash ((ek e.key) key || (ek ! null key.equals(ek)))) { oldVal e.val; if (!onlyIfAbsent) e.val value; break; } NodeK,V pred e; if ((e e.next) null) { pred.next new NodeK,V(hash, key, value, null); break; } } } else if (f instanceof TreeBin) { // 红黑树插入逻辑 } } }锁住之后遍历链表找到相同 key 就替换值找不到就尾插新节点。binCount 记录链表长度后续树化判断要用。整个临界区的代码很短锁持有时间极短所以 synchronized 锁桶的竞争成本远低于全局锁。这里有个细节值得留意链表遍历时的 next 指针读取不需要加锁吗不需要因为 next 是 volatile 字段而且只有持锁线程才能修改链表结构。读线程即使不加锁也能通过 volatile 语义看到最新的 next 指向。3.4 链表转红黑树的触发与校验put 结束后如果 binCount 大于等于 TREEIFY_THRESHOLD8调用 treeifyBin 尝试把链表转红黑树。但这里有个反直觉的点链表长度达到 8 并不一定真的转树还要判断数组容量是否达到 MIN_TREEIFY_CAPACITY64。private final void treeifyBin(NodeK,V[] tab, int index) { NodeK,V b; int n, sc; if (tab ! null) { if ((n tab.length) MIN_TREEIFY_CAPACITY) tryPresize(n 1); else if ((b tabAt(tab, index)) ! null b.hash 0) { synchronized (b) { // 链表转红黑树代码 } } } }如果数组长度不到 64不转树而是先扩容把链表拆到新数组的两个桶里相当于通过扩容降低单个桶的负载。为什么这么设计因为红黑树本身有维护成本旋转、变色都需要额外开销而且节点占用内存比链表节点更大大约是链表节点的两倍左右。只有在数组容量较大的前提下树化才算划算。数组太小的时候扩容是比树化更优的解法。链表的树化阈值选 8是因为泊松分布的结论在负载因子 0.75 的情况下链表长度达到 8 的概率大约是千万分之六属于极端情况。如果链表真长到了 8说明哈希分布出了严重问题此时转红黑树能兜底保障性能。4. get 流程走读无锁读的底层秘密ConcurrentHashMap 的 get 方法完全不加锁这是它相比 Hashtable 最大的性能优势之一。读完下面这段逻辑你会理解为什么能不加锁还保证安全。4.1 一条 get 的完整路径public V get(Object key) { NodeK,V[] tab; NodeK,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) { if ((ek e.key) key || (ek ! null key.equals(ek))) return e.val; } else if (eh 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; }get 的路径比 put 简单得多定位桶、检查头节点、遍历链表或走特殊节点查找。第一步定位桶用的是 (n - 1) h跟 put 完全一致保证同一个 key 必然落到同一个桶。头节点检查有三种情况。第一种头节点 hash 等于目标 hashkey 也相等直接返回。第二种头节点 hash 为负数说明是特殊节点调用 find 方法查找。第三种头节点 hash 不等于目标 hash也不是负数则沿着链表继续遍历。4.2 volatile 在读取路径中的作用get 不加锁的安全性建立在两个 volatile 语义之上table 数组引用用 volatile 修饰Node 的 val 和 next 字段用 volatile 修饰。volatile 保证了两件事可见性一个线程修改了 volatile 变量的值其他线程能立刻看到最新值有序性volatile 变量的读写操作禁止指令重排序。考虑这样一个场景一个线程 put 一个新 key 到链表尾部另一个线程同时 get 这个 key。put 线程在持锁状态下创建新 Node并通过 volatile next 把它链接到链表末尾。get 线程在遍历链表时通过 volatile 读 next 字段能保证看到的是最新的链表结构。这是因为 volatile 写入 happens-before 后续的 volatile 读取JMM 保证了这个语义。数组元素的读取用的是 tabAt 方法底层是 Unsafe.getObjectVolatile。这很关键因为普通数组读取不具备 volatile 语义如果直接读 table[i]可能读到旧值。ConcurrentHashMap 通过 tabAt 绕过了这个问题确保每次数组元素读取都像读 volatile 变量一样。4.3 get 遇到特殊节点如何处理get 在遍历时如果遇到 hash 为负数的节点会进入特殊分支。遇到 ForwardingNode 时说明当前正处于扩容期该桶已经迁移到新数组。此时调用 ForwardingNode 的 find 方法会在新数组中继续查找 key。ForwardingNode 内部保存了新数组 nextTable 的引用所以能跳转过去。遇到 TreeBin 时调用 TreeBin 的 find 方法在红黑树中查找。这里的 find 方法内部处理了读写锁的协调如果当前有写线程正在改树结构读线程会采用类似等待的方式短暂阻塞直到结构稳定后再遍历。这个机制保证了读线程不会在红黑树旋转过程中看到不一致的树形结构。这两个特殊节点的 find 实现有各自的遍历逻辑但对外表现一致返回匹配的节点找不到返回 null。这层封装让 get 方法不需要关心桶里具体是什么结构只管调用 find 即可。4.4 弱一致性的时效真相get 不加锁的代价是可能读到稍旧的数据。比如一个线程 put 了一个新值另一个线程可能不会立刻看到这取决于 volatile 的可见性传播延迟。不过在实际运行时这个延迟极短对绝大多数业务来说可以忽略。更重要的是get 在看到扩容中间状态时有自己的处理逻辑如果一个桶已经迁移过头节点是 ForwardingNode走新数组查找如果还没迁移走旧数组遍历。一个 key 在扩容过程中可能被搬到新数组但只要 get 能定位到正确的位置就能找到数据。这里不存在读到半个链表的情况因为链表的修改只发生在持锁状态下而且 next 指针的更新是原子的。这种弱一致性是有意为之的设计取舍。用极短时间的读延迟换取极高的读并发度在高并发读多写少场景下是划算的。如果你需要强一致性的读取那应该考虑加锁方案而不是 ConcurrentHashMap这本来就不是它的设计目标。5. size 计数机制深度解析5.1 CounterCell 的出现与 LongAdder 思路Map 的 size 操作看似简单在并发场景下却是个麻烦事。如果每次 size 都加全局锁相当于让计数操作成为并发瓶颈。Java 8 采用了 LongAdder 的分散计数思路在 baseCount 之外维护一个 CounterCell 数组每个写线程尝试更新 baseCount冲突时改更新某个 CounterCell把竞争分散到多个独立的计数单元上。CounterCell 是这样一个内部类sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value x; } }Contended 注解是为了避免伪共享问题。多个线程同时写不同 CounterCell 时如果这些 CounterCell 恰好落在同一个 CPU 缓存行里会互相拖慢性能。Contended 会在每个 CounterCell 前后填充足够的内存空间让它们各自独占缓存行。5.2 计数更新的完整流程每次 put 成功新增一个键值对都会调用 addCount 方法。addCount 首先尝试通过 CAS 更新 baseCount把新增的数量加进去。如果 CAS 失败说明 baseCount 竞争激烈就随机选择一个 CounterCell 进行累加同样用 CAS 尝试更新 CounterCell 的 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(); } // 检查是否需要扩容的逻辑 }fullAddCount 是应对极端竞争的方法创建 CounterCell 数组、扩容数组、或者重新探测线程随机数避免多个线程长期绑定同一个 CounterCell。这里有一个经常会忽略的点为什么 addCount 还要顺带检查扩容因为 size 变化会影响负载因子当总键值对数超过阈值时需要触发扩容。addCount 的第二个参数是 putVal 传入的 binCount用于判断是否需要进一步检查扩容条件。5.3 并发下 sumCount 的精度真相sumCount 的代码很朴素遍历 CounterCell 数组累加每个单元的值再加上 baseCount。这段代码没有加锁所以理论上 size 方法返回的是一个近似值。实际上这个近似值在大多数场景下接近真实的键值对数但严格来说并发写入进行到一半时某些计数单元的累加可能还没落盘返回的值会偏小。同样地并发删除时可能偏大。size 方法并不承诺强一致。JDK 文档对 size 的说明是返回一个估计值。如果你的业务逻辑依赖精确的 Map 大小做决策需要评估这个误差是否能接受。在绝大多数业务场景下这个误差很小因为这些计数单元的更新非常频繁误差窗口极短。但在极端高并发下误差可能变得明显。mappingCount与size的区别在于后者在键值对数量超过 Integer.MAX_VALUE 时返回一个截断值而mappingCount返回 long 类型能容纳更大的计数范围。JDK 官方建议使用mappingCount而不是size。6. 扩容机制剖析扩容是 ConcurrentHashMap 源码里最复杂的部分很多人翻源码翻到这里就放弃了。我尽量用拆解的方式说清楚。6.1 sizeCtl 控制字段的三种状态sizeCtl 是扩容机制的总开关它有三种含义sizeCtl 为负值时表示正在初始化或者扩容。具体来说-1 表示有线程正在初始化 table小于 -1 表示正在扩容低 16 位表示参与扩容的线程数加 1。sizeCtl 为零时表示 table 还未初始化使用默认容量 16。sizeCtl 为正值时表示下一次触发扩容的阈值即当前容量乘以负载因子 0.75。在 Java 8 的实现里扩容被设计成多线程协助的机制而不是单个线程独自完成整个迁移过程。每个参与扩容的线程领取一段连续的桶区间迁移完自己那一段再领下一段直到所有桶都迁移完。6.2 transfer 方法的多线程协助机制transfer 方法执行步骤拆开看是清晰的第一计算迁移步长。当数组长度较大时每个线程一次处理的桶数由 stride 决定最小是 16。这样避免极端情况下一个线程迁移所有桶时间过长。第二创建新数组 nextTable容量是旧数组的两倍。然后从旧数组的高索引向低索引遍历逐个处理每个桶。第三处理每个桶时如果桶为空直接放入 ForwardingNode 占位表示该桶已迁移如果桶头已经是 ForwardingNode说明其他线程已经处理过跳过否则锁住桶头节点把桶里的链表拆成两部分。拆分的依据是节点 hash 的某个高位比特位由于新数组容量翻倍(n - 1) hash的计算结果要么等于原来的索引要么等于原索引加 n。把这两类节点分别串成 low 链表和 high 链表low 链表放入新数组的原索引槽位high 链表放入原索引加 n 的槽位。迁移完成后旧桶放入 ForwardingNode。一个线程完成自己负责的区间后会尝试领取新的区间继续迁移。如果所有区间都迁移完则结束。扩容期间还会通过 CAS 更新 sizeCtl记录还在工作的线程数。6.3 扩容期间的读写与 put 协同扩容期间其他线程的读写遇到 ForwardingNode 时各自的处理方式不同读线程遇到 ForwardingNode跳转到新数组继续查找。这个跳转由节点内部的 find 方法完成。写线程遇到 ForwardingNode会调用 helpTransfer 加入扩容队伍先帮扩容完成再重试写入。这样设计是为了避免多个线程在旧数组上写入时数据迁移后丢失因为迁移后的数据不会同步回旧数组。只有真正持锁迁移桶内容的线程才会锁住旧桶头节点所以迁移过程与正常写入互不冲突。扩容线程锁住头节点迁移时写线程只能等待迁移完成后写线程看到的是 ForwardingNode会跳转或协助扩容。6.4 扩容触发阈值分析扩容的触发条件有两个put 之后 addCount 检查发现键值对数超过 sizeCtl 阈值以及 treeifyBin 时发现数组长度小于 64 且链表已经很长。第一种是正常的容量不够用导致的扩容阈值是容量的 0.75。第二种是哈希分布不均匀导致某个桶链表过长通过扩容来分散节点。还有一种情况是其他线程在迁移时发现任务量太大会调用 tryPresize 主动扩容但本质都是这两个触发点。扩容虽然由多线程协作完成但整个迁移过程涉及大量内存复制操作CPU 开销并不低。所以初始化容量的时候如果能预估键值对数量最好直接设置一个够用的初始容量减少扩容次数。构造方法里的 initialCapacity 参数会被换算成 2 的幂次方保证数组大小符合要求。7. 使用层面的实战建议与避坑清单7.1 什么场景更适合用 ConcurrentHashMapConcurrentHashMap 最适合的是读多写少、且写入分布相对均匀的共享缓存场景。比如按用户维度缓存配置信息、按商品维度缓存库存快照、维护在线用户状态等。这些场景的特点是读操作远多于写操作get 无锁带来的性能收益非常大。不适合的场景也很明确。如果你的业务是高频更新同一个 key比如一个全局计数器不停自增那么大量写线程会锁同一个桶头节点并发度并不比 Hashtable 强太多。这种场景更适合使用 AtomicLong、LongAdder 或分段独立的计数器。另一个需要留意的场景是重计算逻辑。如果你在 get 返回 null 之后去做耗时的数据加载再 put 回去那么并发请求同一 key 时会出现缓存击穿。ConcurrentHashMap 本身不提供 putIfAbsent 之外的高级原子语义你需要自己用 putIfAbsent 结合循环实现单飞加载逻辑或者使用更专门的缓存组件。7.2 几个容易踩的并发陷阱第一个坑是用普通 HashMap 的遍历习惯去遍历 ConcurrentHashMap。虽然 ConcurrentHashMap 的迭代器是弱一致的不会抛 ConcurrentModificationException但如果在遍历过程中删除元素某些场景下可能出现删了但后面又遍历到的现象。官方提供的 remove 方法本身是线程安全的但在迭代期间删除需要谨慎处理。第二个坑是把 key 的 hashCode 写得太差。ConcurrentHashMap 对哈希分布敏感如果 key 的 hashCode 大量相同比如都用 0 或者都用同一个固定值那么所有 key 都会落到同一个桶锁竞争和链表遍历都会让性能急剧恶化。自定义对象作为 key 时一定要遵守 hashCode 和 equals 的规范。第三个坑是错误理解 size 的语义。它返回的是近似值而非精确值。如果你的代码逻辑依赖精确计数比如当 Map 中元素达到 10000 时就清理那么并发写入下可能触发时机有偏差。要精确计数应该自己维护一个 AtomicLong 或者额外加锁而不是靠 size。第四个坑是初始化容量设置不当。虽然容量会自动扩容到 2 的幂次方但如果你预估 100 个元素设置了 initialCapacity 100实际数组大小是 128扩容阈值是 96那么插入到第 97 个元素时就会触发扩容。建议设置成预估数量除以 0.75 再向上取整也就是 initialCapacity (预估元素数 / 0.75) 1。7.3 性能观测与容量设置想验证 ConcurrentHashMap 的并发性能可以用一个简单的压测脚本多个线程同时对一个 Map 执行 put/get 混合操作统计每秒操作数。你可以做一个对照实验分别用 Hashtable、Collections.synchronizedMap、ConcurrentHashMap 跑同一组操作观察吞吐量和锁等待时间。我实测下来的经验是在线程数较少比如 4 个以下时三者的差距不是特别明显一旦线程数超过 CPU 核数的一半Hashtable 的吞吐量会明显下滑而 ConcurrentHashMap 的吞吐量随着线程数增长仍然能保持线性增长趋势直到 CPU 资源耗尽。这背后的原因就是锁粒度带来的并发度差异。另外可以开启 JFR 或者使用 JMH 做微观基准测试。如果你发现 ConcurrentHashMap 的 put 操作耗时异常高优先检查两点第一有没有违反 hashCode 原则导致桶分布严重不均第二扩容是不是过于频繁。两者的共同解法都是把初始容量设大减少扩容触发次数。扩容耗时在线程数多的时候会被摊薄但在单线程写入场景下一次扩容可能要阻塞整个 put 操作几十毫秒对延迟敏感的服务不可接受。容量设置这块还有一个容易被忽略的点每次扩容数组翻倍如果预估容量接近 2 的某次幂比如 1 万个元素换算成容量就是 16384扩容阈值是 12288。如果实际数量最终超过 12288又会触发一次扩容。最好留出 20% 到 30% 的余量避免频繁触发扩容带来的性能抖动。从源码层面理解了 ConcurrentHashMap 之后其实不难发现它的每个设计决策背后都有明确的取舍依据。空桶插入用 CAS 是为了快速路径不加锁非空桶用 synchronized 是为了保证链表操作的原子性get 不加锁是因为 volatile 语义已经覆盖了读路径的可见性需求多线程协助扩容是为了把大任务的耗时摊分到多个线程上。这些细节单独看都有道理组合在一起就形成了一套非常精致的并发控制体系。我自己在阅读源码时有个小习惯带着场景去看代码。比如看到 ForwardingNode 的时候脑子里想的是扩容期间 get 一个正在迁移的 key 会发生什么这样代码就不再是一堆变量跳转而是一套应对真实并发问题的完整方案。如果你也想深入研究并发容器建议从 put、get、addCount、transfer 这四个方法入手把它们的执行路径画出来对照着今天的分析再看一遍源码理解会快很多。
阅读完成 · 觉得有帮助?
咨询建站