ConcurrentHashMap 1.7 源码深度解析:分段锁的设计与实现
前言
在Java并发编程中,HashMap因线程不安全 在多线程环境下会出现链表环、数据丢失等问题,而Hashtable虽通过全局synchronized锁 保证线程安全,但锁粒度太大,所有操作都竞争同一把锁,并发效率极低。为了解决这一矛盾,JDK1.5引入了ConcurrentHashMap,JDK1.7版本的ConcurrentHashMap采用经典的「分段锁(Segment)」机制,通过减小锁粒度实现高并发,成为多线程环境下哈希表的首选。
本文将从底层结构、核心设计原理、核心方法源码、扩容机制等维度,结合流程图和源码片段,深度解析ConcurrentHashMap 1.7的实现细节,同时讲解其并发安全的核心保障,让大家理解「分段锁」设计的精髓。本文适合Java后端开发工程师、并发编程学习者,也是Java面试的高频考点解析。
一、ConcurrentHashMap 1.7 核心底层结构
ConcurrentHashMap 1.7的底层结构采用三层嵌套 的设计:Segment数组 + HashEntry数组 + 单向链表 ,核心是通过Segment实现分段锁,这也是其与HashMap 1.7(数组+链表)、Hashtable(全局锁的数组+链表)最核心的区别。
1.1 核心结构组成
- Segment :继承自
ReentrantLock(可重入锁),是ConcurrentHashMap的分段锁核心 ,一个Segment就是一个独立的锁区域;多个Segment组成一个Segment数组,默认长度为16(对应默认并发级别16)。 - HashEntry :是ConcurrentHashMap的数据存储节点 ,采用
final修饰保证不可变,通过volatile修饰next节点保证可见性,是并发安全的基础。 - HashEntry数组:每个Segment内部维护一个HashEntry数组,数组的每个元素是一个HashEntry单向链表的头节点,用于解决哈希冲突。
1.2 结构可视化流程图
ConcurrentHashMap
Segment数组
默认长度16
每个Segment是独立锁
Segment[0]
ReentrantLock+HashEntry数组
Segment[1]
ReentrantLock+HashEntry数组
...
共16个Segment
Segment[15]
ReentrantLock+HashEntry数组
HashEntry[0]链表头
HashEntry[1]链表头
HashEntry[len-1]链表头
HashEntry节点
final key
final hash
volatile value
volatile next
下一个HashEntry节点
...链表尾
1.3 核心成员变量(源码节选)
java
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
// 1. Segment数组,存储所有分段锁
final Segment<K,V>[] segments;
// 2. 默认初始容量:16(整个Map的初始容量,平均分配给16个Segment,每个Segment初始容量1)
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 3. 默认负载因子:0.75(单个Segment的负载因子,与HashMap一致)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 4. 默认并发级别:16(决定Segment数组的默认长度,理论上支持16个线程同时操作)
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 5. 单个Segment的最大容量:2^30
static final int MAX_SEGMENT_TABLE_CAPACITY = 1 << 30;
}
// Segment类:继承ReentrantLock,作为分段锁
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 每个Segment内部的HashEntry数组
transient volatile HashEntry<K,V>[] table;
// Segment的元素数量
transient int count;
// Segment的扩容阈值(负载因子 * HashEntry数组长度)
transient int threshold;
// Segment的负载因子
final float loadFactor;
}
// HashEntry节点:不可变设计+volatile保证并发安全
static final class HashEntry<K,V> {
final int hash; // 哈希值,final不可变
final K key; // 键,final不可变
volatile V value; // 值,volatile保证可见性
volatile HashEntry<K,V> next; // 下一个节点,volatile保证可见性
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
1.4 关键设计细节
- Segment数组长度不可变 :Segment数组在初始化时确定长度(由并发级别决定,取大于等于并发级别的2的幂次),初始化后无法修改,保证分段锁的结构稳定。
- HashEntry不可变设计 :
key和hash被final修饰,一旦创建无法修改,避免了多线程下的节点篡改;value和next被volatile修饰,保证多线程下的可见性(一个线程修改后,其他线程能立即看到)。 - 并发级别 :ConcurrentHashMap 1.7的并发能力由Segment数组长度决定,默认16,理论上支持16个线程同时对不同Segment执行写操作(互不阻塞),读操作全程无锁。
二、ConcurrentHashMap 1.7 核心设计原理
2.1 并发安全的核心:分段锁(Segment)
分段锁是ConcurrentHashMap 1.7解决并发问题的核心思想 ,本质是将全局锁拆分为多个细粒度的锁,每个锁保护一个独立的数据区域(Segment)。
- Hashtable的全局锁:所有读写操作都竞争同一把synchronized锁,同一时间只有一个线程能操作,并发效率为1。
- ConcurrentHashMap的分段锁 :每个Segment是一个独立的ReentrantLock,操作不同Segment的线程不会互相阻塞,只有操作同一个Segment的线程才会竞争同一把锁。默认16个Segment,理论上并发效率提升16倍。
分段锁的优势 :减小锁粒度,提升并发度;ReentrantLock的可重入性,保证同一线程多次获取同一Segment的锁不会死锁。
2.2 哈希计算:两次哈希分散冲突
为了将key均匀分配到不同的Segment中,ConcurrentHashMap 1.7采用两次哈希计算的方式,避免所有key都哈希到同一个Segment,导致分段锁失效(所有操作竞争同一把锁)。
两次哈希的执行步骤
- 第一次哈希 :对key调用
hashCode()计算原始哈希值,再通过ConcurrentHashMap的内部哈希算法 二次哈希,得到全局哈希值 ;将该值对Segment数组长度取模 ,得到key对应的Segment数组索引。 - 第二次哈希 :将上述全局哈希值对当前Segment内部的HashEntry数组长度取模 ,得到key对应的HashEntry数组桶索引。
哈希计算源码(核心方法)
java
// 第一次哈希:计算key对应的Segment索引
private int segmentFor(int hash) {
// 利用位运算替代取模,效率更高(数组长度为2的幂次时,hash % len = hash & (len-1))
return hash & (segments.length - 1);
}
// 内部二次哈希算法:减少哈希冲突,让key更均匀分布
private static int hash(int h) {
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
设计目的:通过两次哈希,让key既均匀分布在不同Segment,又均匀分布在Segment内部的HashEntry数组,最大程度分散哈希冲突,保证分段锁的并发效率。
2.3 读操作无锁的核心保障
ConcurrentHashMap 1.7的get方法全程无锁 ,是其高性能的重要原因,无锁的核心保障来自HashEntry的不可变设计+volatile关键字的可见性:
key和hash是final的,保证节点的核心标识不会被篡改;value和next是volatile的,保证多线程下的可见性和有序性:一个线程修改了节点的value或next,其他线程能立即看到最新值,避免脏读;- 写操作时对Segment加锁,保证同一Segment内的写操作是串行的,不会出现多线程同时修改链表的情况,读操作无需加锁即可保证数据一致性。
三、ConcurrentHashMap 1.7 核心方法源码解析
3.1 核心写操作:put方法(加锁+头插法)
put方法是ConcurrentHashMap 1.7的核心写操作,全程对目标Segment加锁 ,保证同一Segment内的写操作串行执行,不同Segment的写操作并行执行。同时采用头插法添加新节点(与HashMap 1.7一致),并在添加后检查是否需要扩容。
put方法整体执行流程
是
否
是
否
是
否
调用put(K key,V value)
检查key/value是否为null?
抛出NullPointerException
ConcurrentHashMap不允许空键空值
计算key的二次哈希值,得到全局hash
调用segmentFor(hash),得到目标Segment索引
获取目标Segment对象
对目标Segment加锁(lock())
ReentrantLock可重入锁
在Segment内计算hash,得到HashEntry数组桶索引
遍历桶对应的单向链表
是否找到key相同的节点?
替换该节点的volatile value值
头插法添加新HashEntry节点
新节点作为链表头
Segment内元素数是否超过扩容阈值?
对当前Segment执行扩容(仅扩容该Segment)
解锁(unlock())
返回旧值/Null
put方法核心源码(带注释)
java
public V put(K key, V value) {
Segment<K,V> s;
// 禁止空键空值,与HashMap不同(HashMap允许空键空值)
if (value == null)
throw new NullPointerException();
// 1. 二次哈希计算全局hash值
int hash = hash(key.hashCode());
// 2. 计算目标Segment的索引,通过位运算定位
int j = (hash >>> segmentShift) & segmentMask;
// 3. 定位目标Segment,若未初始化则先初始化
if ((s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
// 4. 调用Segment的put方法,对Segment加锁操作
return s.put(key, hash, value, false);
}
// Segment内部的put方法(核心写操作)
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 加锁:获取ReentrantLock锁,若获取失败则调用scanAndLockForPut自旋获取
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
// 计算Segment内部HashEntry数组的桶索引
int index = (tab.length - 1) & hash;
// 定位桶的头节点
HashEntry<K,V> first = entryAt(tab, index);
// 遍历链表
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 找到key相同的节点,替换value
if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value; // volatile value,保证可见性
++modCount;
}
break;
}
e = e.next;
} else {
// 未找到,头插法添加新节点
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 检查是否需要扩容:元素数>阈值 且 HashEntry数组长度<最大容量
if (c > threshold && tab.length < MAX_SEGMENT_TABLE_CAPACITY)
rehash(node); // 扩容当前Segment
else
setEntryAt(tab, index, node); // 头插法设置新节点为链表头
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock(); // 最终解锁,保证锁的释放
}
return oldValue;
}
put方法关键细节
- 禁止空键空值 :ConcurrentHashMap 1.7/1.8都不允许key或value为null,而HashMap允许,原因是多线程环境下,空值会导致无法区分是"键不存在"还是"值为null",引发并发问题。
- 自旋加锁 :若
tryLock()获取锁失败,会调用scanAndLockForPut自旋获取锁,避免直接阻塞(ReentrantLock的非阻塞特性),提升并发效率。 - 仅对单个Segment扩容 :扩容是Segment级别的局部扩容,而非整个ConcurrentHashMap扩容,扩容时仅锁定当前Segment,不影响其他Segment的操作。
- 头插法:新节点作为链表头,插入效率高(无需遍历到链表尾),但会导致链表顺序与插入顺序相反(HashMap 1.7也采用此方式)。
3.2 核心读操作:get方法(全程无锁)
get方法是ConcurrentHashMap 1.7的核心读操作,全程无锁 ,仅通过两次哈希定位节点,遍历链表获取值,并发效率极高,其线程安全由HashEntry的final和volatile修饰保证。
get方法执行流程
是
否
是
否
调用get(Object key)
检查key是否为null?
抛出NullPointerException
计算key的二次哈希值,得到全局hash
调用segmentFor(hash),得到目标Segment索引
获取目标Segment对象(无需加锁)
在Segment内计算hash,得到HashEntry数组桶索引
遍历桶对应的单向链表(无需加锁)
是否找到key相同的节点?
返回该节点的volatile value值
返回null
get方法核心源码(带注释)
java
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
// 1. 计算二次哈希值
int h = hash(key.hashCode());
// 2. 定位目标Segment,通过Unsafe直接获取,无锁
long u = (h >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject(segments, u)) != null && (tab = s.table) != null) {
// 3. 定位HashEntry数组桶索引,遍历链表
for (HashEntry<K,V> e = (HashEntry<K,V>)UNSAFE.getObject(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
// 找到匹配的key,返回value
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
get方法无锁的核心原因
- HashEntry的不可变设计 :
key和hash是final的,不会被篡改,保证遍历链表时节点的标识不会变化。 - volatile的可见性 :
value和next是volatile的,写操作修改后,读操作能立即看到最新值,避免脏读。 - 写操作加锁:同一Segment内的写操作是串行的,不会出现多线程同时修改链表的情况,读操作无需加锁即可保证数据一致性。
3.3 扩容机制:rehash方法(Segment局部扩容)
ConcurrentHashMap 1.7的扩容仅针对单个Segment ,而非全局扩容,当某个Segment内的元素数量count超过其扩容阈值(threshold = 负载因子 * HashEntry数组长度),且HashEntry数组长度小于最大容量时,触发该Segment的扩容。
扩容核心步骤
- 创建新的HashEntry数组 :新数组长度为原数组的2倍(与HashMap一致,保证2的幂次,方便位运算取模)。
- 重新哈希 :将原HashEntry数组中的所有节点,通过二次哈希重新分配到新数组的桶中。
- 头插法转移节点 :将原链表的节点通过头插法转移到新数组的对应桶中,转移过程中当前Segment始终处于加锁状态。
- 替换数组:将Segment的table引用指向新的HashEntry数组,完成扩容。
扩容关键细节
- 局部扩容:仅扩容操作的Segment,其他Segment不受影响,保证扩容时的并发效率。
- 加锁扩容:扩容全程对Segment加锁,避免多线程下的数组篡改,保证扩容的原子性。
- 无链表环问题 :虽然采用头插法,但扩容是单线程串行执行(加锁),不会像HashMap 1.7那样出现多线程下的链表环问题。
3.4 移除操作:remove方法(加锁执行)
remove方法与put方法的并发控制逻辑一致,先对目标Segment加锁,再遍历链表找到目标节点,修改链表的next引用实现节点移除,最后解锁,全程保证串行执行。
核心特点 :移除节点时,并非直接删除节点,而是通过修改前驱节点的volatile next引用,让目标节点脱离链表(GC会自动回收),利用volatile的可见性保证其他线程能立即看到链表的修改。
四、ConcurrentHashMap 1.7 扩容阈值与初始化
4.1 核心参数的计算
ConcurrentHashMap 1.7的初始化会根据初始容量、并发级别、负载因子三个参数,计算出Segment数组长度、单个Segment的初始容量、单个Segment的扩容阈值。
核心计算公式
- Segment数组长度 :取大于等于并发级别的最小2的幂次,默认并发级别16,故Segment数组长度为16。
- 单个Segment的初始HashEntry数组长度 :
初始容量 / Segment数组长度,若无法整除则向上取整,且保证为2的幂次;默认初始容量16,故每个Segment的初始容量为1。 - 单个Segment的扩容阈值 :
HashEntry数组长度 * 负载因子,默认负载因子0.75,故初始阈值为1 * 0.75 = 0(首次添加元素即触发扩容)。
4.2 懒加载初始化
ConcurrentHashMap 1.7采用懒加载机制初始化Segment和HashEntry数组:
- Segment数组:在ConcurrentHashMap构造方法中仅初始化数组结构,不初始化每个Segment对象。
- Segment对象 :当首次向某个Segment执行put操作时,才通过
ensureSegment方法初始化该Segment。 - HashEntry数组:在Segment的初始化方法中创建,首次添加元素时若触发扩容则动态扩容。
设计目的:避免初始化时创建大量空的Segment和HashEntry数组,节省内存空间。
五、ConcurrentHashMap 1.7 与 HashMap/Hashtable 对比
为了更清晰理解ConcurrentHashMap 1.7的设计优势,将其与同版本的HashMap 1.7、Hashtable做核心维度对比:
| 特性 | ConcurrentHashMap 1.7 | HashMap 1.7 | Hashtable |
|---|---|---|---|
| 线程安全 | 是(分段锁+volatile) | 否 | 是(全局synchronized) |
| 锁机制 | 分段锁(ReentrantLock) | 无锁 | 全局锁(synchronized) |
| 并发度 | 高(默认16,Segment数决定) | 无 | 低(同一时间仅1个线程) |
| 读操作是否加锁 | 无锁 | 无锁 | 加锁(synchronized) |
| 允许空键空值 | 否 | 是(空键1个,空值多个) | 否 |
| 哈希计算 | 两次哈希 | 一次哈希 | 一次哈希 |
| 扩容方式 | Segment局部扩容 | 全局扩容 | 全局扩容 |
| 底层结构 | Segment数组+HashEntry数组+链表 | 数组+链表 | 数组+链表 |
| 扩容阈值 | 单个Segment独立计算 | 全局计算 | 全局计算 |
六、ConcurrentHashMap 1.7 优缺点分析
6.1 优点
- 高并发:分段锁机制减小了锁粒度,不同Segment的操作可并行执行,默认支持16个线程同时写操作。
- 高性能读操作:get方法全程无锁,依赖volatile和final保证并发安全,读效率与HashMap持平。
- 可重入锁:Segment继承ReentrantLock,支持可重入,避免同一线程多次获取锁导致的死锁。
- 局部扩容:仅对操作的Segment扩容,不影响其他Segment,扩容时的并发影响最小。
- 懒加载:Segment和HashEntry数组懒加载,节省内存空间。
6.2 缺点
- 分段锁粒度仍不够细 :若大量key哈希到同一个Segment,会导致该Segment成为性能瓶颈(所有操作竞争同一把锁),并发度下降。
- 并发级别固定:Segment数组长度初始化后无法修改,若业务并发量超过初始并发级别,无法动态提升。
- 哈希冲突效率低:采用单向链表解决哈希冲突,当链表过长时,查询效率降至O(n)。
- 不支持红黑树:与HashMap 1.8相比,无红黑树优化,链表过长时性能下降明显。
- 头插法的局限性:头插法虽插入效率高,但会导致链表顺序反转,且无法利用红黑树优化。
七、Java面试高频考点(ConcurrentHashMap 1.7)
- ConcurrentHashMap 1.7的底层结构是什么?
答:Segment数组+HashEntry数组+单向链表;Segment继承ReentrantLock实现分段锁,HashEntry通过final+volatile保证并发安全。 - ConcurrentHashMap 1.7为什么采用分段锁?
答:为了解决Hashtable全局锁的低并发问题,通过拆分锁粒度,让不同Segment的操作并行执行,提升并发效率。 - ConcurrentHashMap 1.7的get方法为什么无锁?
答:HashEntry的key/hash是final的,value/next是volatile的,保证了节点的不可变和可见性;同时写操作对Segment加锁,保证同一Segment内的写操作串行,读操作无需加锁即可保证数据一致性。 - ConcurrentHashMap 1.7是否允许空键空值?为什么?
答:不允许;多线程环境下,空值无法区分是"键不存在"还是"值为null",会引发并发判断问题,而HashMap是单线程的,无此问题。 - ConcurrentHashMap 1.7的扩容是全局的吗?
答:不是;是Segment级别的局部扩容,仅对操作的Segment扩容,扩容时加锁,不影响其他Segment。 - ConcurrentHashMap 1.7和Hashtable的锁机制有什么区别?
答:Hashtable采用全局synchronized锁,所有操作竞争同一把锁;ConcurrentHashMap 1.7采用分段锁(ReentrantLock),每个Segment是独立的锁,不同Segment的操作并行执行。
八、总结与设计演进
8.1 核心总结
ConcurrentHashMap 1.7的核心设计是分段锁(Segment) ,通过将哈希表拆分为多个独立的锁区域,解决了Hashtable全局锁的低并发问题,同时通过final+volatile保证了HashEntry的并发安全,实现了无锁读、加锁写的高性能并发模型。
其设计的精髓在于锁的粒度拆分 和并发安全的细粒度保障,既保证了多线程下的数据一致性,又最大化提升了并发效率,成为JDK1.7及之前多线程环境下哈希表的最优选择。
8.2 设计演进:为什么JDK1.8放弃了分段锁?
JDK1.8的ConcurrentHashMap彻底放弃了分段锁,采用CAS + synchronized + 红黑树的实现方式,原因在于1.7的分段锁存在以下局限性:
- 分段锁的性能瓶颈:若大量key哈希到同一个Segment,会导致锁竞争加剧,并发度下降。
- 并发级别固定:Segment数组长度初始化后无法修改,无法适配动态变化的业务并发量。
- 链表的性能问题:无红黑树优化,链表过长时查询效率低。
- synchronized的优化:JDK1.6对synchronized做了大量优化(偏向锁、轻量级锁、重量级锁),性能与ReentrantLock持平,甚至在某些场景下更优。
JDK1.8的实现既保留了1.7的高并发特性,又通过红黑树优化了哈希冲突的查询效率,成为更优的并发哈希表实现。
下一章我们来介绍一下JDK1.8的实现。