在 Java 并发编程中,我们经常会遇到需要多个线程同时访问和修改共享数据的情况。普通的 HashMap 在并发场景下并不安全,可能导致数据丢失、死循环等问题。为了解决这个问题,JDK 提供了 ConcurrentHashMap。那么问题来了:为什么 ConcurrentHashMap 是线程安全的?它内部到底用了什么机制?

本文将详细解析 ConcurrentHashMap 的设计原理,并通过代码示例来说明它的线程安全性。


1. HashMap 为什么线程不安全?

我们先回顾一下 HashMap 的问题。

  • 在多线程同时 put 的时候,可能导致 数据覆盖
  • 在扩容(resize)过程中,可能会因为链表迁移出现 环形链表,导致 CPU 占满;
  • 没有任何同步措施,多个线程修改同一个桶时,数据状态完全不可控。

示例(线程不安全的场景):

import java.util.HashMap;

public class HashMapUnsafeDemo {
    static HashMap<Integer, String> map = new HashMap<>();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                map.put(i, "t1-" + i);
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                map.put(i, "t2-" + i);
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("map.size=" + map.size()); // 可能小于 2000
    }
}

运行结果:map.size 可能小于 2000,因为数据覆盖或丢失。


2. ConcurrentHashMap 为什么线程安全?

ConcurrentHashMap 的核心思路是 分而治之 + CAS + volatile

JDK 7 的实现

  • 使用 分段锁(Segment),每个 Segment 内部用 ReentrantLock 控制;
  • 多个线程只要操作的 key 落在不同 Segment 上,就能并行执行,减少锁竞争。

JDK 8 的实现

  • 放弃了 Segment,直接基于 数组 + 链表 + 红黑树
  • 使用 CAS(Compare-And-Swap)synchronized 锁定桶(bin) 来实现更细粒度的并发控制;
  • volatile 保证可见性。

关键机制

  1. CAS + 自旋
    插入新节点时,使用 CAS 方式设置,避免全表加锁。

    if (tabAt(tab, i) == null) {
        if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
            break; // 插入成功
    }
    
  2. 桶级别 synchronized
    如果一个桶里已经有节点(链表或红黑树),则用 synchronized 锁定桶头节点,保证同一桶的并发安全。

  3. volatile 保证可见性
    Node 的 valnextvolatile 修饰,确保修改对其他线程立即可见。


3.JDK 7:Segment 分段锁版(ReentrantLock)

1)整体结构与关键字段

  • 结构:Segment<K,V>[] segments + 每个 Segment 里一张 独立的 HashEntry<K,V>[] table
  • Segment 继承 ReentrantLock,所以对单段的写可以用显式锁保护,跨段可并行
  • 典型字段(精简):
    • volatile HashEntry<K,V>[] table:段内桶数组。
    • volatile int count:段内键值对数量。
    • int threshold, float loadFactor:控制段内扩容。
    • volatile int modCount:结构性修改计数(用于 size 计算的重试)。
  • HashEntry(桶节点):
    • final K key; final int hash; volatile V value; volatile HashEntry<K,V> next;
    • value/nextvolatile 保证读写可见;读路径无需加锁(只读 volatile)。

2)定位到 Segment 与桶

  • 初始化时根据 concurrencyLevel 选择段数(2 的幂,默认 16)。
  • 根据 key 的 hash 做高位扰动后:
    • 段下标(hash >>> segmentShift) & segmentMask
    • 桶下标indexFor(hash, table.length)

3)get 流程(无锁快读)

  1. 计算段、取到段里的 table
  2. 在对应桶上顺着 nextvolatile)查找相同 key;
  3. 仅发生 volatile 读,不加锁
    线程安全点:由于 value/nextvolatile,并发写入对读者立即可见;段内结构性修改在写锁保护下完成,发布时对读者可见。

