Skip to content
Go back

ConcurrentHashMap——从JDK7分段锁到JDK8桶级锁

ConcurrentHashMap:JDK 8 为什么去掉了分段锁?

一句话结论(30s)

JDK 8 去掉分段锁是因为固定 16 个 Segment 的并发度天花板太低,不同桶一旦落到同一 Segment 就串行。关键设计是”桶头节点 synchronized + 空桶 CAS 无锁写”,并发度提升到桶数量(无上限);配合 ForwardingNode 路标让读操作对扩容透明、多线程 CAS 认领区间协助扩容。权衡是锁粒度更细、迁移协调更复杂,但换来了可扩展的并发度和单桶性能。

核心原理(2min)

put 时空桶用 casTabAt 无锁设置桶头,非空则 synchronized 锁桶头节点在链表/红黑树插入。扩容时各线程按 stride 通过 CAS 递减 transferIndex 认领区间迁移,已完成桶放 ForwardingNode(hash=MOVED)指向新表——get 遇到即跳新表,put 遇到则 helpTransfer 帮忙再执行。size 用 CounterCell 数组分散计数。

想一想:CHM 为什么只锁桶头节点、不锁整张表?——锁的粒度直接决定并发上限:锁整表时不同桶的写也要互相排队,锁到单桶则不同桶天然并行,最大并发度从「1」或「固定 16」抬升到「桶数量、无上限」。这正是 JDK 8 敢去掉分段锁的底气。

底层深入(5-10min)

JDK 7 的 Segment 分段锁

// JDK 7
final Segment<K,V>[] segments;  // 默认 16 个 Segment
// 每个 Segment 继承 ReentrantLock,内部是一个小 HashMap

Segment 是 ReentrantLock 的子类。put 时先定位 Segment,再对 Segment 加锁。并发度固定 = Segment 数量(默认 16)。超过 16 个线程并发写不同桶但落在同一 Segment → 串行。

想一想:为什么固定 16 个 Segment 会成为「天花板」?——因为并发度被硬编码成 16,且不同桶只要哈希到同一个 Segment 就得共享一把锁;Segment 数量要么太少撑不起并发、要么构造时写死无法随数据量增长。这个死结让 JDK 8 彻底重写。

JDK 8 的桶级锁

先看三个底层原子读写封装——所有”CAS 定位桶”都靠它们:

// 原子读桶头(acquire 语义,保证后续读到的 next 链可见)
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getReferenceAcquire(tab, ((long)i << ASHIFT) + ABASE);
}

// 原子 CAS 写桶头:仅当当前值 == c 时才替换成 v,返回是否成功
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSetReference(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

// hash 字段的三种特殊编码
static final int MOVED     = -1; // forwarding node 的 hash:桶已迁移
static final int TREEBIN   = -2; // 红黑树桶根的 hash
static final int RESERVED  = -3; // 临时占位(computeIfAbsent 等用)

put 的完整核心逻辑在 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; K fk; V fv;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        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);
        else if (onlyIfAbsent // 不加锁先检查首节点
                 && fh == hash
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                 && (fv = f.val) != null)
            return fv;
        else {
            V oldVal = null;
            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)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key, value);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                    else if (f instanceof ReservationNode)
                        throw new IllegalStateException("Recursive update");
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

为什么这么写:空桶直接 casTabAt 无锁 CAS 设头节点,失败就回到 for 循环重试,一次插入不必抢任何锁;非空桶 synchronized (f) 锁的是桶头节点对象而非整张表,不同桶互不干扰,所以并发度 = 桶数量、无上限。加锁后再 if (tabAt(tab, i) == f) 二次校验,防止加锁前该桶已被扩容或替换,保证锁到的还是原桶。看到 hash == MOVED 就调 helpTransfer 帮忙扩容再重试,写操作对扩容也不阻塞。

想一想:空桶为什么用 CAS 抢头节点、而不是直接抢锁?——因为 CAS 是无锁乐观写:大多数新元素落到空桶,一次 CAS 成功即返回、失败回到 for 重试,省掉了「写个空桶也要先抢锁」的开销;这也是「桶级锁」比「每桶一把锁」更轻量的关键。

链表升级红黑树:treeifyBin

putVal 末尾 binCount >= TREEIFY_THRESHOLD(8)时触发树化,真实源码:

