为什么 ConcurrentHashMap 抛弃分段锁改用 CAS?源码拆解

图片[1]-为什么 ConcurrentHashMap 抛弃分段锁改用 CAS?源码拆解-速优课

有次面试,候选人信誓旦旦地说「ConcurrentHashMap 就是用分段锁实现的」。我说 JDK 8 呢?他愣了一下,说应该差不多吧。实际上,JDK 7 和 JDK 8 的 ConcurrentHashMap 几乎是两个不同的类。

这篇文章不背八股,只看源码。我把两个版本都翻了一遍,说说 JDK 7 的分段锁是怎么设计的、JDK 8 为什么要换 CAS + synchronized、以及源码里几个让我意外的细节。

JDK 7:把一把大锁拆成 16 把小锁

JDK 7 的 ConcurrentHashMap 核心思路很朴素:HashMap 慢是因为整张表只有一把锁,ConcurrentHashMap 就多拆几把。它内部维护了一个 Segment 数组,每个 Segment 继承自 ReentrantLock,独立管理一段数据。

// JDK 7 ConcurrentHashMap 源码结构
public class ConcurrentHashMap<K, V> {
    final Segment<K, V>[] segments;   // Segment 数组

    static final class Segment<K, V> extends ReentrantLock {
        transient volatile HashEntry<K, V>[] table;
        transient int count;             // 这个 Segment 的元素数
        transient int modCount;          // 修改次数
        transient int threshold;         // 扩容阈值
    }
}

put 操作时,先根据 key 的 hash 定位到对应的 Segment,再对 Segment 加锁:

// put 操作
public V put(K key, V value) {
    int hash = hash(key.hashCode());
    Segment<K, V> s = segmentForHash(hash);
    return s.put(key, hash, value, false);
}

// Segment.put 加锁写入
V put(K key, int hash, V value, boolean onlyIfAbsent) {
    lock();  // lock() 不是 tryLock()
    try {
        HashEntry<K, V>[] tab = table;
        int index = hash & (tab.length - 1);

        // 遍历链表
        for (HashEntry<K, V> e = tab[index]; e != null; e = e.next) {
            if (e.hash == hash && eq(key, e.key)) {
                V oldValue = e.value;
                if (!onlyIfAbsent)
                    e.value = value;
                return oldValue;
            }
        }
        // 没找到,插入新节点
        tab[index] = new HashEntry<>(key, hash, tab[index], value);
        count++;
        return null;
    } finally {
        unlock();
    }
}

默认 16 个 Segment,意味着最多 16 个线程可以同时写入。如果 concurrencyLevel 参数设了 10,实际分配 16——往大了取整到 2 的幂次。

get 操作不需要加锁,因为 HashEntry 的 value 和 next 都是 volatile 的——保证了可见性。这是 JDK 7 里让我意外的设计:写入要锁,读取完全无锁。

我翻源码时注意到的细节:

Segment 继承自 ReentrantLock,但用的是「非公平锁」。因为 ConcurrentHashMap 的设计者觉得:抢锁的线程不需要排队,谁先抢到谁先写,吞吐更重要。这个设计哲学贯穿了整个类。

JDK 7 的几个瓶颈

JDK 7 的设计在 2011 年是很先进的,但它有几个硬伤。

第一:Segment 数量固定。初始化 16 个 Segment,存 1 万条数据是 16 个,存 1 亿条也是 16 个。热点 Segment 的竞争会越来越严重。

第二:每个 Segment 是一个独立的 HashMap。HashMap 的链表过长问题没有解决——默认链表超过 8 个并不会转红黑树(JDK 8 才加的)。当某个桶位的链表长到几百个时,get 性能退化到 O(n)。

第三:size() 方法要锁所有 Segment。调用 size() 时,先尝试不加锁累加 3 次,如果 count 不一致就锁住所有 Segment 再统计。在写频繁的场景下,size() 会阻塞所有写入操作。

