彻底讲透 JDK1.8 ConcurrentHashMap 线程安全原理(全网最通俗图文完整版)
前言
HashMap 线程不安全,多线程并发 put 会出现数据覆盖、数据丢失;JDK1.7 还会出现扩容死循环。为了解决并发安全问题,JDK1.8 的 ConcurrentHashMap 抛弃了 JDK1.7 的 Segment 分段锁,采用volatile + CAS + synchronized + 并发扩容四大机制实现高性能线程安全。
本文从零梳理底层结构、每一个机制的作用位置、适用场景、图文对照,彻底搞懂 CHM 如何保证并发安全。
一、底层数据结构总览(图文)
JDK1.8 ConcurrentHashMap 底层结构:数组 + 链表 + 红黑树
1.1 普通链表桶结构(无树化)
【volatile 数组引用】 transient volatile Node<K,V>[] table 数组堆内存结构: table[0] → null table[1] → 🔒Node1(桶头节点 / synchronized 锁对象) ↓ next(volatile) Node2 ↓ next(volatile) Node3
核心规则:
数组中只有桶头 Node的引用存在 table 数组格子中
链表后续 Node2、Node3不在数组中,仅靠 Node.next 指针串联
所有 Node 对象全部创建在堆内存
1.2 红黑树桶结构(树化后)
table[2] → 🔒TreeBin(桶头、synchronized 锁对象,继承 Node) ├─ root → TreeNode(红黑树根节点,❗不是桶头) ├─ 挂载大量 TreeNode 左右子节点 └─ 内置读写锁,提升并发读性能
重中之重(90%人混淆):
树化后,数组桶头不是 TreeNode,而是TreeBin
TreeNode 只是内部数据节点,永远不会被锁
synchronized 锁的是桶头 TreeBin 对象
二、volatile 两层作用(核心难点彻底拆解)
ConcurrentHashMap 有两套独立的 volatile 机制,互不干扰,这是最容易混淆的知识点。
2.1 第一层:volatile 修饰 table 数组引用
transient volatile Node<K,V>[] table;
作用:保证「数组引用切换」的可见性
关键点纠正:
volatile不修饰数组内容、不修饰数组元素
仅修饰table 这个变量(保存数组堆地址)
数组一旦 new 出来,自身堆地址永远不变
volatile 只负责:扩容/初始化时 table = 新数组地址全局可见
场景:
首次 put 初始化数组
扩容结束切换新数组
table = nextTable
保证所有线程立刻识别新数组,不会继续读写旧数组,避免数据错乱。
2.2 第二层:volatile 修饰 Node 内部字段
static class Node<K,V> { final int hash; final K key; volatile V val; // 值可见性 volatile Node<K,V> next; // 指针可见性 }作用:保证节点数据、链表结构的可见性
val volatile:一个线程修改 value,其他线程立刻读到最新值
next volatile:线程追加新节点、修改链表指针,其他遍历线程立刻感知链表变化
核心价值:支撑get() 无锁读取,全程不加锁,依靠 volatile 可见性拿到最新数据,并发读性能极高。
2.3 补充:数组格子可见性(Unsafe 兜底)
table 的 volatile 不管数组内部元素修改,所以 JDK 底层通过三个 Unsafe 方法实现数组桶位的 volatile 读写:
tabAt:volatile 读桶头数据casTabAt:CAS 无锁更新桶位setTabAt:volatile 写桶位数据
三、CAS 无锁机制(空桶写入专属)
3.1 核心定位
CAS 是无锁乐观操作,不是加锁!
适用唯一场景:桶位为 null(空桶)
// 如果当前桶为空,CAS 尝试写入新节点 casTabAt(tab, i, null, new Node<>(hash, key, value, null));
3.2 执行逻辑
多线程同时写入同一个空桶
CAS 比较内存值:预期是 null 才写入
只有一个线程成功,其余线程自旋重试
空桶场景无锁竞争,性能极致,避免了无脑加锁。
四、synchronized 细粒度锁(冲突场景核心保障)
当桶位已有数据(链表/红黑树),CAS 无法解决并发冲突,启用 synchronized 互斥锁。
4.1 锁对象核心规则(全网最清晰总结)
链表桶:锁「桶头普通 Node 节点」
红黑树桶:锁「桶头 TreeBin 对象」
❌ 不锁链表所有节点、❌ 不锁 TreeNode、❌ 不锁数组
4.2 如何实现锁住整条桶数据?
并不是语法上锁了整条链表/红黑树,而是并发约定:
所有线程修改同一个桶的数据,必须先竞争桶头锁,同一时刻只有一个线程能修改该桶,从而保证整条链表/红黑树的线程安全。
4.3 锁粒度优势
不同桶互不阻塞,支持最大并发
废弃 JDK1.7 笨重的 Segment 分段锁
JDK1.8 对 synchronized 做了大量优化(偏向锁/轻量级锁),性能远超 ReentrantLock
五、并发扩容机制(兜底线程安全)
CHM 扩容不会阻塞所有线程,支持多线程协助扩容,是高并发核心亮点。
5.1 核心流程
达到扩容阈值,新建 nextTable 新数组
原桶正在迁移时,标记为ForwardingNode
其他线程写入时发现桶是 ForwardingNode,先协助扩容,迁移完成再写入
全部迁移完毕,执行
table = nextTable(volatile 全局可见)
彻底解决了 HashMap 扩容死循环、数据丢失问题。
六、完整 put 执行流程(串联所有机制)
判断数组是否未初始化,CAS 初始化数组(volatile 保证数组引用可见)
计算 hash 定位桶下标,通过 tabAt volatile 读获取最新桶头
桶为空:CAS 无锁写入新节点
桶正在扩容:协助扩容后再写入
桶已有数据:synchronized 锁桶头
链表:遍历覆盖/追加节点,依靠 node val/next volatile 保证可见性
链表长度≥8 且数组≥64:转为红黑树
更新元素数量,判断是否触发扩容
七、最终总结(可直接背诵、发博客收尾)
JDK1.8 ConcurrentHashMap 依靠三层可见性 + 无锁CAS + 细粒度锁 + 并发扩容全方位保证线程安全:
volatile 两层可见性:table 数组引用保证扩容切换全局可见;Node 的 val、next 保证节点数据和链表结构实时可见,支撑无锁读。
CAS 无锁写入:空桶场景乐观写入,无锁高并发,避免多余锁竞争。
synchronized 细粒度互斥:冲突场景锁桶头(Node/TreeBin),仅阻塞同桶线程,最大化并发性能。
并发扩容机制:多线程协助迁移,扩容不阻塞读写,彻底解决并发扩容数据异常问题。
相比 JDK1.7 分段锁,锁粒度更细、并发度更高、读写性能更优,是 Java 高并发场景下的标准键值存储方案。
(注:部分内容可能由 AI 生成)