ConcurrentHashMap博客

ConcurrentHashMap 深度解析:从源码到底层,再到实战

面向:已经会用 HashMap、想真正搞懂并发容器底层、准备面试或正在做高并发开发的 Java 工程师。
本文以 JDK 1.8 为主(也是现在的主流版本),并对比 JDK 1.7 的设计演进。


目录

  1. 为什么需要 ConcurrentHashMap
  2. 底层数据结构演进(1.7 vs 1.8)
  3. 核心字段:读懂这些变量才算入门
  4. put 方法源码逐行解析
  5. get 为什么不需要加锁
  6. size / mappingCount 如何高并发计数
  7. 扩容与数据迁移(transfer)
  8. 底层知识讲解(进阶)
  9. 实际开发中的需求与最佳实践
  10. 面试高频问题速答
  11. 总结

一、为什么需要 ConcurrentHashMap

直接用 HashMap 在多线程下会出事:

  • JDK 1.7 HashMap:扩容时采用头插法,并发 resize 可能形成环形链表,导致 get 时 CPU 100%。
  • JDK 1.8 HashMap:改成尾插法避免了环,但并发 put 仍会出现数据覆盖++size、桶指针赋值都不是原子的)。
  • Hashtable / Collections.synchronizedMap:对整个 put/getsynchronized一把锁串行化所有线程,并发性能很差。

ConcurrentHashMap 的目标就是:既要线程安全,又要高并发,同时提供弱一致性(weakly consistent)的迭代器,避免为了强一致付出巨大性能代价。

直觉对比:

方案 线程安全 并发度 迭代器一致性
HashMap
Hashtable 全局 1 把锁(串行) 强一致(会抛 ConcurrentModificationException
Collections.synchronizedMap 全局 1 把锁 强一致
ConcurrentHashMap (1.8) 桶级别(可视为 N 把锁 + CAS) 弱一致(不抛异常)

二、底层数据结构演进(1.7 vs 1.8)

2.1 JDK 1.7:分段锁(Segment + ReentrantLock)

JDK1.7 结构

  • 内部是一个 Segment[],默认 16Segment
  • 每个 Segment 继承 ReentrantLock,自身又是一张小的 HashMapHashEntry[] + 链表)。
  • 写操作:先定位 key 属于哪个 Segment,再 lock() 该段,段与段之间可以并发,段内是串行的
  • 并发度 = Segment 数量(创建后不可动态扩容)。

缺点:锁粒度仍然偏粗。如果大量 key 哈希到同一个 Segment,这个段就成了性能瓶颈,退化成“单锁串行”。

2.2 JDK 1.8:取消 Segment,采用 CAS + synchronized

JDK1.8 结构

  • 结构与 HashMap 1.8 一致:Node<K,V>[] table + 链表 + 红黑树
  • 取消了 Segment,锁的粒度从“段”降到“桶(bin)”。
  • 关键设计:
    • 桶为空:用 CAS 无锁写入新节点。
    • 桶非空:用 synchronized(f) 锁住桶的头节点(只锁一个桶,其他桶仍可并发)。
    • 链表长度 ≥ 8 且表长 ≥ 64 时树化为红黑树;长度 ≤ 6 时退树化回链表。
  • 扩容时通过 ForwardingNode(hash = MOVED = -1)标记正在迁移的桶,引导线程去新表。

为什么 1.8 用 synchronized 而不是 ReentrantLock

  1. 锁的是单个桶头节点,锁竞争极低,JVM 对 synchronized锁升级(偏向→轻量→重量)锁消除/锁粗化的持续优化;
  2. synchronized 是 JVM 内建,不需要像 ReentrantLock 那样为每个节点额外维护一个 AQS 队列对象,内存占用更小

三、核心字段:读懂这些变量才算入门

// 底层数组,长度总是 2 的幂;volatile 保证扩容时对其他线程可见
transient volatile Node<K,V>[] table;

// 扩容时的新数组(仅扩容期间非空)
private transient volatile Node<K,V>[] nextTable;