4)put/replace/remove 流程(段锁保护)

  • 进入段:先尝试 乐观读;若发现需要修改则 lock() 加段锁。
  • put
    1. 拿锁→遍历桶→存在相同 key 则覆盖 value
    2. 不存在则头插新节点;
    3. count++,若 count > threshold 触发段内扩容(rehash 整个段的 table)。
  • remove/replace 同理:拿锁→链表里删除/替换→count--/modCount++

线程安全点:写入期间只有该段被独占,不影响其它段;段内读者受 volatile 与锁释放的 happens-before 保障。

5)扩容(段内 rehash)

  • 仅扩容该段的 table,迁移链表到新桶数组;
  • 其他段不受影响(扩展性的一部分),但段数固定(见缺点)。

6)size/isEmpty 的一致性策略

  • size() 会遍历所有段累计 count。为保证近似一致性:
    1. 先尝试两次不加锁的累加,并检查各段的 modCount 是否变化;
    2. 若变化(说明有并发写),则退化为对每段加锁后再统计一次。
  • 这就是“弱一致”的体现:追求高性能,不保证强一致大小,但提供“足够准确或加锁重试”的策略。

7)优缺点

  • 优点:锁分散(最多到段级),读无锁,写并行性好于全表锁。
  • 缺点:段数在构造时固定,扩展性一般;段内仍是链表结构,高冲突时退化明显;扩容是段级串行

4.JDK 8:无 Segment,CAS + synchronized + 红黑树

JDK 8 直接用一张表 Node<K,V>[] table,抛弃 Segment并发控制更细,配合树化降低高冲突退化。

1)核心结构与关键类

  • Node<K,V>final int hash; final K key; volatile V val; volatile Node<K,V> next;
  • TreeNode<K,V>:红黑树节点(父/子/颜色等)。
  • TreeBin桶为红黑树时的包装,内部维护 root、读写控制位等。
  • 关键控制字段:
    • volatile Node<K,V>[] table:主数组。
    • volatile int sizeCtl:表初始化/扩容控制:
      • sizeCtl < 0:正在扩容(负值编码并发迁移状态)
      • sizeCtl = 0:尚未初始化
      • sizeCtl > 0:下一次触发扩容的阈值或初始容量提示
    • 计数器:baseCount + CounterCell[]分片计数,减少热点),mappingCount()/size() 依赖它们求和。

2)并发控制三板斧(如何协同)

(1)CAS + 自旋(无锁开路)
  • 表初始化:用 CAS 抢占 sizeCtl 完成一次性初始化。
  • 首个节点插入:目标桶为 null 时,CAS 直接把新 Node 放进去,无锁完成写入
  • CAS 失败 → 说明有竞争 → 短自旋 或进入下一步。
(2)synchronized 桶级别锁(细粒度互斥)
  • 桶非空(链表/树)时,用 synchronized(f) 锁住桶头节点 f
    • 在该桶内完成插入、更新、删除、链表到树的转换等;
    • 只互斥该桶,其它桶并发互不干扰。
  • 选择 synchronized 的原因:
    • JIT 对轻量级锁/偏向锁/锁消除优化成熟;
    • 内存占用更低,避免每桶显式锁对象。
(3)volatile 可见性(读路径无锁)
  • Node.val/nexttable 等关键引用 volatile
  • get() 全程无锁:只读 volatile 即可看到最新发布的结构;
  • 对树桶,TreeBin.find 也能在读模式下无锁遍历(必要时会做短暂协助/退化)。

三者协作:“能 CAS 就 CAS;CAS 不成,锁到桶;所有结构发布靠 volatile”

3)典型操作流程

A. get(完全无锁)
  1. 取表 table、算桶下标,读桶头 f
  2. f == null → 返回 null
  3. f.hash < 0 → 特殊节点:
    • TreeBin:树查找;
    • ForwardingNode:说明在扩容迁移,按新表继续找
  4. 普通节点:链表遍历 nextvolatile)匹配 key 返回 val

线程安全点:所有读只依赖 volatile可见性与单向发布,且迁移时采用 ForwardingNode 保证读者能“跟着读”。

