《Java 并发编程·性能诊断与事故》4.3 锁顺序规范与超时兜底

把锁顺序规范落成可执行的规则:按业务 ID 排序、按类型分层、粗粒度到细粒度单向升级,并给出 tryLock(timeout) 兜底的转账代码与真实代价——采样 60 份转储发现 10 份存在反向边,固定顺序后 20 份降为 0,附可直接运行的检测脚本。

承接的问题

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
混合顺序115910 / 60 份9 / 60 份
固定顺序16500 / 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 报告,先要确认等待是否带超时,再决定是不是硬死锁。

兜底本身也有成本,落地时要写清楚四条边界:

  1. 超时必须大于临界区的 p99。 100 ms 是拍脑袋的值;如果正常持锁就要 80 ms,超时会大量误伤。先测 p99,再取 3–5 倍。
  2. 重试次数必须有上限。 无限重试会把“锁碰撞”变成“CPU 空转”,4.2 的活锁就是这么来的。
  3. 重试要带抖动。 所有线程都用同一个 100 ms,会在同一时刻再次碰撞。
  4. 超时要计数、要告警。 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,就说明代码里存在不一致的获取顺序,哪怕这一次没有死锁。

判定步骤:

  1. 逐份解析转储,按线程名分段;
  2. 收集 - locked <地址> 和 Locked ownable synchronizers 里的地址,作为持有集合;
  3. 收集 - waiting to lock <地址> 和 parking to wait for <地址>,作为等待集合;
  4. 对每个线程的每个“持有 → 等待”组合建边;
  5. 在同一份转储内查找反向边,统计出现次数;
  6. 跨多份采样汇总,反向边出现得越频繁,说明碰撞窗口越大。

下面是一个可以直接跑的最小实现,输入是若干份 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 里把锁顺序固化成代码,而不是文档:

  1. 定义层号。 LockLevel 枚举按 L0–L3 排列,每个锁对象在创建时绑定层号;获取时断言 next.level() >= current.level(),在测试环境直接抛异常。
  2. 统一入口。 所有多锁操作走 OrderedLocks.with(keys, action):先去重、再按业务键排序、依次 tryLock,失败则按相反顺序释放,最后执行 action。
  3. 指标。 tryLock 失败次数、等待时长、重试次数都打点到 conc-ops 的 8091 端口;超时率超过阈值告警。
  4. 采样检测。 staging 压测时挂一个脚本,每 100 ms 抓一份转储,跑上面的反向边检查;CI 的夜间任务对固定场景跑 20 份,出现反向边直接失败。
  5. 回归用例。 把“混合顺序”作为反例保留在 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 在池化中的应用

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收