// 控制表初始化与扩容的核心信号量,含义随值变化:
//  = 0          未初始化
//  > 0          下一次扩容的阈值(容量 * 负载因子)
//  = -1         正在初始化
//  其他负数     高 16 位是扩容 stamp,低 16 位是参与扩容的线程数
private transient volatile int sizeCtl;

// 扩容时,多个线程认领桶区间的游标(CAS 递减分配任务)
private transient volatile int transferIndex;

// 计数基础值,无竞争时直接累加
private transient volatile long baseCount;

// 计数分片数组(竞争激烈时分摊到各分片,思想同 LongAdder)
private transient volatile CounterCell[] counterCells;

// 初始化 counterCells 或扩容时的自旋锁标记(0 无锁 / 1 加锁)
private transient volatile int cellsBusy;

辅助常量:

static final int TREEIFY_THRESHOLD = 8;     // 链表转红黑树阈值
static final int UNTREEIFY_THRESHOLD = 6;   // 红黑树转链表阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 树化还要求表容量至少 64
static final int MOVED     = -1;            // 表示该桶正在扩容(ForwardingNode)
static final int TREEBIN   = -2;            // 表示该桶是红黑树(TreeBin)
static final int HASH_BITS = 0x7fffffff;    // 正常节点哈希的上限掩码

哈希扰动(让高位也参与运算,减少碰撞):

static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}

四、put 方法源码逐行解析

put 流程图

putVal 是核心写入方法(已省略部分分支,保留主干):

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException(); // ① 不允许 null
    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;
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);                                  // ⑥ 正在扩容,帮忙搬
        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;                          // 找到相同 key
                                if (!onlyIfAbsent) e.val = value;        // 覆盖(value 是 volatile)
                                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) {                     // ⑩ 红黑树
                        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;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)                       // ⑪ 达到阈值树化
                    treeifyBin(tab, i);
                if (oldVal != null) return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);                                              // ⑫ 计数 +1 并可能触发扩容
    return null;
}

要点拆解:

  1. 不允许 key/value 为 null:并发场景下无法区分“key 不存在”和“value 就是 null”,故直接抛 NullPointerException(这也是它和 HashMap 的一大区别)。
  2. spread 扰动哈希:高 16 位异或低 16 位,再用 & HASH_BITS 去掉符号位,保证 (n-1) & hash 分布更均匀。
  3. 空桶 CAS:竞争极小,无锁成功率高。
  4. 非空桶 synchronized(f):只锁桶头,冲突域极小。
  5. 双重检查 tabAt(tab,i) == f:防止在获取锁期间该桶被扩容迁移走。
  6. addCount:累加计数,并判断是否需要扩容(sizeCtl 阈值)。

initTable(用 CAS 把 sizeCtl 从 0 置为 -1 抢到初始化资格):

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // 其他线程正在初始化,让出 CPU 自旋等待
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // CAS 抢到初始化权
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 默认 16
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2); // 阈值 = n * 0.75
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

五、get 为什么不需要加锁

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {                                   // 头节点就命中
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)                                            // 树或 ForwardingNode
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {                              // 遍历链表
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

不加锁的底气来自 volatile

  • tableNode.valNode.next 都是 volatile,保证可见性禁止重排序
  • 读线程总能看到已完成的写(happens-before),不需要加锁。
  • 若读到正在迁移的桶(hash < 0ForwardingNode),会调用 find新表查找。

代价是弱一致性:迭代器在遍历过程中,其他线程的增删改可能“部分可见”,但不会抛 ConcurrentModificationException,也不会死循环。这对缓存、计数等大多数业务场景完全够用。


六、size / mappingCount 如何高并发计数

size 计数机制

高并发下若用单个 AtomicLong 计数,多核 CAS 会剧烈自旋竞争。JDK 1.8 采用 baseCount + CounterCell[] 分片计数(与 LongAdder 同源):

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;
}

public int size() { return (int)sumCount(); }            // 返回 int,可能溢出截断
public long mappingCount() { return sumCount(); }        // 推荐用这个,返回 long

CounterCell@Contended缓存行填充,避免相邻分片伪共享:

@sun.misc.Contended
static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

注意:size()弱一致的估计值,不保证某一瞬间的精确值。它适合做监控/统计,不适合用来判断“集合是否刚好为空”这种强一致逻辑(见第九节坑点)。


