在Java并发编程中,ConcurrentHashMap 是不可或缺的利器。它的诞生源于对 Hashtable 性能瓶颈的突破。Hashtable 通过全局锁保证线程安全,所有读写操作都竞争同一把锁,在高并发场景下性能急剧下降。JDK 1.5 中首次引入的 ConcurrentHashMap(在 JDK 1.7 中进一步完善)采用分段锁 机制,将锁粒度从整个表细化到多个独立的段,极大提升了并发吞吐量。本文将深入剖析 JDK 1.7 中 ConcurrentHashMap 的设计思想、核心实现以及优缺点,带你领略分段锁的艺术。
一、背景:HashTable的全局锁之痛
Hashtable 是 Java 早期提供的线程安全哈希表,其所有公共方法(如 put、get、size)都通过 synchronized 修饰,相当于整个对象被一把大锁保护。当多个线程同时访问时,即使执行的是不同哈希桶的操作,也只能串行执行,CPU 资源利用率低下。随着多核处理器的普及,这种粗粒度锁显然无法满足高并发需求。因此,ConcurrentHashMap 应运而生。
二、分段锁架构设计
JDK 1.7 中的 ConcurrentHashMap 采用分段锁思想,将数据分片管理,每个分片拥有一把独立的锁。其核心结构如下:
java
public class ConcurrentHashMap<K, V> {
// 分段数组,默认大小16,不可扩容
final Segment<K,V>[] segments;
// Segment 内部类,继承 ReentrantLock
static final class Segment<K,V> extends ReentrantLock {
transient volatile HashEntry<K,V>[] table;
transient int count; // 该段中元素个数
transient int modCount; // 结构变更次数,用于检测并发
final float loadFactor;
// ...
}
// 真正的数据节点
static final class HashEntry<K,V> {
final K key;
volatile V value;
final int hash;
final HashEntry<K,V> next;
// ...
}
}
-
Segment 数组 :
ConcurrentHashMap持有一个Segment数组,默认容量为 16。每个Segment都继承自ReentrantLock,因此可以独立加锁。 -
HashEntry 数组 :每个
Segment内部维护一个HashEntry数组,用于存储实际的数据节点。HashEntry中的value和next字段都使用volatile修饰,保证多线程下的可见性。 -
锁粒度 :多个线程可以同时访问不同
Segment中的数据,只有访问同一Segment时才会发生锁竞争。因此,理论上并发度可以达到Segment的数量(默认 16)。
这种双层数组结构使得定位一个元素需要两次哈希计算:第一次定位到 Segment,第二次定位到 HashEntry 数组。
三、核心方法实现原理
1. put/remove 操作:分段加锁
以 put 方法为例,流程如下:
-
根据 key 的哈希值定位到所属
Segment。 -
调用
Segment.put方法,首先尝试获取该Segment的锁(ReentrantLock.lock())。 -
获取锁后,在对应
HashEntry数组中进行插入或更新操作。如果哈希桶为空或需要扩容,则同步处理。 -
最后释放锁。
由于锁的粒度是 Segment,不同 Segment 之间的操作可以完全并发,互不干扰。remove 方法同理。
java
// 简化后的伪代码
public V put(K key, V value) {
int hash = hash(key);
int segmentIndex = (hash >>> segmentShift) & segmentMask;
Segment<K,V> seg = segments[segmentIndex];
return seg.put(key, hash, value, false);
}
// Segment.put 内部
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock(); // 加锁
try {
HashEntry<K,V>[] tab = table;
int index = hash & (tab.length - 1);
HashEntry<K,V> first = tab[index];
// 遍历链表,若key存在则替换
for (HashEntry<K,V> e = first; e != null; e = e.next) {
if (e.hash == hash && key.equals(e.key)) {
V oldValue = e.value;
if (!onlyIfAbsent) e.value = value;
return oldValue;
}
}
// 不存在则插入新节点
tab[index] = new HashEntry<K,V>(key, hash, value, first);
count++;
return null;
} finally {
unlock(); // 释放锁
}
}
2. get 操作:无锁读,利用 volatile
get 方法是完全无锁的,它不需要获取任何锁,只需通过 volatile 保证可见性:
-
定位到
Segment后,再定位到HashEntry数组。 -
由于
value和next都是volatile修饰,读取到的数据一定是主内存中最新的值(或至少是某个一致性状态)。 -
即使在遍历链表时发生了并发修改,
volatile也能保证读取到的节点引用是最新的。
java
public V get(Object key) {
int hash = hash(key);
int segmentIndex = (hash >>> segmentShift) & segmentMask;
Segment<K,V> seg = segments[segmentIndex];
HashEntry<K,V>[] tab = seg.table;
int index = hash & (tab.length - 1);
for (HashEntry<K,V> e = tab[index]; e != null; e = e.next) {
if (e.hash == hash && key.equals(e.key)) {
return e.value; // value 是 volatile 的
}
}
return null;
}
这种设计使得读操作几乎不会阻塞写操作,从而实现了高效的读写并发。
3. size 操作:两次遍历+加锁兜底
size 方法需要统计所有 Segment 中元素的总数。如果简单地对每个 Segment 加锁再统计,在高并发下会严重影响性能。JDK 1.7 的 ConcurrentHashMap 采用了一种乐观策略:
-
先不加锁,遍历所有
Segment,累加count和modCount。 -
如果两次遍历得到的
modCount总和相同,说明遍历期间没有发生结构变更,直接返回统计结果。 -
如果两次
modCount不一致,说明有线程进行了put、remove等操作,此时会尝试对所有Segment加锁,再进行一次准确统计。
这种方法兼顾了效率和准确性,在多数场景下无需加锁即可快速返回。
java
public int size() {
final Segment<K,V>[] segments = this.segments;
long sum = 0;
long check = 0;
int[] mc = new int[segments.length];
// 重试次数
for (int k = 0; k < 2; k++) {
check = 0;
sum = 0;
for (int i = 0; i < segments.length; i++) {
if (k == 0) {
mc[i] = segments[i].modCount;
} else {
if (segments[i].modCount != mc[i]) {
// 不一致,进入加锁模式
return trySizeWithLock();
}
}
sum += segments[i].count;
}
}
return (int) sum;
}
四、扩容机制
JDK 1.7 的 ConcurrentHashMap 扩容发生在 Segment 内部,即 HashEntry 数组的大小调整。每个 Segment 独立扩容,互不影响。当某个 Segment 中的元素数量超过阈值(loadFactor * 当前容量)时,会创建一个新的 HashEntry 数组,容量为原来的 2 倍,然后将原数组中的元素重新哈希到新数组中。整个扩容过程在 Segment 锁的保护下完成,因此同一时刻只有一个线程对该 Segment 执行扩容,其他访问该 Segment 的线程会被阻塞。
这种设计的优点是扩容不影响其他 Segment,保证了并发度;缺点是扩容不支持多线程并行,且扩容期间该 Segment 的访问性能会下降。此外,Segment 的数量一旦初始化就无法再改变,导致整体容量受限于 Segment 数组大小和每个 Segment 内部数组大小的乘积,灵活性不足。
五、优缺点总结
优点
-
高并发 :相比
Hashtable,锁粒度细化,默认并发度可达 16,在多线程环境下吞吐量显著提升。 -
读操作无锁 :
get方法利用volatile实现无锁读,读写操作几乎不互斥,适合读多写少的场景。 -
分段独立性 :每个
Segment独立管理自己的数据,扩容、计数等操作互不干扰,降低了复杂度。
缺点
-
两次哈希计算 :定位元素需要先定位
Segment再定位HashEntry,相比HashMap多了一次哈希运算,有一定性能开销。 -
锁粒度依然较粗 :当多个线程竞争同一
Segment时,仍会出现锁等待,无法进一步细化。 -
扩容效率低 :每个
Segment独立扩容,且不支持并发扩容,扩容期间该段会被阻塞。 -
内存占用较高:维护了两层数组结构,内存开销比单一数组大。
六、结语
JDK 1.7 的 ConcurrentHashMap 通过分段锁机制,在保证线程安全的前提下大幅提升了并发性能,是 Java 并发容器中里程碑式的设计。它巧妙地平衡了锁的粒度与实现复杂度,为后来的 JDK 1.8 版本(采用 CAS + synchronized 的细粒度锁)奠定了基础。
理解 JDK 1.7 的 ConcurrentHashMap 不仅有助于我们掌握分段锁的思想,更能帮助我们对比不同版本的演进,从而更深刻地理解并发编程的设计权衡。如果你正维护着基于 JDK 1.7 的遗留系统,或者只是对并发容器的历史感兴趣,希望本文能为你提供清晰的脉络。