路从脚起头像
关注

ConcurrentHashMap 的 size 不是实时值:一次缓存不一致让我追了 2 小时源码


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 之后性能确实很好,但它有三个容易忽视的限制:

  1. 不允许 null key/value。这和 Hashtable 一致,但和 HashMap 不同。迁移代码时要特别小心。
  2. size() 是近似值。需要精确计数的场景请用 ConcurrentHashMap 自己维护原子变量,或改用 Collections.synchronizedMap
  3. computeIfAbsent 只保证同 key 单次计算。并发回源保护需要额外设计。

我的取舍是:
- 高并发读写的本地缓存:用 ConcurrentHashMap。
- 需要精确 size 或频繁遍历:考虑 ConcurrentSkipListMap 或自己实现计数。
- 写多读少且 key 冲突严重:注意 synchronized 头节点可能升级为重竞争,必要时加锁或拆分 key。

六、思考题

  1. JDK 8 的 ConcurrentHashMap 在链表长度达到 8 时一定转红黑树吗?什么条件下不转换?
  2. size() 返回的近似值在什么场景下会偏差较大?
  3. 如果一个桶内的 synchronized 头节点竞争非常激烈,有什么优化思路?

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_39042870/article/details/166242554

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--