title: "ConcurrentHashMap 的 size 不是实时值:一次缓存不一致让我追了 2 小时源码"
category: Java
tags: ["ConcurrentHashMap", "CAS", "分段锁", "JUC", "源码"]
三年前做一个分布式配置中心,客户端本地缓存用 ConcurrentHashMap,后台推送变更后,运维页面显示“已更新”,但客户端却读到旧值。排查了 2 小时,最后发现代码里调用了 map.size() 来判断有没有新配置进来,而 size 返回的是个近似值,不是精确值。这个 bug 让我重新读了 JDK 8 的 ConcurrentHashMap 源码,发现它从 JDK 7 的 Segment 分段锁到 JDK 8 的 CAS + synchronized,变化远比“砍掉分段锁”几个字来得复杂。
一、JDK 7 的分段锁:不是锁整个 map,而是锁 16 个 Segment
JDK 7 的 ConcurrentHashMap 把数组分成若干段(默认 16 段),每个 Segment 是一把独立的 ReentrantLock:
// JDK 7 简化版
final Segment<K,V>[] segments;
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
// ...
}
读写时先根据 key 的 hash 定位到 Segment,再对这个 Segment 加锁。这样 16 个线程可以并发操作 16 个 Segment,比 Hashtable 的整表锁好得多。但 Segment 数量在构造时固定,并发度上限就是 Segment 数量。如果想提高并发度,只能扩容 Segment,成本很高。
JDK 7 的 size() 为了追求精确,会尝试两次不加锁统计,如果 modCount 没有变化就直接返回;如果变化了,再对所有 Segment 加锁统计。这导致 size() 在并发高时可能性能很差。
二、JDK 8 的设计转向:Node + CAS + synchronized 头节点
JDK 8 抛弃了 Segment,直接用数组 + 链表 + 红黑树。核心结构变成了:
// java.util.concurrent.ConcurrentHashMap (JDK 8+)
transient volatile Node<K,V>[] table;
transient volatile Node<K,V> nextTable; // 扩容时的新表
private transient volatile long baseCount;
private transient volatile int sizeCtl;
private transient volatile int transferIndex;
每个桶的头节点 Node 是 volatile 的。对某个桶的操作,只需要 synchronized 这个头节点,而不是锁整个 Segment。没有竞争时用 CAS,有竞争时只锁头节点,粒度更细。
putVal 的核心逻辑:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
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();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶为空,尝试 CAS 插入新节点
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // CAS 成功
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 扩容中,帮忙迁移
else {
synchronized (f) { // 有头节点,只锁这个头节点
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 链表
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key || (ek != null && key.equals(ek)))) {
// key 已存在,替换或跳过
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
// 红黑树处理
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i); // 链表转红黑树
break;
}
}
}
addCount(1L, binCount);
return null;
}
这段代码把 JDK 8 的设计思路展现得很清楚:
1. 桶为空:CAS 抢。
2. 桶不为空:synchronized 锁头节点,链表或红黑树内操作。
3. 遇到 MOVED 标记:帮忙扩容。
三、size() 为什么是近似值?baseCount + CounterCell 的秘密
JDK 8 的 size() 没有加锁,而是用一个 baseCount 加上若干 CounterCell:
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
baseCount 是 LongAdder 风格的基准计数器,高并发时通过 CounterCell 数组分散竞争。sumCount() 把 base 和所有 cell 相加,得到的是一个“快照”,不保证读取过程中没有并发修改。所以 size() 返回的是近似值,不是精确值。
那次缓存不一致的 bug 就出在这里:
ConcurrentHashMap<String, Config> cache = ...;
int oldSize = cache.size();
cache.put(newKey, newConfig);
// 期望 size 增加 1
if (cache.size() == oldSize) {
// 错误地以为“没写进去”,又写了一遍
}
实际上 size() 不是精确计数,两次调用之间还有并发 put,判断逻辑完全错误。修复方式是把判断条件改成 cache.putIfAbsent() 或 computeIfAbsent() 的返回值。
四、computeIfAbsent 的回源问题:线程安全不等于去重
我见过很多人用 computeIfAbsent 做缓存回源:
map.computeIfAbsent(key, k -> loadFromDatabase(k));
这句话的意思是“如果 key 不存在,用 loadFromDatabase 计算 value 并放入 map”。它确实保证了对同一个 key 的并发调用只会执行一次 load,但很多人误以为“对不同的 key 并发回源也能被去重”。
实际上 computeIfAbsent 只锁住当前 key 所在的桶,不同 key 的回源操作是并行执行的。如果数据库扛不住并发回源,一样会被打挂。我的解决方案是:
// 用二级缓存或互斥锁保护回源热点
private final Object lock = new Object();
public Config getConfig(String key) {
Config c = l1Cache.get(key);
if (c != null) return c;
synchronized (lock) { // 保护对 L2 和 DB 的回源
c = l2Cache.get(key);
if (c != null) {
l1Cache.put(key, c);
return c;
}
c = loadFromDatabase(key);
l2Cache.put(key, c);
l1Cache.put(key, c);
return c;
}
}
这个方案牺牲了部分并发度,但保护了数据库。是否用全局锁、分段锁还是分布式锁,取决于回源成本和系统规模。
五、个人观点:ConcurrentHashMap 不是银弹
ConcurrentHashMap 在 JDK 8 之后性能确实很好,但它有三个容易忽视的限制:
- 不允许 null key/value。这和 Hashtable 一致,但和 HashMap 不同。迁移代码时要特别小心。
- size() 是近似值。需要精确计数的场景请用
ConcurrentHashMap自己维护原子变量,或改用Collections.synchronizedMap。 - computeIfAbsent 只保证同 key 单次计算。并发回源保护需要额外设计。
我的取舍是:
- 高并发读写的本地缓存:用 ConcurrentHashMap。
- 需要精确 size 或频繁遍历:考虑 ConcurrentSkipListMap 或自己实现计数。
- 写多读少且 key 冲突严重:注意 synchronized 头节点可能升级为重竞争,必要时加锁或拆分 key。
六、思考题
- JDK 8 的 ConcurrentHashMap 在链表长度达到 8 时一定转红黑树吗?什么条件下不转换?
size()返回的近似值在什么场景下会偏差较大?- 如果一个桶内的 synchronized 头节点竞争非常激烈,有什么优化思路?
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_39042870/article/details/166242554



