2266 字
约 7 分钟
20
S04-A12 · 锁的粒度:从粗到细分几步

一段处理库存的代码用了类级 synchronized,压测时并发二百,实际吞吐与并发二差不多。改成按 SKU 加锁之后,吞吐涨了十倍——然后出现另一类问题:同一个 SKU 的两次操作隔了很远,却因为锁对象在方法里被重新创建,互斥完全失效。

锁的粒度调整是个典型的两难:粗了吞吐上不去,细了正确性容易破。而"细"不只是把大锁拆成小锁,它要求先有一个稳定的加锁键、一个可靠的锁对象来源、以及一套能解释争用的观测。


1. 三个粒度层级

层级 互斥范围 吞吐 正确性风险
全局锁 整个方法/类 最低 低
分段锁 一组键 中 中(分段不均可退化)
按键锁 单个键 高 高(键与锁对象要稳定)
无锁(CAS/原子类) 单变量 最高 需保证组合原子性

粒度调整还有一层隐含成本:拆细之后,观测口径必须跟着变。粗锁的争用只表现为一个数字(方法耗时),细锁的争用分散在很多键上,必须按键统计才能看出"哪些键在排队"。

第四层不是"更好",而是"只适用于单变量更新"。多变量必须一起变时,仍然是锁的领域。

2. 细分的前提:一个稳定的加锁键

键的选择 问题
对象本身(this) 一个实例一把锁,等于全局
业务 ID 正确,但要处理 null 与规范化
复合键(ID + 类型) 需要保证拼接无歧义
字符串常量 极易因驻留机制意外合并

第四项是 Java 特有的陷阱:字面量字符串会被驻留复用,两个不相关的业务用同一个字面量做锁键,就会互相阻塞。用 new String(...) 或 intern() 的取舍要写清楚。

// 错误:字面量字符串被驻留,不同业务共用同一把锁
synchronized ("lock") { ... }

// 正确:锁对象来自一个受控的映射,键规范化后再取
private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();
Object lockFor(String sku) {
    String key = sku.trim().toUpperCase();          // 规范化,避免 S01/s01 变成两把锁
    return locks.computeIfAbsent(key, k -> new Object());
}

分段锁还有一个隐蔽的退化形态:段数固定但键分布倾斜。64 个段里,热点 SKU 全落在同一段,段数再多也没用——表面上是"分段锁",实际退化成"单段锁"。所以分段数要配合"键的分布"看,而不是"并发数"看。

3. 锁对象必须有生命周期

做法 风险
每次新建 Object 当锁 互斥完全失效
放在 Map 里永不清除 内存缓慢增长
用弱引用 可能被回收导致换锁
用固定数量的锁槽(分段) 冲突概率上升但内存可控

第一项是最常见的隐蔽缺陷:synchronized (new Object()) 等于没加锁,因为每次调用拿到的是不同的对象。它在代码评审里几乎看不出来,因为没有语法错误。

// 反例:锁对象每次新建,互斥失效
void update(String sku) {
    synchronized (new Object()) { ... }   // 完全无效
}

// 正例:固定锁槽,内存可控且互斥有效
private final Object[] slots = new Object[64];
{ for (int i = 0; i < slots.length; i++) slots[i] = new Object(); }

void update(String sku) {
    Object lock = slots[Math.floorMod(sku.hashCode(), slots.length)];
    synchronized (lock) { ... }
}

4. 从粗到细的三步顺序

// 调整顺序:先测、再分、最后验证
1. 度量争用:锁等待时间占比、持锁时长分布
2. 确认瓶颈在锁上(而不是在 IO 或下游)
3. 选加锁键,改成按键/分段锁
4. 用并发压测验证:吞吐上升且结果正确
5. 补观测:锁等待、冲突率、持锁时长

度量可以直接从线程栈上取:

