浴火重生:从Segment到CAS+Synchronized——ConcurrentHashMap 1.7与1.8核心演进全解析
浴火重生:从Segment到CAS+Synchronized——ConcurrentHashMap 1.7与1.8核心演进全解析
|
🌺The Begin🌺点点关注,收藏不迷路🌺
|
1. 引言:并发容器的困局与破局
在Java并发编程的浩瀚星河中,ConcurrentHashMap无疑是一颗璀璨的明星。它的出现,完美解决了HashMap线程不安全与Hashtable性能低下这一两难困境。
但是,你是否真正思考过:JDK 1.7和1.8中的ConcurrentHashMap究竟有何本质区别?为什么1.8要抛弃曾经引以为傲的“分段锁”设计?从Segment到CAS+synchronized,这背后隐藏着怎样的设计哲学转变?
本文将带你深入源码,用流程图和对比分析,彻底搞懂这两个版本的核心差异。
2. 一图看懂:整体架构对比
| 对比维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | Segment数组 + HashEntry数组 + 链表 | Node数组 + 链表 + 红黑树 |
| 锁机制 | ReentrantLock(分段锁) | CAS + synchronized(桶锁) |
| 锁粒度 | Segment级别(默认16个) | 单个桶(bucket)级别 |
| 并发度 | 固定为Segment数组长度 | 理论上等于数组长度 |
| 查询复杂度 | O(n) | O(log n)(红黑树优化) |
3. JDK 1.7:分段锁的辉煌时代
3.1 核心设计思想:“分而治之”
JDK 1.7的ConcurrentHashMap采用了经典的分段锁设计,将整个Map分割成多个独立的Segment,每个Segment都是一个“微型HashMap”,拥有自己的锁。
3.2 源码解析:Segment的定义
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 真正存放数据的桶数组
transient volatile HashEntry<K,V>[] table;
// 元素个数
transient int count;
// 扩容阈值
transient int threshold;
// 负载因子
final float loadFactor;
}
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value; // volatile保证可见性
volatile HashEntry<K,V> next; // volatile保证可见性
}
关键点:
- Segment继承ReentrantLock:每个Segment都是一把独立的锁
- HashEntry的value和next用volatile修饰:保证读操作无需加锁即可看到最新值
3.3 put操作流程图解
put操作核心流程:
- 二次哈希定位Segment
- 调用
tryLock()尝试获取锁 - 失败则
scanAndLockForPut()自旋(最多64次),超时则阻塞 - 定位桶,遍历链表
- 头插法插入新节点
- 判断是否需要扩容(Segment独立扩容)
- 释放锁
3.4 get操作:无锁读的秘密
public V get(Object key) {
int hash = hash(key);
// 定位Segment
Segment<K,V> s = segmentForHash(hash);
HashEntry<K,V>[] tab = s.table;
// 定位桶,遍历链表
for (HashEntry<K,V> e = tab[index]; e != null; e = e.next) {
if (e.hash == hash && key.equals(e.key))
return e.value; // volatile保证可见性
}
return null;
}
为什么get不需要加锁?
- HashEntry的value和next都是volatile修饰
- 通过Unsafe的
getObjectVolatile保证读取最新值 - 弱一致性的设计:可能读到旧值,但不会读到无效值
4. JDK 1.8:从分段锁到CAS+synchronized的蜕变
4.1 核心变革:为什么放弃Segment?
Segment设计虽然优秀,但存在三大痛点:
- 并发度受限:默认只有16个Segment,无法充分利用多核CPU
- 结构臃肿:两层数组导致内存开销大
- 扩容效率低:每个Segment独立扩容,无法并行
JDK 1.8彻底重构,采用CAS + synchronized组合拳:
4.2 新的数据结构:Node + 链表 + 红黑树
// Node节点:取代HashEntry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // volatile保证可见性
volatile Node<K,V> next;
}
// 红黑树节点(当链表长度≥8时转换)
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
}
为什么引入红黑树?
- 链表过长时查询效率O(n)
- 红黑树将最坏复杂度降至O(log n)
4.3 put操作:CAS + synchronized协同作战
核心代码解析:
final V putVal(K key, V value, boolean onlyIfAbsent) {
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 情况1:数组未初始化,CAS初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 情况2:桶为空,CAS直接插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
// 情况3:正在扩容,协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 情况4:桶不为空,synchronized锁住头节点
else {
V oldVal = null;
synchronized (f) {
// 再次确认头节点未变化
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 链表
// 遍历链表,尾插法
} else if (f instanceof TreeBin) { // 红黑树
// 树节点插入
}
}
}
// 检查是否需要转红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
break;
}
}
addCount(1L, binCount);
return null;
}
4.4 为什么选择synchronized而不是ReentrantLock?
| 对比项 | ReentrantLock | synchronized (1.6+) |
|---|---|---|
| 性能 | 较高 | 已优化,接近ReentrantLock |
| 锁升级 | 无 | 偏向锁→轻量级锁→重量级锁 |
| 自旋 | 需手动实现 | JVM自动优化 |
| 代码简洁 | 较复杂 | 简洁 |
| 资源开销 | 较高(AQS Node) | 较低 |
结论:锁细化到桶级别后,竞争概率极低,synchronized的轻量级锁和自旋优化足以应对,且避免了ReentrantLock的AQS节点开销。
5. 扩容机制的对决
5.1 JDK 1.7:单线程分段扩容
特点:
- 每个Segment独立扩容,不影响其他Segment
- 单线程执行,无法并行
- 扩容时该Segment的读写被阻塞
5.2 JDK 1.8:多线程协作扩容
这是1.8最精妙的设计:让读写线程都参与扩容。
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, null); // hash = -1
this.nextTable = tab;
}
}
当线程访问到ForwardingNode时,就知道该桶已迁移,会主动帮助完成剩余迁移任务。
6. 核心差异对比总结
| 对比维度 | JDK 1.7 ConcurrentHashMap | JDK 1.8 ConcurrentHashMap |
|---|---|---|
| 数据结构 | Segment + HashEntry + 链表 | Node + 链表 + 红黑树 |
| 锁机制 | ReentrantLock(分段锁) | CAS + synchronized(桶锁) |
| 锁粒度 | 整个Segment(含多个桶) | 单个桶(一个链表/树的头节点) |
| 并发度 | 固定为Segment数(默认16) | 理论等于数组长度,动态 |
| 插入方式 | 头插法 | 尾插法 |
| 扩容方式 | 单线程,Segment独立扩容 | 多线程协作,全局扩容 |
| 扩容时机 | 先判断扩容,再插入 | 先插入,再判断扩容 |
| 查询复杂度 | O(n) | O(log n)(红黑树优化) |
| size()实现 | 加锁累加Segment.count | baseCount + CounterCells |
| 死锁风险 | 无 | 无(尾插法避免扩容死循环) |
| 性能特点 | 并发度有上限 | 近乎无锁,性能随CPU核心数扩展 |
7. 日常用法对比
// 1.7和1.8的API完全一致,底层实现不同
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
// 基本操作
map.put("key", "value");
String value = map.get("key");
// 原子操作(两个版本都支持)
map.putIfAbsent("key", "default");
map.computeIfAbsent("key", k -> fetchFromDB(k));
// 遍历(弱一致性)
map.forEach((k, v) -> System.out.println(k + "=" + v));
8. 面试高频追问
8.1 ConcurrentHashMap能保证强一致性吗?
不能。 它是最终一致性。get操作不加锁,可能读到其他线程正在修改但尚未提交的数据。
8.2 为什么不允许null键和null值?
因为HashMap中containsKey(key)返回false时,无法区分是key不存在还是value为null。在多线程环境下,这种二义性会带来严重问题。
8.3 红黑树的转换阈值为什么是8?
统计学上,哈希分布符合泊松分布,链表长度达到8的概率极小(约0.00000006)。此时转换红黑树是性价比最高的选择。
9. 总结:演进的智慧
从JDK 1.7到1.8,ConcurrentHashMap的演进体现了Java并发编程的两大趋势:
- 锁粒度越来越细:从锁Segment到锁单个桶
- 无锁化倾向增强:CAS操作的大量使用
选择建议:
- 生产环境优先使用JDK 1.8及以上版本
- 如果需要高并发读写,1.8的多线程协作扩容优势明显
- 代码无需改动,JVM自动优化
📌 本文基于JDK 1.7.0_80和JDK 1.8.0_202源码分析,不同小版本可能存在细微差异。
如果觉得本文对你有帮助,欢迎点赞、收藏、转发~

|
🌺The End🌺点点关注,收藏不迷路🌺
|
更多推荐





所有评论(0)