private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n;
    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) {
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p =
                            new TreeNode<K,V>(e.hash, e.key, e.val,
                                              null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

为什么这么写:链表长到 8 不一定立刻树化,先判断 tab.length < MIN_TREEIFY_CAPACITY(64)——表还小时直接 tryPresize 扩容,因为哈希冲突的根因往往是桶太少,而不是真的需要树。树化同样 synchronized (b) 锁桶头,在锁内把链表节点逐个包成 TreeNode、最后整体 setTabAt 替换成 TreeBin,读操作不会看到半成品。

想一想:链表为什么要长到 8 才考虑树化、而且先看表大小?——因为树化本身有成本(节点包成 TreeNode、还要维护红黑树平衡),短链表线性查找反而更快;而冲突多的根因常是桶太少,先把表扩容摊开元素往往比树化更划算,所以 table.length < 64 时宁可先扩容。

多线程协助扩容

扩容不是单线程迁徙——所有线程都参与:

扩容时 table 状态:
  [桶0] [桶1] ... [桶k] [ForwardingNode] [ForwardingNode] ... [ForwardingNode]
     ↑ 待迁移                        ↑ 已迁移 → 新来的读/写自动跳转到新表

transferIndexvolatile int)从 table.length 递减,每个线程通过 CAS 认领一段区间:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // 划分迁移区间
    if (nextTab == null) {            // 首次发起扩容
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // 尽力应对 OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;
    }
    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false; // 提交 nextTab 前确保扫完一遍
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            int nextIndex, nextBound;
            if (--i >= bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            else if (U.compareAndSetInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // 提交前复查一遍
            }
        }
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            advance = true; // 已被处理
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    // 红黑树 TreeBin 分支:同样按 hash & n 拆成 lo/hi 两棵子树,此处从略
                }
            }
        }
    }
}

为什么这么写:stride 是每个线程一次认领的区间大小,transferIndexn 递减、靠 compareAndSetInt CAS 认领 [nextBound, nextIndex),多线程并发认领互不重叠,这就是”协助扩容”的调度核心。迁移链表时只用 hash & n 一位把节点分成 ln/hn 两条链,setTabAt 写入新表后立刻把旧桶 setTabAt(tab, i, fwd) 换成路标,读写因此对新旧表始终可见。空桶直接 casTabAt(tab, i, null, fwd) 打成 ForwardingNode 免加锁;最后一个认领完区间的线程把 table 切到 nextTab

想一想:为什么要让所有线程都来「帮忙」扩容,而不是单线程扛?——单线程迁移大表时其他写线程只能干等或阻塞,可能造成系统级停顿;按 stride 区间 CAS 认领、互不重叠,能把一次大迁移摊薄成「人人搭把手」,让写操作对扩容也不阻塞。

读操作不阻塞:遇到 ForwardingNode(hash == MOVED),通过其 nextTable 字段直接去新表查找。

写操作主动帮忙put 发现当前桶是 ForwardingNode → 调 helpTransfer() → 认领一段区间帮扩容 → 自己的 put 在新表执行。

ForwardingNode 的协调作用

// ForwardingNode 是一个"路标",本身不存数据
static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) {
        super(MOVED, null, null);   // hash = MOVED(-1)
        this.nextTable = tab;
    }
}

put 撞上 ForwardingNode 时走的 helpTransfer

final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
    Node<K,V>[] nextTab; int sc;
    if (tab != null && (f instanceof ForwardingNode) &&
        (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
        int rs = resizeStamp(tab.length) << RESIZE_STAMP_SHIFT;
        while (nextTab == nextTable && table == tab &&
               (sc = sizeCtl) < 0) {
            if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
                transferIndex <= 0)
                break;
            if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) {
                transfer(tab, nextTab);
                break;
            }
        }
        return nextTab;
    }
    return table;
}

为什么这么写:ForwardingNode 的 hash = MOVED(-1)nextTable 指向新表——任何线程看到桶头是它,就立刻知道”这桶已迁走、去新表找”。getfind 里直接用 nextTable 按位查找,全程无锁;put 则走 helpTransfer,用 sizeCtl 做 CAS +1 认领一份迁移任务,transfer 干完回到 nextTab 继续自己的插入,所以写操作对扩容不阻塞、读操作对扩容透明

想一想:读操作凭什么能对扩容「透明」?——靠 ForwardingNode 这个路标:旧桶一迁走就留下 hash=MOVEDnextTable 指向新表的节点,get 遇到它按 nextTable 继续找,全程无锁,读者根本感知不到扩容正在发生。

