一段处理库存的代码用了类级 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. 自测
- 三个粒度层级各自牺牲了什么?为什么无锁不是"更好的锁"?
- 为什么字面量字符串不能当锁键?
new String与intern的取舍是什么? synchronized (new Object())为什么等于没加锁?它在评审中为什么难以发现?- 从粗到细的五步中,哪一步最容易被跳过?跳过会导致什么误判?
- 拆细锁会破坏什么?举一个必须共用一把锁的业务例子。
- 判断"是否值得细调锁粒度"的那个问题是什么?