七、扩容与数据迁移(transfer)

扩容迁移

触发时机:addCount 发现元素数量达到阈值(sizeCtl)。扩容是容量翻倍(2n)

核心亮点:多线程协助迁移

  • 每个线程认领一段桶区间:stride = (n >>> 3) / NCPU(至少 16),通过 transferIndex CAS 递减分配任务。
  • 已经搬完的桶被替换为 ForwardingNodehash = MOVED),后来的读写线程看到它就会先帮忙迁移,然后去新表操作。
  • 旧桶 i 的元素按 (e.hash & n) 拆分为两批:低位留在新表 i,高位去新表 i + n
  • 所有线程搬完后,把 table 指针指向 nextTable,旧表被 GC。

扩容期间能否正常读写?能。

  • get:碰到 ForwardingNode 直接去新表查。
  • put:碰到 MOVEDhelpTransfer 一起搬,搬完再写。
  • 这就是“并发迁移”比 1.7 高效很多的原因。

八、底层知识讲解(进阶)

8.1 CAS(Compare-And-Swap)

无锁原子操作:比较内存值与预期值,相等才更新,返回成功/失败。JDK 1.8 用 UnsafecompareAndSwapXxx 实现 casTabAt 等操作。

  • ABA 问题:值从 A→B→A,CAS 误以为没变。解决方案是加版本号(AtomicStampedReference),但 ConcurrentHashMap 的桶迁移场景下 ABA 不会影响正确性,故未引入。
  • 自旋开销:CAS 失败会重试,高竞争下可能空转,因此才引入 CounterCell 分片降低竞争。

8.2 synchronized 的锁升级

synchronized(f) 锁的是桶头节点,竞争极低。JVM 会:偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(操作系统互斥)。低竞争时几乎无开销。

8.3 volatile 的内存语义

tablevalnextvolatile 保证了:写操作对后续读可见;写操作不会与后续操作重排序。这是“读不加锁”的基石。

8.4 伪共享(False Sharing)与 @Contended

CPU 缓存以缓存行(通常 64 字节)为单位。若多个 CounterCell 落在同一缓存行,一个线程改其中一个会让其他核心的整行失效,导致额外同步开销。@Contended 通过填充让它们独占缓存行。

8.5 哈希扰动与高位参与

(h ^ (h>>>16)) & HASH_BITS 让高位也参与取模定位,避免只用低位导致的桶分布不均、链表过长。

8.6 为什么 key/value 不允许 null

并发下 get(key) 返回 null 无法区分“key 不存在”还是“value 为 null”,所以干脆禁止 null,从 API 层面消除歧义。

8.7 弱一致性迭代器

迭代器创建后,不要求反映后续所有修改,也不抛 CME。设计哲学:高并发下“看到大部分、不崩溃”比“某一刻精确”更有价值


九、实际开发中的需求与最佳实践

场景 1:高并发本地缓存 / 配置映射

// 多实例共享的配置缓存,读多写少
private static final ConcurrentHashMap<String, AppConfig> CONFIG_CACHE =
        new ConcurrentHashMap<>(64);

public AppConfig getConfig(String key) {
    return CONFIG_CACHE.computeIfAbsent(key, k -> loadFromDb(k)); // 注意第 6 点的坑
}

避免在 computeIfAbsentmappingFunction 里再调本 map 的其他写方法(JDK 8/9 有死循环/死锁 bug,JDK 10+ 已修;保险起见尽量别在映射函数内递归写同一 map)。

场景 2:并发计数(别用 size())

// 错误:用 size 做强一致判断
if (map.size() == 0) doSomething();   // 不可靠

// 正确:用 LongAdder / AtomicLong 做精确计数
private final LongAdder hitCounter = new LongAdder();
hitCounter.increment();
long hits = hitCounter.sum();

ConcurrentHashMap.size() 只是估计值,仅用于监控面板,不要用它做业务流程的分支判断。

场景 3:批量初始容量,避免频繁扩容

