ConcurrentHashMap 深度解析:高并发下的线程安全 HashMap


一、JDK 1.7 vs JDK 1.8 架构对比

1.1 JDK 1.7:分段锁架构

ConcurrentHashMap (JDK 1.7)
├── Segment[0] ──→ HashEntry[] ──→ 链表
├── Segment[1] ──→ HashEntry[] ──→ 链表
├── ...
└── Segment[15] ──→ HashEntry[] ──→ 链表
    ↑
  ReentrantLock

结构本质数组 + 数组 + 链表(多套了一层 Segment 外壳,16个带锁的小HashMap拼成一个大Map)

通俗理解

  • Segment 就是一个小型、带锁的 HashMap
  • ConcurrentHashMap 就是把 16 个 HashMap 拼成一个大 Map
  • 每个 Segment 独立持有 ReentrantLock,实现分段锁

核心特点:

  • 并发度:最多支持 16 个分段并行写入,同一 Segment 还是串行
  • 两次取模:定位数据需要两次哈希取模

两次取模过程

  1. 第一次取模:哈希值 & (Segment 数组长度 - 1) 定位 Segment 下标
  2. 第二次取模:哈希值 & (HashEntry 数组长度 - 1) 定位桶位置

原理:不同 Segment 的操作互不抢锁,同一 Segment 内排队上锁


1.2 JDK 1.8:CAS + synchronized 架构

ConcurrentHashMap (JDK 1.8)
└── Node[] table(单层哈希数组)
    ├── 桶0:链表 / 红黑树
    ├── 桶1:链表 / 红黑树
    └── ...

结构本质数组 + 链表 / 红黑树(去掉 Segment,简化成一层,原生 HashMap 套了一层 CAS + 桶头 synchronized 锁)

通俗理解

  • 结构完全照搬 HashMap(数组 + 链表 / 红黑树)
  • 只是加了并发控制(CAS + synchronized)
  • 保证多线程不乱结构、不丢数据

核心特点:

  • 锁粒度更细锁住当前哈希桶的第一个头节点,不是锁数组、不是锁整个 map,别的桶完全不影响,并发度极高
  • CAS 无锁操作:尝试无锁插入,失败才加锁
  • 红黑树优化:链表长度 ≥ 8 并且 数组长度 ≥ 64 才树化;节点 ≤ 6 退化成链表(7 是缓冲区间,避免频繁转换)

原理:CAS 快速路径 + synchronized 兜底,读操作几乎无锁

为什么放弃 ReentrantLock 改用 synchronized?

JDK 1.6 后 synchronized 做了锁升级优化:
┌─────────────────────────────────────────────────────┐
│  偏向锁 → 轻量级锁 → 重量级锁                        │
│     ↓          ↓          ↓                        │
│  无竞争    少量竞争    重度竞争                       │
└─────────────────────────────────────────────────────┘
对比维度 ReentrantLock synchronized
锁升级 支持(偏向→轻量→重量)
优化空间 API层,难优化 JVM原生,可逃逸分析、锁消除
代码复杂度 需手动加解锁 自动加解锁,更简洁
性能 较好 JDK1.6+ 性能相当甚至更优

结论:锁粒度降到桶头节点后,用 synchronized 更轻量、更简洁,JVM 还能做更多优化


1.3 架构差异对比表

维度 JDK 1.7 JDK 1.8
结构层次 双层(Segment + HashEntry) 单层(Node[])
锁机制 ReentrantLock 分段锁 CAS + synchronized 桶头锁
并发度 固定16 理论上等于桶数
扩容方式 各 Segment 独立扩容 全局渐进式扩容
数据结构 链表 链表 + 红黑树
内存开销 较高(Segment 开销) 较低

二、核心区别:同桶不同 Key 的并发写入

2.1 HashMap 的问题(JDK 1.8)

// 多线程往同一个桶写入不同 key
// 问题:无锁争抢,后线程覆盖前线程的节点
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        map.put(Thread.currentThread().getId(), "value");
    }).start();
}
// 结果:部分 key 丢失,数据错乱

问题本质

  • 尾插法虽解决了链表成环,但未解决数据覆盖
  • 无锁操作导致节点引用被覆盖
  • 扩容时可能出现数据丢失

2.2 ConcurrentHashMap 的保障

// 多线程往同一个桶写入不同 key
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        chm.put(Thread.currentThread().getId(), "value");
    }).start();
}
// 结果:所有 key 都保留,结构完整

保障机制

// 关键代码:synchronized 锁住桶头节点
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, null);
                    break;
                }
            }
        }
        // 红黑树处理...
    }
}

核心逻辑

  1. 同一桶内的操作串行化
  2. 线程 A 先入队,线程 B 等待
  3. 线程 B 获得锁后,在链表尾部插入
  4. 不同 key 全部保留,不会互相覆盖

三、关键误区澄清

3.1 关于同一个 Key 的覆盖

场景 HashMap ConcurrentHashMap
同 key 并发 put 后写入覆盖前写入 后写入覆盖前写入

