ConcurrentHashMap 是 Java 后端开发中常用的并发容器,它的线程安全并不依赖单一的全局锁,而是通过减小锁粒度、CAS 无锁更新和 volatile 可见性共同实现。理解这一点,需要先对比 Hashtable 和 Collections.synchronizedMap 的实现方式。

一、为什么 Hashtable 的线程安全不够好
Hashtable 的线程安全非常直观:几乎所有公开方法都使用 synchronized 修饰,例如 get、put、remove 都锁定当前 Hashtable 实例。这意味着同一时刻只能有一个线程访问容器,哪怕两个线程操作完全不同的键,也必须串行执行。这种粗粒度锁虽然能保证正确性,但并发场景下会形成激烈的锁竞争,吞吐量很难随 CPU 核数提升。
Collections.synchronizedMap 也采用类似思路:它返回一个包装类,内部用互斥锁保护所有方法调用。不同之处只是锁对象可以指定,但本质上仍是全表互斥。在线程数量较多、扩容或遍历频繁时,这种设计会带来明显的性能瓶颈。因此,并发包提供了 ConcurrentHashMap,把锁的范围从整个哈希表缩小到更小的数据结构单元。
二、JDK 7 的分段锁:拆细锁粒度
在 JDK 7 及更早版本中,ConcurrentHashMap 内部维护一个 Segment 数组,每个 Segment 继承 ReentrantLock,并管理自己的哈希桶数组。put 操作先根据 key 的哈希值定位到某个 Segment,然后只对该 Segment 加锁。这样,不同 Segment 之间的写入操作可以并行执行,默认并发级别为 16,意味着理论上最多有 16 个写线程同时进入容器而互不阻塞。
读操作在这套设计下几乎不需要加锁。get 方法先通过哈希值定位 Segment,再在 Segment 内部的 table 中查找。由于 Segment 内部的 HashEntry 链使用 volatile 保证节点引用可见,get 可以直接读取当前链表头,无需获取锁。只有在 size、isEmpty、containsValue 等需要统计全表的方法中,分段锁实现才可能先尝试无锁统计,失败后锁定所有 Segment 进行精确计算。
分段锁仍然存在一些问题:Segment 数量固定后不能随表容量自动扩展,锁粒度虽然比全表锁细,但当某个 Segment 内数据量很大时,不同 key 仍可能被路由到同一个 Segment,从而产生不必要的竞争。此外,内存开销和工程复杂度也较高,JDK 8 因此对它进行了重构。
三、JDK 8 的 CAS 与 synchronized 协同机制
JDK 8 去掉了 Segment 结构,改为与 HashMap 更接近的数组加链表加红黑树结构。线程安全主要依靠 CAS 操作和 synchronized 块。putVal 方法中,如果目标桶为空,会先使用 Unsafe 提供的 compareAndSwapObject 尝试将桶头节点直接写入。这个 CAS 操作是原子性的,只有当前桶确实为 null 时才会成功;如果多个线程同时向同一个空桶写入,只有一个线程能成功,失败的线程会进入下一轮处理。
如果桶头节点已经存在,或者 CAS 失败,代码会对该桶的头节点加 synchronized 锁。注意锁对象是单个桶的头节点,而不是整个数组,因此不同桶之间仍然可以并发写入。加锁后,会在锁内完成链表查找、新增节点、红黑树转换以及可能的计数更新。synchronized 在 JDK 6 之后经过了偏向锁、轻量级锁等优化,对于大多数桶内节点数量有限的场景,性能表现已经足够稳定。
put 流程可用以下简化代码表示:
// ConcurrentHashMap putVal 简化逻辑
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
Node<K,V>[] tab = table;
for (Node<K,V>[] t = tab;;) {
Node<K,V> f; int n, i, fh;
if (t == null || (n = t.length) == 0) {
t = initTable();
continue;
}
i = (n - 1) & hash;
f = tabAt(t, i);
if (f == null) {
if (casTabAt(t, i, null, new Node<K,V>(hash, key, value, null))) {
break;
}
} else if (fh == f.hash && fh == MOVED) {
t = helpTransfer(t, f);
} else {
synchronized (f) {
// 在锁内检查并插入节点
}
}
}
addCount(1L, binCount);
return null;
}
上面代码中,tabAt 和 casTabAt 都通过 Unsafe 类按内存偏移直接读取和更新数组元素。Unsafe 不是普通 Java API,它绕过了 JVM 的对象访问边界,直接操作主内存,配合 volatile 语义可以保证可见性和有序性。这种设计让并发容器在进行高频写入时,大量路径都走 CAS 而不是锁。
四、读操作、volatile 与弱一致性
ConcurrentHashMap 的 get 操作在 JDK 8 中同样不加锁。table 数组以及 Node 的 next 引用都使用 volatile 修饰,保证一个线程对它们的修改能够及时刷新到主内存,另一个线程读取时能看到最新值。get 先通过 tabAt 读取桶头节点,再沿 next 指针遍历。这种设计使得读线程不会阻塞写线程,也不会因为锁而降低并发度。
不过,这种弱一致性的代价是:某个线程刚刚调用 put 写入成功,另一个线程的 get 未必立刻能看到,需要等待 volatile 写和读之间的内存屏障同步。对于大多数缓存、统计、配置管理等场景,这种最终一致性可以接受。如果业务要求强一致读,需要自行在容器外加锁或使用其他同步手段,因为 ConcurrentHashMap 的设计目标不是强一致,而是高并发下的安全操作。
size 方法也体现了这种权衡。JDK 8 不再维护精确的全表元素个数,而是通过 baseCount 和 CounterCell 数组分散记录各线程的增量,addCount 时优先 CAS 更新 baseCount,失败则把增量累加到某个 CounterCell。size 时汇总 baseCount 和所有 CounterCell,得到的是一个近似值或最终一致的值,而非加锁后的精确快照,因此并发插入和删除过程中 size 可能暂时不准确,但不会破坏内部结构。
五、与 Hashtable 的区别及使用建议
Hashtable 的全表锁保证了强一致性和简单性,但并发性能差。ConcurrentHashMap 通过 CAS 与细粒度锁把写入竞争限制在单桶级别,通过 volatile 降低读延迟,并允许复合操作被拆分为多个独立步骤。因此,单个 put 或 get 是线程安全的,但一组复合操作不一定是原子的。例如先 get 再 put 的 check-and-act 场景,可能被其他线程插入相同 key,导致后写入覆盖前值。JDK 8 为此提供了一些原子方法,如 putIfAbsent、compute、merge、replace 等,它们在桶级别锁内完成判断和更新,适合大多数需要复合更新的场景。
在 Java 后端开发中,如果只需要一个线程安全的 Map,并且允许弱一致读,优先选择 ConcurrentHashMap;如果需要全表快照或强一致遍历,可以使用 ConcurrentHashMap 的弱一致迭代器配合业务处理,或者在锁保护下使用 HashMap;Hashtable 和 Collections.synchronizedMap 在新代码中通常不再推荐,除非有明确的兼容性或强一致语义要求。
还需要注意,ConcurrentHashMap 的 key 和 value 都不能为 null。这不仅是避免二义性,也是因为并发环境下无法区分 key 不存在和 key 映射为 null。put 方法中对 null 的直接校验,也从 API 层面减少了误用。
ConcurrentHashMap线程安全Java并发编程修改时间:2026-08-25 07:27:37