// 采样线程栈,看有多少线程停在锁上(BLOCKED 状态)
jstack 1 | grep -A2 "State: BLOCKED" | head -40
jcmd 1 Thread.print | grep -c "waiting to lock"   # 计数:锁等待的线程数
// BLOCKED 线程占比高 → 锁是瓶颈;RUNNABLE 且栈在 IO → 瓶颈不在锁

第二项最容易被跳过:很多"锁竞争"实际是下游慢。持锁期间调用数据库,锁等待自然高,但拆细锁没用——该做的是把 IO 移出临界区。

5. 细粒度带来的新问题

新问题 表现
多键操作需多把锁 死锁或必须固定顺序
原子性丢失 两个小锁之间出现中间态
观测变难 争用分散,难以定位
公平性变差 热点键上排队更长

锁的公平性也值得一句:热点键上排队更长是正常的,不是缺陷。如果要求"每个请求都公平",就要用公平锁,代价是吞吐下降——多数业务需要的是"不饿死",而不是"严格先来先服务"。

第二项是最本质的:拆锁会破坏跨键的原子性。如果业务上"扣 A 加 B"必须同时成功,那么 A 与 B 必须共用一把锁,拆细就错了。

// 多键操作:固定顺序加锁,避免死锁
void transfer(String from, String to, long amt) {
    Object first  = lockFor(from.compareTo(to) < 0 ? from : to);   // 按字典序固定
    Object second = lockFor(from.compareTo(to) < 0 ? to : from);
    synchronized (first) {
        synchronized (second) { doTransfer(from, to, amt); }
    }
}
// 若不做顺序约定,两个方向的转账会互相等待

锁与事务的边界也要分清楚:数据库行锁与 Java 锁是两套互不相干的机制。在同一段逻辑里同时用两者,会出现"数据库锁住的行被另一个线程改了内存对象"这类跨层不一致,而两边的锁各自都"工作正常"。

6. 排错:五种"加了锁还是不对"

现象 成因 处理方向
并发下计数不准 锁对象每次新建 用稳定锁对象
同一业务偶发互斥 键未规范化 统一大小写/去空格
吞吐不随并发上升 锁范围含 IO 或全局锁 缩小临界区
偶发死锁 多锁顺序不一致 固定全局顺序
拆锁后数据错乱 跨键原子性被破坏 合并锁或改用单线程处理

排错时还要区分"锁"与"等待":线程池、连接池、队列都会造成等待,表现与锁争用相似。看栈上的等待对象是哪一类,才能定位到真正的瓶颈。

第一项与第五项是这一对调整的两个失败方向:锁对象不稳定导致细粒度失效,原子性被破坏导致细粒度有害。前者靠代码审查与测试发现,后者只能靠业务语义判断。

7. 边界:什么情况不必细调

场景 为什么 做法
临界区极短(几次赋值) 争用成本低于拆分成本 保持粗锁
并发极低 争用不发生 不必优化
已经是单线程处理(如单分区消费) 无并发 不需要锁
可用无锁结构(原子类、CHM) 锁本身是多余 用无锁

最后一条与设计有关:能在结构上消除争用,就不要靠调锁。比如把"共享计数"改成"分片计数最后合并",把"共享缓存"改成"每线程本地缓存"——这些改动消灭的是争用本身,而不是让争用更公平。

判断口径一句话:锁等待是否真的占了可观的时间。占了,再谈粒度;没占,拆锁只会增加出错面。


8. 自测

  1. 三个粒度层级各自牺牲了什么?为什么无锁不是"更好的锁"?
  2. 为什么字面量字符串不能当锁键?new String 与 intern 的取舍是什么?
  3. synchronized (new Object()) 为什么等于没加锁?它在评审中为什么难以发现?
  4. 从粗到细的五步中,哪一步最容易被跳过?跳过会导致什么误判?
  5. 拆细锁会破坏什么?举一个必须共用一把锁的业务例子。
  6. 判断"是否值得细调锁粒度"的那个问题是什么?
S04-A12 · 锁的粒度:从粗到细分几步
https://www.clxhxhhr.top/posts/6429/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。