说明:同 key 覆盖是正常的业务行为,两个线程写同一个 key,后写入的值会覆盖前一个。若业务需要避免覆盖或实现原子更新,可使用以下原子操作方法:

// 原子操作方法
chm.putIfAbsent("key", "value");   // 不存在才插入
chm.merge("key", "value", (oldVal, newVal) -> oldVal + newVal);  // 合并
chm.compute("key", (k, v) -> v == null ? 1 : v + 1); // 计算更新

四、硬性特性必记

4.1 核心特性

特性 说明
null 值 key、value 都不能为 null
读操作 几乎无锁(volatile 保证可见性)
写操作 细粒度锁(只锁当前桶)
扩容 渐进式扩容,多线程协助迁移
结构安全 保证底层数据结构完整性
原子性 不保证业务复合操作的原子性

为什么 key/value 不能为 null?

HashMap 能存 null,为什么 ConcurrentHashMap 不能?

核心原因:并发环境下 get(key) 返回 null 会产生歧义,无法区分两种情况:

情况 场景
情况1 key 根本不存在于 map 中
情况2 key 存在,但对应的 value 就是 null

示例

// 线程 A
chm.put("key", null);  // 假设允许存 null

// 线程 B
Object val = chm.get("key");  // 返回 null
// 此时无法判断:是 key 不存在?还是 value 本身就是 null?

结论:并发场景下无法通过返回值判断真实情况,容易引发业务 bug,因此 ConcurrentHashMap 直接禁止存 null


4.2 扩容机制对比

JDK 1.7:Segment独立扩容

扩容过程:
1. 单个Segment触发扩容阈值
2. 对该Segment加锁(ReentrantLock)
3. 该Segment内的数据迁移到新数组
4. 扩容期间,该Segment的读写都阻塞

特点:每个Segment独立扩容,锁粒度大,会阻塞

JDK 1.8:渐进式扩容

扩容过程:
1. 检测容量阈值 → 初始化新数组
2. 多线程分段迁移(每个线程负责一部分桶)
3. 使用 ForwardingNode 标记正在迁移的桶
4. 迁移期间:读操作可正常进行,写操作协助扩容

特点:全局统一扩容,渐进式迁移,读写不阻塞

扩容机制对比表

维度 JDK 1.7 JDK 1.8
扩容范围 单个Segment独立扩容 全局统一扩容
锁粒度 整个Segment锁 无锁 + CAS + 桶头锁
并发影响 扩容Segment阻塞 读写都不阻塞
迁移方式 单线程一次性迁移 多线程渐进式迁移
迁移期间 Segment不可用 可正常读写,写协助扩容

优势:JDK 1.8的渐进式扩容避免了单线程扩容导致的长时间阻塞


五、与 HashMap 的核心区别

维度 HashMap ConcurrentHashMap
线程安全 ❌ 不安全 ✅ 安全
同桶不同 key 会覆盖丢失 排队写入,全部保留
锁机制 无锁 CAS + synchronized
扩容影响 单线程,阻塞 多线程协助,非阻塞
null 值 支持 不支持

六、使用场景与最佳实践

6.1 适用场景

场景 是否适用 说明
高并发读写 核心适用场景
缓存实现 线程安全的缓存容器
共享状态管理 多线程共享配置
计数器 使用 compute/merge

6.2 代码示例

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

// 基本操作
chm.put("apple", 1);
Integer value = chm.get("apple");

// 原子操作(推荐)
chm.putIfAbsent("orange", 3);                    // 不存在才插入
chm.computeIfAbsent("grape", k -> 4);            // 不存在时计算
chm.computeIfPresent("apple", (k, v) -> v + 1);  // 存在时更新
chm.merge("banana", 1, Integer::sum);            // 合并值

// 批量操作(弱一致性)
chm.forEach((k, v) -> System.out.println(k + ": " + v));

6.3 注意事项

  1. 避免 null 值:会抛出 NullPointerException
  2. 迭代器弱一致性:迭代过程中看到的是某个时刻的快照
  3. size() 非精确:高并发下返回近似值,精确计数用 mappingCount()
  4. 复合操作需原子化:如 get + put 需用 compute 系列方法

七、总结

核心要点

  1. JDK 1.8 架构革新:舍弃 Segment,采用单层哈希数组 + CAS + synchronized
  2. 锁粒度优化:从分段锁(最多16并发)到桶头锁(理论无限并发)
  3. 结构安全保障:同桶不同 key 并发写入不丢数据、不乱结构

版本对比与核心区别

版本 架构 并发度 核心特点
JDK 1.7 双层结构(Segment + HashEntry) 固定16 分段锁,层级笨重
JDK 1.8 单层结构(Node[]) 理论无限 CAS + 桶头锁,粒度更细

与 HashMap 最大区别:同桶不同 key 并发写入,CHM 不丢数据不乱结构,HashMap 会丢数据

核心问题解答

ConcurrentHashMap 在 JDK 8 中如何实现线程安全?

  • CAS 尝试无锁插入,失败则 synchronized 锁桶头节点
  • 读操作无锁,靠 volatile 保证可见性
  • 渐进式扩容,多线程协助不阻塞读写
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容