B. put/putIfAbsent
  1. 表未初始化 → CAS 初始化(sizeCtl 控制);
  2. 定位桶:
    • 桶为 nullCAS 头插成功即结束;
    • 桶非空 → synchronized(f)
      1. 链表:遍历存在则覆盖,不存在尾插;长度达到阈值树化;
      2. 树桶(TreeBin):按红黑树规则插入/变色/旋转;
  3. 完成后 分片计数器 增加,用于 size()

树化触发条件

  • 常量:TREEIFY_THRESHOLD = 8UNTREEIFY_THRESHOLD = 6MIN_TREEIFY_CAPACITY = 64
  • 当桶中节点数 ≥ 8 且表容量 ≥ 64 才树化,否则优先扩容来分摊冲突(比过早树化更划算)。
C. remove/replace
  • 桶非空时 synchronized(f),从链表/树中删除或替换;
  • 可能触发 反树化(桶中元素回落到 6 以下)。
D. 扩容(并行迁移,性能关键)
  • 触发:计数器超过阈值 sizeCtl
  • 过程:
    1. sizeCtl 设为负值,编码“正在扩容”和并行参与者数量
    2. 设置 transferIndex,把桶区间切成步长(stride),允许多个线程并行迁移各自的区间;
    3. 迁移一个桶后,用 ForwardingNode 替换原槽:指向新表,告诉读者/后续写者“去新表找/写”;
    4. 全部分段迁移完成后,将 table 指向新表,恢复 sizeCtl
  • 读/写在迁移期的表现:
    • get 碰到 ForwardingNode跳到新表继续;
    • put 也会协助迁移或直接对新表写,无全表停顿

线程安全点:通过 ForwardingNode + sizeCtl/transferIndex 的协同,保证“迁移可并行,读写不中断,结构发布有序”。

4)计算类方法的原子性

  • computeIfAbsent(key, fn):仅在“确实不存在”时在桶锁内调用 fn 并插入;多线程并发只会一个成功执行插入(但 fn 可能被多线程并发计算,只有持桶锁的能写入)。
  • merge(key, val, remap):在桶锁内拿旧值、合并并回写,一次互斥完成
  • putIfAbsent:不存在才插入,存在不覆盖;读冲突时会在桶锁内二次确认,避免竞态。

5)计数与 size()

  • 不是简单的 AtomicLong,而是 baseCount + CounterCell[]分片累加,类似 LongAdder):
    • 减少热点争用;
    • size()/mappingCount() 对它们求和(弱一致,足够快)。

6)为什么 JDK 8 在读多写少更快?

  • 读:全程无锁,只有 volatile 成本;
  • 写:乐观 CAS 优先,失败才落到桶级锁,锁冲突面小;
  • 高冲突桶树化:O(logN) 查找、写入;
  • 扩容:并行迁移,无全表 Stop-the-world。

5.内存模型与 happens-before(两代共同的“看不见却最关键”的保障)

  • 发布(Publish):把新节点放入 table[i] 的动作(CAS/写)在 JMM 下对后续读者可见;
  • 可见性tableNode.val/nextForwardingNode.nextTable 等是 volatile,读者看到最新结构
  • 互斥区释放:JDK 7 的段锁释放、JDK 8 桶锁释放后,对修改建立 happens-before,保证后续读能见到结果;
  • 有序性:Unsafe/VarHandle 的有序写与 volatile 读写共同确保“先构造,再发布”。

6.实战:同一 Key 只加载一次(JDK 8)

// 典型:缓存缺失时加载,避免多线程重复打 DB
ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();

public Object getUser(String id) {
    return cache.computeIfAbsent("user:" + id, k -> loadFromDb(id));
}
  • 多线程同时请求同一 key 时,可能多个线程并发执行 loadFromDb,但只有持桶锁的那个线程能把结果放入 Map;其余线程返回已放入的值。
  • 若你想严格只执行一次计算(避免多次函数执行),可以:
    • 方案 A:CompletableFuture 协作(值是 Future,先占坑);
    • 方案 B:值用 LongAdder/AtomicReference免重复开销的容器。

