1. ConcurrentHashMap 1.7 的设计背景与核心诉求
在Java集合框架中,HashMap作为最常用的键值对存储结构,其非线程安全的特性使得在多线程环境下直接使用会导致数据不一致问题。传统解决方案是使用Collections.synchronizedMap()或直接使用Hashtable,但这些方案在高并发场景下会因全局锁导致性能瓶颈。
ConcurrentHashMap 1.7版本的设计目标很明确:在保证线程安全的前提下,实现比Hashtable更高的并发性能。其核心思路是采用分段锁(Segment)机制,将整个哈希表划分为多个独立的段(Segment),每个段相当于一个独立的哈希表,拥有自己的锁。这种设计使得不同线程可以同时访问不同的段,从而显著提升并发吞吐量。
关键设计决策:分段数量默认16个(可通过构造函数指定),意味着理论上最多支持16个线程完全无竞争地并发写入。这个数字的选择是基于典型服务器CPU核心数的折中考虑。
2. 分段锁的底层数据结构实现
2.1 Segment内部结构剖析
每个Segment本质上是一个特殊的ReentrantLock子类,包含一个HashEntry数组。HashEntry是ConcurrentHashMap的基础存储单元,其定义如下:
java复制static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
// 构造函数和其他方法省略...
}
值得注意的是value和next字段都被声明为volatile,这保证了内存可见性,使得读操作无需加锁也能获取最新值。这种设计实现了读写分离:读操作完全无锁,写操作只需锁定对应的Segment。
2.2 哈希分布与段定位机制
当插入或查找一个键值对时,ConcurrentHashMap需要经过两次哈希计算:
- 首先对key的hashCode进行再哈希(通过Wang/Jenkins算法),目的是减少哈希冲突
- 然后使用高位哈希值确定Segment位置,低位哈希值确定HashEntry在Segment内部数组的位置
java复制final Segment<K,V> segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask];
}
segmentShift和segmentMask是根据segments数组长度计算得出的位运算参数,这种设计使得段定位操作非常高效。
3. 关键操作源码解析
3.1 put操作实现细节
put操作是ConcurrentHashMap最复杂的操作之一,其核心流程如下:
- 计算key的哈希值并定位到对应Segment
- 尝试获取Segment锁(失败会自旋或阻塞)
- 在Segment内部进行链表遍历:
- 如果找到相同key,替换value
- 否则创建新HashEntry并插入链表头部
- 检查是否需要扩容(超过阈值且未达到最大容量)
- 释放Segment锁
扩容(rehash)过程特别值得关注:它只针对当前Segment内部的HashEntry数组,不会影响其他Segment。这种局部扩容的设计是ConcurrentHashMap高性能的关键之一。
3.2 get操作的无需锁实现
get操作的实现展示了ConcurrentHashMap如何实现无锁读取:
java复制public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
Segment内部的get方法通过volatile读保证可见性:
java复制V get(Object key, int hash) {
if (count != 0) { // 读取volatile变量
HashEntry<K,V> e = getFirst(hash);
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
V v = e.value;
if (v != null) // 再次检查防止指令重排
return v;
return readValueUnderLock(e); // 罕见情况:值正在被写入
}
e = e.next;
}
}
return null;
}
这种设计使得读操作在大多数情况下完全不需要同步开销,只有在极少数情况下(恰好遇到value字段正在被写入)才会降级为加锁读取。
4. 分段锁设计的性能考量与局限性
4.1 并发度(Concurrency Level)的影响
构造函数的concurrencyLevel参数直接影响Segment数量,进而决定最大并行度。但实际测试表明:
- 当并发线程数远大于Segment数量时,性能会下降
- 过大的concurrencyLevel会导致内存浪费(每个Segment都有独立的数据结构)
- 经验值通常设置为预估并发线程数的1-1.5倍
4.2 大小统计的准确性挑战
ConcurrentHashMap的size()方法实现反映了分段设计的一个痛点:
java复制public int size() {
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // 如果size超过32位
long sum; // 所有Segment的modCount总和
long last = 0L; // 前一次的总和
int retries = -1; // 第一次迭代不计入重试
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
// 超过重试次数,对所有Segment加锁
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock();
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
这个方法展示了分段设计的一个妥协:为了不总是锁定所有Segment来获取准确大小,它先尝试无锁统计(最多重试3次),只有在检测到并发修改时才降级为全表锁。这反映了分段锁设计在全局操作上的局限性。
5. 实际应用中的经验与陷阱
5.1 哈希函数的选择与性能
ConcurrentHashMap使用特殊的哈希算法来分散键到不同Segment:
java复制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);
}
这种再哈希算法虽然增加了计算开销,但能有效减少哈希冲突。在实际应用中,如果键的自定义hashCode()实现质量较差(如分布不均匀),ConcurrentHashMap的表现会明显优于HashMap,因为其内置的再哈希可以部分补偿不良的hashCode实现。
5.2 迭代器的弱一致性行为
ConcurrentHashMap的迭代器(包括keySet、entrySet等视图)具有弱一致性(weakly consistent)特性:
- 不保证能反映迭代过程中所有的并发修改
- 但保证不会抛出ConcurrentModificationException
- 每个Segment内部的迭代是稳定的(基于创建迭代器时的快照)
这种行为是设计上的权衡:为了不锁定整个表而牺牲强一致性。在开发中需要注意,不能依赖迭代器来检测实时变化。
5.3 内存占用与分段数量的权衡
每个Segment都维护独立的HashEntry数组和计数器,这带来了额外的内存开销。在内存敏感的环境中,需要谨慎选择concurrencyLevel:
- 默认16对于大多数应用是合理的起点
- 对于小规模并发(<8线程)可以适当降低
- 对于超大规模并发(>64线程),考虑使用更高版本(如JDK8的ConcurrentHashMap)
6. 从1.7到后续版本的演进
虽然本文聚焦1.7版本,但了解后续版本的改进有助于全面理解设计演进:
- JDK8的ConcurrentHashMap放弃了分段锁,改用CAS+synchronized实现
- 引入红黑树处理哈希冲突,最坏情况时间复杂度从O(n)提升到O(log n)
- size()的实现改为基于CounterCell的近似计数,避免全表锁
- 新增了compute、merge等原子操作方法
这些改进解决了1.7版本的一些痛点:
- 分段数量固定导致的扩展性问题
- 小对象内存开销过大
- 极端情况下链表过长导致的性能下降
然而1.7版本的分段锁设计仍然有其历史价值,它展示了如何通过细粒度锁来实现并发控制,这种设计思路在许多现代系统中仍有应用。