// 预计放入约 1000 个元素,负载因子 0.75
int expectedSize = 1000;
int capacity = (int) (expectedSize / 0.75f) + 1; // ≈ 1334,向上取 2 的幂
ConcurrentHashMap<String, Object> map = new ConcurrentHashMap<>(capacity);

不指定容量时默认 16,频繁 put 会触发多次扩容(transfer 有成本)。预估容量能显著减少扩容次数。

场景 4:别用大对象 / 可变对象当 key

  • key 的 hashCode()稳定且分布均匀。若 key 是自定义类,必须正确重写 equalshashCode,且不要让它可变(改了 key 的字段会导致再也查不到)。
  • 避免在 hashCode() 里做昂贵计算(如序列化整个对象)。

场景 5:批量写入用 putAll,但注意它不是原子的

map.putAll(anotherMap); // 整个过程并发安全,但其他线程可能在中间看到部分数据

putAll 是逐条 put不保证“要么全有要么全无”。如果业务需要“原子替换整个映射”,请用 replaceAll + 临时 map 切换引用,或加外部锁。

场景 6:用 putIfAbsent / replace 做原子操作

map.putIfAbsent(key, defaultValue);          // 不存在才放
map.replace(key, oldVal, newVal);            // CAS 式替换,成功返回 true

这些原子方法比“先 get 再 put”安全得多,是并发环境下避免覆盖的标准写法。

场景 7:遍历用 forEach / entrySet,别用 keys() 后逐个 get

map.forEach((k, v) -> System.out.println(k + "=" + v)); // 弱一致迭代,安全高效

弱一致迭代器不会抛异常,遍历过程中其他线程的修改可能不全部可见,但程序不会崩溃。

常见坑速查

说明
size() 当强一致用 只是估计值,业务判断用专用计数器
get(null) / put(null,..) 直接 NPE
computeIfAbsent 里写同一 map 旧 JDK 死循环/死锁
用可变对象当 key 改字段后查不到
不设初始容量 频繁扩容影响性能
putAll 当原子批量替换 实际是逐条 put

十、面试高频问题速答

  1. JDK 1.7 和 1.8 的区别?
    1.7 用 Segment 分段锁(继承 ReentrantLock),1.8 取消 Segment,改 Node[] + 链表/红黑树,CAS + synchronized 锁桶头,粒度更细、并发更高。

  2. 如何保证线程安全?
    初始化/空桶用 CAS;非空桶用 synchronized(f) 锁头节点;计数用 baseCount + CounterCell 分片;读操作靠 volatile 可见性不加锁。

  3. 为什么 1.8 用 synchronized 而不是 ReentrantLock?
    锁的是单个桶,竞争低,JVM 对 synchronized 有持续优化(锁升级、消除/粗化),且无需额外 AQS 对象、内存更省。

  4. get 为什么不加锁?
    因为 tablevalnext 都是 volatile,写对读可见,且无重排序;遇到 ForwardingNode 会去新表查。

  5. size 怎么统计?精确吗?
    baseCount + Σ CounterCell。是弱一致的估计值,不精确;需要精确计数请用 LongAdder

  6. 扩容时还能读写吗?
    能。扩容是多线程协助迁移,迁移中的桶插 ForwardingNode,读写线程看到后先帮忙迁移/去新表查。

  7. key/value 为什么不能为 null?
    并发下无法区分“不存在”和“值为 null”,直接禁止以消歧义。


十一、总结

ConcurrentHashMap 是“并发容器设计”的教科书:

  • 1.8 把锁粒度从段降到桶,配合 CAS 无锁写synchronized 细锁,并发度大幅提升;
  • 红黑树兜住哈希冲突的极端情况;
  • 分片计数 + 伪共享填充size 也不成为瓶颈;
  • 多线程协助扩容让扩容期间仍能正常读写。

理解它,不只要背八股,更要抓住一条主线:用更细的锁 + 无锁 + 内存可见性(volatile),把“安全”和“性能”同时拉满

源码版本说明:本文代码片段基于 OpenJDK 8 java.util.concurrent.ConcurrentHashMap,不同 JDK 小版本在命名/常量上可能略有差异,但核心设计一致。

posted @ 2026-09-13 16:02  白鹿为溪  阅读(11)  评论(0)    收藏  举报