7.实战:并发计数器(JDK 8)

ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();

public void inc(String key) {
    counter.computeIfAbsent(key, k -> new LongAdder()).increment();
}

public long get(String key) {
    LongAdder adder = counter.get(key);
    return adder == null ? 0 : adder.sum();
}
  • Map 保证 桶级互斥 的结构修改;
  • LongAdder 在高并发下比 AtomicLong 更抗争用;
  • 读路径零锁化,吞吐高。

8.易踩的坑 & 选型建议

  1. 不支持 null key/value
    • 语义模糊(是“缺失”还是“有值为 null”)在并发下会放大问题;
  2. 键需“稳定”
    • hashCode/equals 依赖的字段不要在放入后再修改,否则定位/比较失真;
  3. 不要在外部再包一层全局锁
    • 破坏了内部的细粒度并发优化;
  4. 高冲突优先扩容而非强行树化
    • 初始化 initialCapacity 略大些,避免频繁扩容与链表过长;
  5. JDK 7 与 8 的差异
    • JDK 7:选择合适 concurrencyLevel,但段数一旦确定无法改变
    • JDK 8:concurrencyLevel 仅作为提示意义不大,关注初始容量即可。

5.小结对照表

维度 JDK 7(Segment) JDK 8(CAS + sync + 树)
并发读 无锁(volatile 读) 无锁(volatile 读)
并发写 段锁(ReentrantLock) CAS 头插 + 桶锁(synchronized)
冲突热点 段内链表,易退化 树化为红黑树,O(logN)
扩容 段内串行迁移 并行迁移(ForwardingNode + transferIndex)
计数 段级统计 + 重试 分片计数器(LongAdder 风格)
扩展性 段数固定,扩展一般 细粒度,整体扩展性更好
适用场景 老项目/固定段并发 读多写少/热点键/大表(更优)

6. 常用方法的线程安全性

put(K key, V value)

  • 如果桶为空,CAS 插入;
  • 如果桶不为空,对该桶加锁(synchronized)后插入或更新;
  • 并发下不会出现数据丢失。

get(Object key)

  • 无需加锁,直接通过 volatile 可见性 保证读取安全;
  • 时间复杂度 O(1)。

remove(Object key)

  • 找到桶并加锁,安全移除;
  • 并发下不会出现“读到已经删除的数据”。

computeIfAbsent(K key, Function mappingFunction)

  • 如果 key 不存在,原子地计算并插入;
  • 避免了多线程下重复计算的问题。

7. 举例说明线程安全性

我们用一个例子来演示 ConcurrentHashMap 的线程安全。

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapDemo {
    static ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                map.put(i, "t1-" + i);
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                map.put(i, "t2-" + i);
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("map.size=" + map.size()); // 结果一定是2000
    }
}

运行结果:map.size=2000,说明所有写操作都被安全地执行,没有丢失。


8. 实际应用场景

  • 缓存:多个线程同时读写缓存,不会丢失数据;
  • 统计计数器:可结合 computemerge 方法进行原子更新;
  • 消息系统:存储连接会话,支持高并发访问。

例如:并发计数器

ConcurrentHashMap<String, Integer> counter = new ConcurrentHashMap<>();

public void increment(String key) {
    counter.merge(key, 1, Integer::sum);
}

merge 方法保证并发更新时不会丢失数据。


9. 总结

  1. HashMap 在并发场景下不安全,可能数据丢失或死循环;
  2. ConcurrentHashMap 通过 CAS、自旋、synchronized(桶级别锁)、volatile 可见性 实现线程安全;
  3. 常用方法如 putgetremove 都是线程安全的;
  4. 在高并发场景下,ConcurrentHashMapHashtableCollections.synchronizedMap() 的更优替代。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