承接的问题
4.1 讲的是死锁发生之后怎么确认,4.2 讲的是没有环但系统不前进的情况。这一节处理预防:怎么让“环”在代码层面就难以形成,以及不小心形成之后怎么把损失限制在可控范围。
典型症状有三种:
- 上一次死锁是把
transfer(A, B)改成固定顺序修好的,半年后新增了一条transfer(B, A)的批量入口,死锁原样回归; - 团队接受了“所有地方都用
tryLock(100ms)”的建议,死锁确实不再永久卡死,但接口偶尔多出 100 ms 抖动,超时计数没人看; - Code Review 时看不出锁顺序问题,因为两把锁的获取分散在 service、repository 和事件回调三层。
核心结论先说:锁顺序是一个全局契约。它不能靠某个方法内部的自律,必须有一张所有代码都能查的顺序表,加上一个能发现“有人没遵守”的检测手段。tryLock(timeout) 只是兜底,它把永久死锁变成可观测的超时,并没有消除反向边。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8;实验期间 load average 在 60–80,所有计时为单次运行、随调度波动、不作基准。
全局锁顺序:按 ID、按类型分层
先定规则,再写代码。一个可执行的锁顺序规范至少包含四条:
| 规则 | 具体要求 | 反例 |
|---|---|---|
| 同类资源按业务 ID 排序 | 用 ISBN、会员 ID、账户 ID 等稳定键,小的先拿 | 按 hashCode()、identityHashCode() 排序 |
| 跨类资源按层号排序 | 配置层 < 索引层 < 账户层 < 明细层,只允许低层到高层 | 先拿明细锁再拿账户锁 |
| 动态集合先排序再全部获取 | 需要锁 N 个资源时,按同一个键排序后依次获取 | 按请求参数顺序逐个加锁 |
| 同一资源只锁一次 | 先去重,再排序;重入虽然合法,但会打乱配对 | 列表里有重复 ID,重复 lock() |
第三条最容易被忽略。批量扣减库存时,请求里可能同时出现 ISBN B 和 ISBN A;如果代码按参数顺序加锁,两个请求的参数顺序相反就会形成环。正确做法是先 distinct(),再按 ISBN 排序,最后依次加锁。
下面的转账实现把“按 ID 排序”和“超时兜底”放在一起:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class OrderedTransfer {
private final ReentrantLock[] locks = new ReentrantLock[16];
public OrderedTransfer() {
for (int i = 0; i < locks.length; i++) {
locks[i] = new ReentrantLock();
}
}
public boolean transfer(int fromId, int toId, long amount, long timeoutMillis)
throws InterruptedException {
int firstId = Math.min(fromId, toId);
int secondId = Math.max(fromId, toId);
ReentrantLock first = locks[firstId];
ReentrantLock second = locks[secondId];
if (!first.tryLock(timeoutMillis, TimeUnit.MILLISECONDS)) {
return false;
}
try {
if (!second.tryLock(timeoutMillis, TimeUnit.MILLISECONDS)) {
return false; // finally 会释放 first
}
try {
if (firstId == fromId) {
debit(firstId, amount);
credit(secondId, amount);
} else {
debit(secondId, amount);
credit(firstId, amount);
}
return true;
} finally {
second.unlock();
}
} finally {
first.unlock();
}
}
private void debit(int id, long amount) {
}
private void credit(int id, long amount) {
}
}
这段代码有三个要点。
第一,排序键必须是业务键,不是对象身份。System.identityHashCode() 每次运行都可能不同,无法写进规范,也无法跨进程对齐;hashCode() 可能碰撞,碰撞后两个不同资源的相对顺序在不同 JVM 上可能不一致。ISBN、会员 ID 这类稳定字符串或整数才是可以审计的契约。
第二,fromId == toId 要在入口就拒绝或直接返回,否则同一把锁会被 tryLock 两次,虽然可重入不会死锁,但计数和超时语义会变得难以解释。
第三,锁数组按下标直接索引,隐含了“ID 落在 0–15”这个前提。真实系统里应该用 ConcurrentHashMap<String, ReentrantLock> 按业务键取锁,但取锁之后仍然要按业务键排序;容器只负责定位,顺序仍然由规范决定。
tryLock 兜底:能解环,但不是免费
固定顺序是预防,tryLock(timeout) 是兜底。为了看清兜底的边界,构造一个故意不遵守顺序的靶子:一半线程 A→B,一半线程 B→A,第二把锁用 tryLock(100ms),拿不到就释放第一把重来。进程不会永久死锁,但反向边一直在。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
public final class OrderLab {
private static final ReentrantLock LOCK_A = new ReentrantLock();
private static final ReentrantLock LOCK_B = new ReentrantLock();
private static final AtomicLong BOTH = new AtomicLong();
private static final AtomicLong TIMEOUT = new AtomicLong();
public static void main(String[] args) throws Exception {
long millis = args.length > 0 ? Long.parseLong(args[0]) : 10_000;
for (int i = 0; i < 4; i++) {
boolean forward = i % 2 == 0;
Thread.ofPlatform().name((forward ? "order-ab-" : "order-ba-") + i).start(() -> {
long deadline = System.nanoTime() + millis * 1_000_000L;
while (System.nanoTime() < deadline) {
ReentrantLock first = forward ? LOCK_A : LOCK_B;
ReentrantLock second = forward ? LOCK_B : LOCK_A;
first.lock();
boolean got = false;
try {
sleep(40); // 持有第一把锁时停一下,便于采样
try {
got = second.tryLock(100, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
if (got) {
BOTH.incrementAndGet();
sleep(20);
} else {
TIMEOUT.incrementAndGet();
}
} finally {
if (got) {
second.unlock();
}
first.unlock();
}
}
});
}
System.out.println("pid=" + ProcessHandle.current().pid());
Thread.sleep(millis + 1000);
System.out.printf("both=%d timeout=%d%n", BOTH.get(), TIMEOUT.get());
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
同一段逻辑,只要把所有线程都改成 A→B,就成了修复版。两种版本各跑 12 秒,同时在运行期采样转储:
| 版本 | 成功拿到两把锁 | 超时 | 反向边(采样) | JVM 报 deadlock |
|---|---|---|---|---|
| 混合顺序 | 1 | 159 | 10 / 60 份 | 9 / 60 份 |
| 固定顺序 | 165 | 0 | 0 / 20 份 | 0 / 20 份 |
“成功拿到两把锁”不是业务成功,只是进入临界区;混合顺序下 12 秒只成功 1 次、超时 159 次,吞吐几乎全部消耗在等第二把锁上。固定顺序后没有一次超时,成功 165 次。这个对比说明:tryLock 能避免永久死锁,但不能避免碰撞;碰撞的代价是每次 100 ms 的等待和一次重试。
更值得注意的是 JVM 的判定。混合顺序下,60 份采样里有 9 份被 Thread.print 报成死锁,但线程状态是 TIMED_WAITING (parking):
"order-ab-0" #25 ...
java.lang.Thread.State: TIMED_WAITING (parking)
- parking to wait for <0x000000035fc19610> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.tryAcquireNanos(...)
Found one Java-level deadlock:
=============================
"order-ab-0":
waiting for ownable synchronizer 0x000000035fc19610, ...
which is held by "order-ba-1"
"order-ba-1":
waiting for ownable synchronizer 0x000000035fc195e0, ...
which is held by "order-ab-0"
这两段来自同一份转储。线程明明带超时,100 ms 后就会自己解开,但 JVM 的死锁检测器只看“谁在等谁”,不看等待是否带超时,所以这一瞬间它仍然报 Found 1 deadlock.。进程内的 findDeadlockedThreads() 也一样,看门狗输出在 [25, 26] 和 none 之间交替:
[watchdog] deadlocked=[25, 26]
[watchdog] deadlocked=none
[watchdog] deadlocked=[25, 26]
[watchdog] deadlocked=none
这就是 4.1 里强调“连续两次确认”的原因。对使用 tryLock(timeout) 的系统,值班同学看到一次 deadlock 报告,先要确认等待是否带超时,再决定是不是硬死锁。
兜底本身也有成本,落地时要写清楚四条边界:
- 超时必须大于临界区的 p99。 100 ms 是拍脑袋的值;如果正常持锁就要 80 ms,超时会大量误伤。先测 p99,再取 3–5 倍。
- 重试次数必须有上限。 无限重试会把“锁碰撞”变成“CPU 空转”,4.2 的活锁就是这么来的。
- 重试要带抖动。 所有线程都用同一个 100 ms,会在同一时刻再次碰撞。
- 超时要计数、要告警。
tryLock返回false不是异常,很容易被吞掉;没有指标就等于没有兜底。
分层锁:粗粒度到细粒度
有些场景无法把两把锁合并成一把:目录结构、父子账户、缓存分层都天然有多级锁。这时用“层号”把顺序固定下来,只允许从低层到高层,且拿到高层之前必须释放低层。
import java.util.concurrent.locks.ReentrantLock;
public final class HierarchicalLocks {
private final ReentrantLock catalog = new ReentrantLock(); // L0:目录
private final ReentrantLock[] buckets = new ReentrantLock[32]; // L1:分片
public HierarchicalLocks() {
for (int i = 0; i < buckets.length; i++) {
buckets[i] = new ReentrantLock();
}
}
public void update(String isbn) {
int index;
catalog.lock(); // 先粗
try {
index = Math.floorMod(isbn.hashCode(), buckets.length);
} finally {
catalog.unlock(); // 粗锁必须在拿细锁之前释放
}
buckets[index].lock(); // 再细
try {
// 只操作该分片
} finally {
buckets[index].unlock();
}
}
}
这段代码故意不让两把锁同时持有:先查目录拿到分片下标,释放目录锁,再拿分片锁。代价是“查目录”和“改分片”之间有一个窗口,目录可能已经变化;如果业务要求两者原子,就必须反过来同时持有,这时只能靠层号保证顺序:
| 层 | 锁 | 典型操作 | 持有时间 |
|---|---|---|---|
| L0 | 目录/配置 | 路由表刷新、分片扩容 | 毫秒级,极少写 |
| L1 | 账户/索引 | 余额、会员状态 | 微秒到毫秒 |
| L2 | 分片/桶 | 单条记录更新 | 纳秒到微秒 |
| L3 | 单条记录 | 字段级 CAS | 无锁优先 |
规则只有一句:只能从低层往高层拿,不能在持有高层时回头拿低层。一旦有人写了 bucket.lock() 之后再去 catalog.lock(),层号就失效了,反向边重新出现。层号不是注释,要写进枚举或常量,让 Code Review 能对照。
分层还会带来一个隐性问题:锁升级。如果 L1 的代码发现需要修改 L0 的配置,正确做法是退出 L1、拿 L0、改完、再重新进入 L1;在持有 L1 时直接拿 L0 就是反向。这种代码在单测里通常不会死锁,因为只有一条路径;上线后和另一条反向路径一撞才会暴露。
静态检测:采样转储,找反向边
顺序规范靠人遵守,检测负责发现“有人没遵守”。思路不复杂:任意一份线程转储里,每个线程都有“已持有”和“正在等待”的锁;把它们连成有向边,如果同一份转储里同时出现 A→B 和 B→A,就说明代码里存在不一致的获取顺序,哪怕这一次没有死锁。
判定步骤:
- 逐份解析转储,按线程名分段;
- 收集
- locked <地址>和Locked ownable synchronizers里的地址,作为持有集合; - 收集
- waiting to lock <地址>和parking to wait for <地址>,作为等待集合; - 对每个线程的每个“持有 → 等待”组合建边;
- 在同一份转储内查找反向边,统计出现次数;
- 跨多份采样汇总,反向边出现得越频繁,说明碰撞窗口越大。
下面是一个可以直接跑的最小实现,输入是若干份 jcmd Thread.print -l 的输出:
python3 - out/dumps/*.txt <<'PY'
import re, sys
thread = re.compile(r'^"([^"]+)" #\d+ ')
lock = re.compile(r'^\s*-\s+(?:locked )?<(0x[0-9a-f]+)> \(a ')
wait = re.compile(r'-\s+(?:waiting to lock|parking to wait for)\s+<(0x[0-9a-f]+)>')
found = total = 0
for path in sys.argv[1:]:
name, held, waits, edges = None, set(), set(), set()
def flush():
for h in held:
for w in waits:
if h != w:
edges.add((h, w))
for line in open(path, encoding='utf-8', errors='replace'):
m = thread.match(line)
if m:
flush()
name, held, waits = m.group(1), set(), set()
continue
if name is None:
continue
m = lock.match(line)
if m:
held.add(m.group(1))
continue
m = wait.search(line)
if m:
waits.add(m.group(1))
flush()
reverse = {(h, w) for h, w in edges if (w, h) in edges}
if reverse:
found += 1
print(path, 'reverse edges:', sorted(reverse))
total += 1
print(f'scanned={total} dumps_with_reverse={found}')
PY
在混合顺序的靶子上采样 60 份,脚本报告 10 份存在反向边;把代码改成固定顺序后采样 20 份,反向边为 0。这个结果和线程转储里的 Found ... deadlock 计数能对上:混合顺序 9 份报死锁,固定顺序 0 份。
这个方法有明确的局限:
- 只看得见“正在等”的瞬间。 采样频率低、临界区短,可能一份都抓不到;压测时把采样间隔降到 50–100 ms,或者在 staging 故意在临界区加几毫秒延迟来放大窗口。
- 地址只在单份转储内有效。 跨份比较必须先归一化成业务锁名,否则 GC 和对象复用会让地址变化。
- 监视器和 ownable synchronizer 要分别处理。
synchronized用- locked,ReentrantLock用Locked ownable synchronizers;同一个脚本要同时覆盖两种格式。 - 带超时的等待也会被计入。 这是优点也是噪声:它能发现潜在的反向边,但不能直接判定为死锁。
- 锁类型不同不等于可以混用。 如果 A 是监视器、B 是
ReentrantLock,顺序规范仍然要覆盖两者;JVM 的等待图会一起算。
在 libraryd-conc 里落地
conc-core 里把锁顺序固化成代码,而不是文档:
- 定义层号。
LockLevel枚举按 L0–L3 排列,每个锁对象在创建时绑定层号;获取时断言next.level() >= current.level(),在测试环境直接抛异常。 - 统一入口。 所有多锁操作走
OrderedLocks.with(keys, action):先去重、再按业务键排序、依次tryLock,失败则按相反顺序释放,最后执行 action。 - 指标。
tryLock失败次数、等待时长、重试次数都打点到conc-ops的 8091 端口;超时率超过阈值告警。 - 采样检测。 staging 压测时挂一个脚本,每 100 ms 抓一份转储,跑上面的反向边检查;CI 的夜间任务对固定场景跑 20 份,出现反向边直接失败。
- 回归用例。 把“混合顺序”作为反例保留在
conc-lab,修复版作为正例;每次改锁相关代码都跑一遍,确认反向边为 0。
常见错误与边界
- 按
hashCode()排序。 哈希碰撞时顺序不确定,且不同 JVM 实现可能有差异;必须用业务键。 - 按
System.identityHashCode()排序。 每次运行结果不同,无法写进规范,也无法跨进程复现。 tryLock失败后不释放已持有的锁。 必须用finally保证释放;否则兜底本身变成新的泄漏。- 超时设得比临界区还短。 正常请求也会超时,重试放大流量;先测 p99 再定值。
- 用
tryLock替代顺序规范。 它只把永久死锁变成 100 ms 抖动;反向边还在,吞吐和尾延迟仍然受损。 - 在持有细锁时拿粗锁。 这是分层锁最常见的反向边,单测通常发现不了。
- 忽略带超时的等待。 JVM 会把
tryLock(timeout)形成的瞬时环报成 deadlock;不区分就会误判。 - 把分布式锁也套进本地顺序。 Redis/数据库锁的获取顺序是另一个问题,跨进程没有共享的 JVM 等待图,4.3 的方法不适用;参见 11.2。
什么时候不要用 tryLock 重试: 多资源操作如果不是幂等的,超时后重试可能造成重复扣减或重复写入;必须先有幂等键或补偿事务。另一个边界是“必须原子”的场景:两个资源要么都改、要么都不改,超时重试只能保证最终一致,不能保证中间状态不可见。
什么时候不要做全局排序: 如果锁集合非常大、排序本身成为瓶颈,应该先做分片或合并资源,而不是给几千把锁排序;排序是 O(n log n),但真正的成本是持锁期间的等待。
小结
锁顺序的核心不是某个方法里先拿 A 还是先拿 B,而是一张全局可查的表:同类按业务 ID 排序,跨类按层号排序,动态集合先去重再排序,同一资源只锁一次。固定顺序的实跑结果是 12 秒内 165 次成功、0 次超时;混合顺序只有 1 次成功、159 次超时。
tryLock(timeout) 是兜底,不是预防。它能把永久死锁变成可观测的超时,但反向边仍然存在;而且 JVM 会把带超时的瞬时环报成 deadlock,60 份采样里有 9 份如此。超时值要大于临界区 p99,重试要有上限和抖动,失败要计数。
分层锁只允许从粗到细、单向获取;采样转储找反向边是一个低成本、可自动化的检测手段:混合顺序 60 份里发现 10 份,固定顺序 20 份里为 0。下一节换一个维度,从排队论出发看线程池容量:Little’s law 怎么把并发、时延和吞吐连起来。
阅读导航:上一节 4.2 活锁、饥饿与优先级反转 | 下一节 5.1 Little’s law 在池化中的应用
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。