AI摘要
一、回顾JDK7的分段锁设计:为什么它曾经是优秀的方案?
在深入JDK8之前,我们先理解JDK7中ConcurrentHashMap的分段锁设计,这能帮助我们明白"为什么要改变"。
1.1 分段锁的基本原理
JDK7的ConcurrentHashMap将数据分成多个Segment(默认16个),每个Segment独立加锁:
// JDK7中的ConcurrentHashMap结构
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> {
final Segment<K,V>[] segments;
static final class Segment<K,V> extends ReentrantLock {
// 每个Segment独立维护一个HashEntry数组
transient volatile HashEntry<K,V>[] table;
transient int count;
}
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
}分段锁的优势:
- 缩小锁粒度:从整个Map一把锁变为多个Segment多把锁
- 提高并发性:不同Segment的操作可以并行进行
1.2 分段锁在实际场景中的表现
在我参与的一个日志收集系统中,我们曾经这样使用JDK7的ConcurrentHashMap:
public class LogCollector {
private final ConcurrentHashMap<String, LogQueue> logQueues
= new ConcurrentHashMap<>(16); // 16个Segment
public void collectLog(String appId, String log) {
// 不同appId的日志可以并行处理
LogQueue queue = logQueues.get(appId);
if (queue == null) {
// 需要加锁,但只锁住对应的Segment
queue = new LogQueue();
LogQueue existing = logQueues.putIfAbsent(appId, queue);
if (existing != null) {
queue = existing;
}
}
queue.add(log);
}
}这种设计在中等并发下表现良好,但随着并发量增加,问题开始暴露。
二、分段锁的痛点:为什么需要改变?
2.1 锁竞争仍然不可避免
虽然分段锁减少了竞争,但在高并发场景下,热门Segment仍然会成为瓶颈:
// 假设我们有16个Segment,但80%的请求都落在同一个Segment上
public void hotspotProblem() {
// 即使有16个Segment,如果某个key特别热门,该Segment仍然会成为瓶颈
String hotKey = "popular_product_123";
// 所有对热门商品的请求都会竞争同一个Segment的锁
for (int i = 0; i < 1000000; i++) {
map.put(hotKey, "value_" + i); // 严重的锁竞争!
}
}在我们的电商系统中,热门商品的库存查询就遇到了这个问题。
2.2 内存开销问题
每个Segment都维护独立的HashEntry数组,这带来了额外的内存开销:
// 每个Segment都包含自己的计数器、锁、数组等
public class Segment<K,V> extends ReentrantLock {
transient volatile HashEntry<K,V>[] table;
transient int count; // Segment内的元素计数
transient int modCount; // 修改次数
transient int threshold; // 扩容阈值
final float loadFactor; // 负载因子
}在内存敏感的环境中,这种开销变得不可忽视。
三、JDK8的革新:CAS + synchronized的实现原理
JDK8彻底重构了ConcurrentHashMap,放弃了分段锁,采用了更细粒度的锁策略。
3.1 核心数据结构变化
// JDK8中的ConcurrentHashMap核心结构
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> {
// 不再有Segment数组,只有一个Node数组
transient volatile Node<K,V>[] table;
// 简单的链表节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
// 支持CAS更新value
final V setValue(V value) {
throw new UnsupportedOperationException();
}
}
// 红黑树节点
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
}
}3.2 CAS操作的巧妙应用
JDK8在put操作中大量使用CAS来避免加锁:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 参数校验...
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 延迟初始化
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 关键点1:桶为空,使用CAS直接设置节点
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // 插入成功,无需加锁!
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 协助扩容
else {
V oldVal = null;
// 关键点2:只有发生哈希冲突时才加锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// 处理链表
// ... 链表插入逻辑
}
else if (f instanceof TreeBin) {
// 处理红黑树
// ... 红黑树插入逻辑
}
}
}
// ... 后续处理
}
}
// ... 计数和扩容检查
}CAS操作的优势:
- 无锁化:大多数情况下不需要加锁
- 更高并发:多个线程可以同时修改不同的桶
- 更低开销:避免锁竞争的开销
四、红黑树的引入:解决链表过长问题
JDK8另一个重要改进是当链表过长时转换为红黑树:
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// 如果数组长度小于64,优先扩容而不是转树
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
if (tabAt(tab, index) == b) {
// 将链表转换为红黑树
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val, null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}红黑树的优势:
- 查询效率从O(n)提升到O(log n)
- 避免哈希碰撞攻击
五、扩容机制的优化:多线程协同扩容
JDK8的扩容机制更加高效,支持多线程协同工作:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 计算每个线程处理的区间
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
// 多个线程协同迁移数据
for (int i = 0, bound = 0;;) {
// ... 迁移逻辑
// 关键:其他线程可以协助迁移
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // 重新检查
}
}
}六、性能对比:从理论到实践
6.1 理论性能分析
| 操作场景 | JDK7分段锁 | JDK8 CAS+synchronized |
|---|---|---|
| 低竞争读 | 良好 | 优秀(完全无锁) |
| 低竞争写 | 良好 | 优秀(CAS无锁) |
| 高竞争读 | 良好 | 优秀(volatile读) |
| 高竞争写 | 差(锁竞争) | 良好(细粒度锁) |
| 内存占用 | 较高 | 较低 |
6.2 实际压测数据
在我们电商系统的压测中(8核CPU,1000万次操作):
// 测试代码片段
public class ConcurrentHashMapBenchmark {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// JDK7结果:平均操作耗时 45ms
// JDK8结果:平均操作耗时 28ms (提升38%)
long start = System.currentTimeMillis();
IntStream.range(0, 10_000_000).parallel().forEach(i -> {
map.put("key" + i, i);
});
long end = System.currentTimeMillis();
System.out.println("耗时: " + (end - start) + "ms");
}
}七、实际应用中的注意事项
7.1 正确使用computeIfAbsent
JDK8提供了更便捷的方法,但要避免在函数中修改Map自身:
// 错误用法:在mappingFunction中修改map
map.computeIfAbsent("key", k -> {
map.put("other_key", "value"); // 可能导致死锁!
return "computed_value";
});
// 正确用法
map.computeIfAbsent("key", k -> "computed_value");7.2 合理设置初始容量
// 预估有100万数据,并发级别高
ConcurrentHashMap<String, Object> map =
new ConcurrentHashMap<>(1_000_000, 0.75f);八、从ConcurrentHashMap看技术演进哲学
这次从分段锁到CAS+synchronized的转变,体现了重要的技术演进规律:
- 从粗粒度到细粒度:锁的粒度越来越小
- 从悲观到乐观:更多使用无锁的CAS操作
- 拥抱底层优化:相信JVM的synchronized优化
- 算法数据结构结合:红黑树解决极端情况
总结
JDK8放弃分段锁不是简单的技术替换,而是并发编程理念的进化。它告诉我们:
真正的性能优化不是增加更多的锁,而是尽可能减少锁的使用。
在实际项目中,理解这些底层变化帮助我们做出更好的技术决策。当我们在设计高并发系统时,应该像ConcurrentHashMap一样:在保证线程安全的前提下,最大程度地减少竞争,提升并行度。
这次探究让我明白,技术选型不能停留在表面认知,必须深入理解其适用场景和底层原理。这也是四年Java开发经验带给我的最重要收获。