并发内功-如何确定加锁粒度
前言
前言
在并发编程中,我们常面临一个两难的抉择:
- 锁粒度太粗(如直接锁整个方法):虽然绝对安全且代码简单,但系统几乎退化为串行,多核 CPU 成了摆设。
- 锁粒度太细(如只锁某一行代码):并发度上去了,但带来了复杂的死锁风险,且过频的“加锁/解锁”引发的上下文切换(Context Switch)开销,反而可能拖慢系统。
本文不讨论“如何选择锁对象”或“该用哪种锁类”,只专注解决一个核心问题: 当必须加锁时,我们该如何精准地划定锁的时空范围,从而在保证线程安全的前提下,将性能压榨到极致?
⚡️ 精华速查版 (TL;DR)
如果你时间紧迫,请掌握以下关于“粒度控制”的核心心法:
- 纵向拆分(时间维度): 快进快出。将 I/O、预处理、参数校验、复杂计算移出同步块,只锁住“修改共享数据”的那一瞬间。
- 横向拆分(空间维度): 专锁专用。如果两个共享变量互不干扰(如用户的“积分”和“头像”),请使用两把独立的锁(锁分离),不要用一把大锁通吃。
- 化整为零(数据维度): 借鉴 JDK 1.8
ConcurrentHashMap的思想,尽量将锁粒度细化到**“数据节点”**级别,而不是锁住整个容器。 - 动态分散(热点维度): 对于极高并发的累加操作,使用
LongAdder代替AtomicLong,将单一锁粒度动态分散为多单元(Cell)竞争。 - 信任 JVM(反向优化): 避免在紧凑循环内频繁加锁(会导致锁粗化),对于极短的临界区,JVM 的内置优化往往比手动微操更可靠。
一、 为什么粒度决定生死?——阿姆达尔定律
我们常说“减小锁粒度能提高性能”,这背后的物理定律是 Amdahl’s Law(阿姆达尔定律)。
简单来说,系统的最大加速比,受限于代码中必须串行执行的那一部分(即临界区)。
- 如果你的锁粒度涵盖了 50% 的代码执行时间,哪怕你有 1000 个 CPU 核心,系统的最大性能提升也无法超过 2 倍。
因此,控制锁粒度的本质,就是无限压缩临界区的大小。
二、 纵向优化:缩短临界区的“时间”
这是最容易落地,也是效果最立竿见影的优化手段。
1. 剥离非核心逻辑
很多时候,我们在加锁时习惯性地“图省事”,直接在方法上加 synchronized。
❌ 粒度过粗(包含无关耗时操作):
1 | public synchronized void uploadAndSave(String data) { |
后果: 线程 A 在做网络上传时,线程 B 即使只是想写数据,也必须等待。
✅ 粒度优化(快进快出):
1 | public void uploadAndSave(String data) { |
效果: 临界区从 200.1ms 缩减为 0.1ms,并发吞吐量理论提升 2000 倍。
2. Copy-On-Write 的极致粒度
对于读远多于写的场景,我们可以利用 Copy-On-Write 思想,将读操作的锁粒度降为 0。
- 写时: 加锁,复制副本,修改,替换引用。
- 读时: 访问不可变副本,完全无锁。
这不仅是选型问题,更是将“读锁范围”压缩至无的粒度艺术。
三、 横向优化:减小锁的“覆盖范围”
如果说纵向优化是缩短排队时间,横向优化就是多开几个服务窗口。
1. 锁分离 (Lock Splitting)
当一个类中存在多个相互独立的共享资源时,不要使用一把全局锁(如 synchronized(this))。
场景: 游戏玩家的“金币”和“经验值”。
❌ 一把大锁: 捡金币时不能打怪涨经验。
✅ 锁分离:
1 | private final Object goldLock = new Object(); |
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 | // 锁粒度太细,导致业务逻辑断层 |
后果: 检查时有库存,扣减时没库存了。
- 原则: 锁的范围必须覆盖整个原子性的业务逻辑,不能为了细化而把一个完整的事务切开。
3. 不要自作聪明:锁粗化 (Lock Coarsening)
❌ 错误示范:
1 | for (int i=0; i<10000; i++) { |
JVM 极其厌恶这种代码。由于加锁/解锁本身有开销,JVM 的 JIT 编译器在运行时会自动进行**“锁粗化”**,将锁扩展到循环外部。
- 启示: 除非循环体特别耗时,否则直接在循环外加锁,代码更清晰,性能也相差无几(甚至更好)。
结语:决策漏斗
当我们在代码中遇到线程安全问题,需要确定加锁粒度时,请按此漏斗进行决策:
- Level 1(无锁): 能否用
ThreadLocal封闭?能否用不可变对象? - Level 2(指令级): 只是简单数值修改?用
Atomic/LongAdder。 - Level 3(数据级): 是集合操作?用
ConcurrentHashMap等并发容器(锁节点)。 - Level 4(逻辑级): 必须加互斥锁?
- 先做纵向优化:把耗时操作踢出去。
- 再做横向优化:看看能不能拆分独立的锁。
锁粒度的控制,本质上是在寻找 CPU 吞吐量与数据一致性之间的纳什均衡。