// JDK 7 size() 方法——先尝试无锁累加
public int size() {
    long sum = 0;
    long check = 0;
    int mc = 0;

    for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
        check = 0;
        sum = 0;
        mc = 0;  // modCount 总和
        for (Segment<K, V> seg : segments) {
            sum += seg.count;
            mc += seg.modCount;
        }
        // 如果两次遍历的 modCount 一致,说明没有并发修改
        if (mc == check)
            return (int) sum;
    }
    // 遍历了 3 次还不一致,锁住所有 Segment 再统计
    for (Segment<K, V> seg : segments)
        seg.lock();
    // 统计...
    for (Segment<K, V> seg : segments)
        seg.unlock();
}

这个设计在当时已经够用——毕竟 16 个 Segment 的并发度覆盖了大部分场景。但随着 CPU 核数增多(16 核、32 核甚至更多),16 个 Segment 的锁竞争就变成了瓶颈。

JDK 8:CAS + synchronized + 红黑树

JDK 8 把整个设计推倒重来了。核心变化是:不再通过 Segment「先分桶再锁」,而是直接对每个桶位(数组元素)加锁

写入流程变成了 3 种路径,每个路径都在追求一个目标——能不加锁就不加锁:

// JDK 8 putVal 核心代码(简化)
final V putVal(K key, V value, boolean onlyIfAbsent) {
    int hash = spread(key.hashCode());
    Node<K, V>[] tab; Node<K, V> f; int n, i;

    for (tab = table;;) {
        // 路径一:table 未初始化 → 初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();

        // 路径二:桶位为空 → CAS 无锁写入
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K, V>(hash, key, value)))
                break;
        }

        // 路径三:正在扩容 → 协助迁移
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);

        // 路径四:桶位有数据 → synchronized 锁头节点
        else {
            synchronized (f) {
                // 遍历链表或红黑树
                // 找到就替换,没找到就插入链表尾部
                if (f.hash >= 0) {  // 链表
                    // 遍历链表
                } else if (f instanceof TreeBin) {  // 红黑树
                    // 插入红黑树
                }
            }
        }
    }
}

最让我意外的是 CAS 的优先级。在最理想的情况下(桶位为空),整个写入没有用到任何重量锁——原子 CAS 操作直接在硬件层面完成了。只有桶位有数据了才用 synchronized 锁住头节点。JDK 8 的 synchronized 已经不是重量级了——它经过了偏向锁→轻量级锁→重量级锁的升级过程,单点竞争时的性能和 ReentrantLock 几乎没差别。

另一个重大变化:链表超过 8 个节点转红黑树。这个阈值来自泊松分布的计算——在随机 hash 分布下,链表长度达到 8 的概率已经小于千万分之一。一旦触发,说明 hash 冲突严重(要么 key 的 hashCode 实现有问题,要么数据量极大),用红黑树把 O(n) 降为 O(log n)。

tabAt 和 casTabAt——为什么不用 volatile 数组

JDK 8 源码里有个我当时不太理解的设计。table 数组本身是 volatile 的:

transient volatile Node<K, V>[] table;

但读取数组元素时没有直接用 table[i],而是通过 Unsafe 的 getObjectVolatile:

