前言

前言

在并发编程中,我们常面临一个两难的抉择:

  • 锁粒度太粗(如直接锁整个方法):虽然绝对安全且代码简单,但系统几乎退化为串行,多核 CPU 成了摆设。
  • 锁粒度太细(如只锁某一行代码):并发度上去了,但带来了复杂的死锁风险,且过频的“加锁/解锁”引发的上下文切换(Context Switch)开销,反而可能拖慢系统。

本文不讨论“如何选择锁对象”或“该用哪种锁类”,只专注解决一个核心问题: 当必须加锁时,我们该如何精准地划定锁的时空范围,从而在保证线程安全的前提下,将性能压榨到极致?


⚡️ 精华速查版 (TL;DR)

如果你时间紧迫,请掌握以下关于“粒度控制”的核心心法:

  1. 纵向拆分(时间维度): 快进快出。将 I/O、预处理、参数校验、复杂计算移出同步块,只锁住“修改共享数据”的那一瞬间。
  2. 横向拆分(空间维度): 专锁专用。如果两个共享变量互不干扰(如用户的“积分”和“头像”),请使用两把独立的锁(锁分离),不要用一把大锁通吃。
  3. 化整为零(数据维度): 借鉴 JDK 1.8 ConcurrentHashMap 的思想,尽量将锁粒度细化到**“数据节点”**级别,而不是锁住整个容器。
  4. 动态分散(热点维度): 对于极高并发的累加操作,使用 LongAdder 代替 AtomicLong,将单一锁粒度动态分散为多单元(Cell)竞争。
  5. 信任 JVM(反向优化): 避免在紧凑循环内频繁加锁(会导致锁粗化),对于极短的临界区,JVM 的内置优化往往比手动微操更可靠。

一、 为什么粒度决定生死?——阿姆达尔定律

我们常说“减小锁粒度能提高性能”,这背后的物理定律是 Amdahl’s Law(阿姆达尔定律)

简单来说,系统的最大加速比,受限于代码中必须串行执行的那一部分(即临界区)。

  • 如果你的锁粒度涵盖了 50% 的代码执行时间,哪怕你有 1000 个 CPU 核心,系统的最大性能提升也无法超过 2 倍。

因此,控制锁粒度的本质,就是无限压缩临界区的大小。


二、 纵向优化:缩短临界区的“时间”

这是最容易落地,也是效果最立竿见影的优化手段。

1. 剥离非核心逻辑

很多时候,我们在加锁时习惯性地“图省事”,直接在方法上加 synchronized

❌ 粒度过粗(包含无关耗时操作):

1
2
3
4
5
6
7
public synchronized void uploadAndSave(String data) {
// 1. 网络IO / 复杂校验 (耗时 200ms) -> 本不需要锁
String processed = networkUtil.upload(data);

// 2. 写入共享列表 (耗时 0.1ms) -> 真正需要保护的临界区
sharedList.add(processed);
}

后果: 线程 A 在做网络上传时,线程 B 即使只是想写数据,也必须等待。

✅ 粒度优化(快进快出):

1
2
3
4
5
6
7
8
9
public void uploadAndSave(String data) {
// 1. 在锁外完成耗时操作 (并行执行)
String processed = networkUtil.upload(data);

// 2. 只在修改那一瞬间加锁 (串行执行)
synchronized (this.lock) {
sharedList.add(processed);
}
}

效果: 临界区从 200.1ms 缩减为 0.1ms,并发吞吐量理论提升 2000 倍。

2. Copy-On-Write 的极致粒度

对于读远多于写的场景,我们可以利用 Copy-On-Write 思想,将读操作的锁粒度降为 0

  • 写时: 加锁,复制副本,修改,替换引用。
  • 读时: 访问不可变副本,完全无锁。
    这不仅是选型问题,更是将“读锁范围”压缩至无的粒度艺术。

三、 横向优化:减小锁的“覆盖范围”

如果说纵向优化是缩短排队时间,横向优化就是多开几个服务窗口

1. 锁分离 (Lock Splitting)

当一个类中存在多个相互独立的共享资源时,不要使用一把全局锁(如 synchronized(this))。