addCount / CounterCell:分散计数

size() 不再全局加锁统计,而是把计数拆到一个 baseCount + 一个 CounterCell[]

@jdk.internal.vm.annotation.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

private final void addCount(long x, int check) {
    CounterCell[] cs; long b, s;
    if ((cs = counterCells) != null ||
        !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell c; long v; int m;
        boolean uncontended = true;
        if (cs == null || (m = cs.length - 1) < 0 ||
            (c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
            if (sc < 0) {
                if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
                    (nt = nextTable) == null || transferIndex <= 0)
                    break;
                if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

size() 实际求和逻辑:

final long sumCount() {
    CounterCell[] cs = counterCells;
    long sum = baseCount;
    if (cs != null) {
        for (CounterCell c : cs)
            if (c != null)
                sum += c.value;
    }
    return sum;
}

为什么这么写:先 CAS 累加 baseCount,成功就直接返回——绝大多数无竞争场景只走一次 CAS、不创建数组。竞争失败才按 ThreadLocalRandom.getProbe() 哈希到某个 CounterCell 上继续 CAS,把全局争抢分散到多个槽位,这是 LongAdder/Striped64 同款的”分散计数”。@Contended 给每个 Cell 加缓存行填充避免伪共享;sumCount 无锁相加 baseCount + 所有 Cell,所以 size() 返回的是近似值。addCount 末尾顺带检查 s >= sizeCtl 触发 transfer 扩容,计数和扩容在同一处协调。

想一想:size() 为什么不直接用一个 long 加锁统计?——因为全局计数是高频热点,加锁或单个 volatile CAS 会让所有写线程争抢同一缓存行、成为吞吐瓶颈;拆成 baseCount + CounterCell[] 把争抢分散到多个槽位,@Contended 再消除伪共享,换来近似值但吞吐大幅提升。

JDK 7 vs JDK 8 对比

JDK 7 (Segment)JDK 8 (桶级锁)
锁粒度Segment(默认 16)单个桶头节点
最大并发度16(固定)table.length(无上限)
扩容单线程多线程协助
读操作无锁(volatile 读)无锁(volatile 读 + ForwardingNode 重定向)
size三次不加锁统计+一次加锁兜底CounterCell 数组分散计数

JDK 8 的 ConcurrentHashMap 是一个全新的设计——从基于”固定分段”的粗粒度锁升级为基于”桶头节点”的细粒度锁 + CAS 无锁写空桶 + 多线程协助扩容。并发度和扩展能力得到了质的飞跃。

章末提问

  1. 为什么 JDK 8 要用「桶头节点 synchronized + CAS」替换 JDK 7 的 Segment 分段锁? —— 结论:为了突破固定并发度、把锁粒度降到单桶。因为 Segment 数量固定 16,不同桶落到同一 Segment 就串行;桶级锁让并发度 = 桶数量(无上限),空桶还能 CAS 无锁写,单桶性能也更好。

  2. 空桶插入为什么用 CAS 而不直接抢锁? —— 结论:为了省掉无谓的锁开销。因为多数插入落在空桶,一次 CAS 成功即返回、失败回退重试,比「先拿锁再写」轻量得多;这也是桶级锁比「每桶一把锁」更优的关键。

  3. 读操作为什么能在扩容期间保持无锁且不出错? —— 结论:因为 ForwardingNode 路标让读对扩容透明。桶迁走后留下 hash=MOVEDnextTable 指向新表,get 遇到它就跳新表继续查,全程无锁也始终能定位到数据。

  4. size() 为什么是近似值而不是精确值? —— 结论:为了用分散计数换吞吐。baseCount + CounterCell[] 把全局争抢摊到多个槽位、@Contended 消除伪共享;代价是 sumCount 无锁求和无法保证瞬时精确。

  5. 多线程协助扩容怎么避免重复迁移同一个桶? —— 结论:靠 transferIndex 的 CAS 递减认领区间。每个线程用 compareAndSetInt 抢一段不重叠的 [nextBound, nextIndex),迁完把桶打成 ForwardingNode,别人看到 MOVED 就跳过,天然不重复。


Share this post on:

Previous Post
HashMap的哈希扰动与扩容机制
Next Post
ArrayList与CopyOnWriteArrayList——1.5倍扩容与fail-fast机制