@SuppressWarnings("unchecked")
static final <K, V> Node<K, V> tabAt(Node<K, V>[] tab, int i) {
    return (Node<K, V>) U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

static final <K, V> boolean casTabAt(Node<K, V>[] tab, int i,
                                      Node<K, V> c, Node<K, V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

原因很微妙:Java 的 volatile 只保证数组引用本身的可见性,不保证数组元素的可见性。table 引用变了,所有线程都看得到新数组。但 table[5] 被修改了,其他线程不一定能看到最新的值。所以 JDK 8 用 Unsafe 直接操作内存地址,绕过了 volatile 数组的限制。

这个细节让我意识到:站在 JVM 的角度,volatile 数组和 volatile 字段不是一回事。

扩容不阻塞写入——多线程协助迁移

HashMap 扩容的时候,所有写入都得停下来等——因为数据要从旧数组搬到新数组。但 ConcurrentHashMap 8 做到了扩容期间可以继续写入。

原理是:检测到正在扩容的线程,不阻塞,而是加入搬运队伍。每个线程领一个任务——搬运 16 个桶(stride = 16)。搬完一个桶就在旧数组的位置放一个 ForwardingNode,把这个桶标记为「已迁移」。

// 扩容迁移核心逻辑
private final void transfer(Node<K, V>[] tab, Node<K, V>[] nextTab) {
    int n = tab.length, stride;
    // 单核 CPU 不拆分任务,多核 CPU 每核至少搬 16 个桶
    stride = (NCPU > 1) ? (n >>> 3) / NCPU : n;
    if (stride < MINIMUM_TRANSFER_STRIDE)
        stride = MINIMUM_TRANSFER_STRIDE;

    // 从最后一个桶开始往前搬
    while (advance) {
        // 领取下一个需要搬运的桶
        // 多线程通过 CAS 竞争领取任务
    }
}

新的 put 操作会先检查目标桶有没有 ForwardingNode。如果没有,就在就数组上写入(因为数据还没搬走,新数组上也没有);如果有,就跟着搬——在新数组上写入。总之从不等待。

对比维度JDK 7 (Segment)JDK 8 (CAS + synchronized)
锁粒度Segment 级别(默认 16 个)桶位级别(默认 16 个桶启动,随扩容增多)
最大并发写入16桶的数量(成百上千)
链表优化纯链表链表 + 红黑树(>=8 转树)
扩容方式每个 Segment 独立扩容多线程协助迁移
get 加锁无锁(volatile 读)无锁(volatile + Unsafe)
size()锁所有 SegmentCounterCell 分段计数
空表内存~1MB(16 Segment + 数组)~40KB(只有数组)

CounterCell——size() 不再锁全局

JDK 8 的 size() 也用了一种分段计数的思路——CounterCell 数组。每个线程写入时,把自己的计数累加到当前线程对应的 CounterCell 上。size() 就是将所有 CounterCell 的值加起来。不需要锁。

// CounterCell——类似 Striped64 的分段计数
@sun.misc.Contended
static final class CounterCell {
    volatile long value;
}

// 增加计数
final void addCount(long x, int check) {
    CounterCell[] cs; long b, s;
    if ((cs = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // CAS 失败 → 用 CounterCell 分段累加
        CounterCell c; int v;
        // 找到当前线程的 CounterCell,CAS 累加
    }
}

这个设计的灵感来自 LongAdder——高并发场景下,多个线程各自累加自己的 CounterCell,避免了单个计数器的 CAS 竞争。

 ConcurrentHashMap 排查清单

  1. JDK 版本:JDK 8+ 的 ConcurrentHashMap 性能远优于 JDK 7,尽早升级
  2. 锁粒度:JDK 8 的锁粒度是桶位级别,远细于 Segment
  3. 红黑树:默认链表 > 8 转红黑树。如果 hashCode 实现不好,频繁红黑树操作会加重 CPU
  4. 热点 key:同一个 key 被多个线程并发写入,会变成单桶热点。考虑二级 hash 打散
  5. size() 性能:不再锁全局,但频繁调 size() 仍然有遍历 CounterCell 的开销
  6. 迭代器弱一致性:ConcurrentHashMap 的迭代器不保证读到最新数据,但不会抛 ConcurrentModificationException
  7. computeIfAbsent:JDK 8 有个 BUG(已修复),多个线程并发 computeIfAbsent 可能死锁

翻 ConcurrentHashMap 的源码,感觉像在看 Java 并发技术的演进史——从 JDK 7 的 ReentrantLock 分段到 JDK 8 的 CAS + synchronized + 红黑树,每一步都是为了解决前一步的瓶颈。

你看过哪些让你「原来如此」的并发源码?评论区说说。

© 版权声明
THE END
喜欢就支持一下吧
点赞10
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!