场景: 游戏玩家的“金币”和“经验值”。
❌ 一把大锁: 捡金币时不能打怪涨经验。
✅ 锁分离:

1
2
3
4
5
6
7
8
9
private final Object goldLock = new Object();
private final Object expLock = new Object();

public void addGold(int n) {
synchronized(goldLock) { gold += n; }
}
public void addExp(int n) {
synchronized(expLock) { exp += n; }
}

JDK 典范: LinkedBlockingQueue 使用 takeLock(出队锁)和 putLock(入队锁),实现了队头和队尾的并发操作。

2. 锁分段与节点锁 (Lock Striping)

如何处理大集合的并发?

  • 粗粒度: 锁住整个 Map。
  • 中粒度(JDK 1.7 CHM): 分段锁(Segment),将 Map 切分为 16 段,锁粒度为 1/16。
  • 细粒度(JDK 1.8 CHM): 节点锁。直接利用 CAS 和 synchronized 锁住哈希桶的头节点
    • 这意味着:只要 hash 不冲突,线程间几乎不存在竞争。
    • 这是横向优化的极致——数据本身就是锁。

四、 硬件级粒度:从锁到指令

有时候,哪怕是最细的 synchronized 代码块,对于仅执行 i++ 的操作来说,粒度也太“粗”了。

1. CAS 的指令级粒度

AtomicInteger 等类使用的 CAS (Compare-And-Swap) 操作,将并发控制的粒度缩小到了CPU 指令级别

  • 它没有线程挂起/唤醒(上下文切换)的开销。
  • 适用场景: 临界区极短,竞争不剧烈。

2. LongAdder 的动态粒度

当 CAS 竞争过于激烈(如千万级 QPS 统计),单一变量的 CAS 会导致 CPU 缓存行的伪共享和总线风暴。
JDK 1.8 引入的 LongAdder 采用了一种动态粒度策略:

  • 无竞争时:直接更新 base 变量(粒度为 1)。
  • 有竞争时:自动扩展为 Cell 数组,将线程分散到不同的 Cell 上进行累加(粒度变为 N)。
  • 这是空间换时间在粒度控制上的完美应用。

五、 避坑指南:反模式与 JVM 的暗箱操作

在追求细粒度的路上,容易走火入魔。请注意以下陷阱:

1. 警惕“嵌套死锁”

锁拆得越细,同时持有多把锁的可能性就越大。

  • 原则: 如果需要同时获取多把小锁,必须严格保证所有线程以相同的顺序加锁。

2. 警惕“原子性破坏”

❌ 错误示范:

1
2
3
4
// 锁粒度太细,导致业务逻辑断层
synchronized(lock) { if (inventory > 0) { ... } } // 检查库存
// ... 中间可能被插队 ...
synchronized(lock) { inventory--; } // 扣减库存

后果: 检查时有库存,扣减时没库存了。

  • 原则: 锁的范围必须覆盖整个原子性的业务逻辑,不能为了细化而把一个完整的事务切开。

3. 不要自作聪明:锁粗化 (Lock Coarsening)

❌ 错误示范:

1
2
3
for (int i=0; i<10000; i++) {
synchronized(lock) { count++; } // 在循环内频繁加锁
}

JVM 极其厌恶这种代码。由于加锁/解锁本身有开销,JVM 的 JIT 编译器在运行时会自动进行**“锁粗化”**,将锁扩展到循环外部。

  • 启示: 除非循环体特别耗时,否则直接在循环外加锁,代码更清晰,性能也相差无几(甚至更好)。

结语:决策漏斗

当我们在代码中遇到线程安全问题,需要确定加锁粒度时,请按此漏斗进行决策:

  1. Level 1(无锁): 能否用 ThreadLocal 封闭?能否用不可变对象?
  2. Level 2(指令级): 只是简单数值修改?用 Atomic / LongAdder
  3. Level 3(数据级): 是集合操作?用 ConcurrentHashMap 等并发容器(锁节点)。
  4. Level 4(逻辑级): 必须加互斥锁?
    • 先做纵向优化:把耗时操作踢出去。
    • 再做横向优化:看看能不能拆分独立的锁。

锁粒度的控制,本质上是在寻找 CPU 吞吐量与数据一致性之间的纳什均衡。