承接的问题
4.1 的死锁是“谁都不动”:线程进入 park,CPU 空着,转储里能看到环。活锁和饥饿更难查,因为它们看起来“很忙”。
典型症状有三种:
- CPU 跑到 90%,吞吐接近 0,线程转储里没有死锁,所有线程都是
RUNNABLE或TIMED_WAITING (parking),日志里全是“retry”; - 读请求一切正常,写请求偶尔要等几秒甚至超时,写线程数不多,锁也没有被谁长期持有;
- 给关键线程调高了
Thread.setPriority,线上表现毫无变化。
这三种分别对应活锁、写饥饿和优先级失效。它们共享一个特征:没有环,所以 JVM 的死锁检测帮不上忙。这一节把三个场景都构造出来,能复现的给真实计数,复现不了的如实说明条件。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8;实验期间 load average 在 60–80。所有计时都是单次运行,随调度波动,不作基准。
活锁:都在动,没人前进
活锁的定义是:线程没有阻塞,仍在执行指令,但系统状态没有向前推进。最典型的形式是两个线程都检测到冲突、都退让、又同时重试,于是永远在同一处碰撞。
下面的靶子让两个线程各持一把锁,再 tryLock 对方那把;失败后释放自己那把,然后重试。forced 模式用三道栅栏把“双方都持有第一把 → 双方都尝试第二把 → 双方都释放”固定成一个不可分割的回合,用来稳定复现;timed 模式不加栅栏,只做对称的 1 ms 退让,用来观察自然碰撞率。
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
public final class LivelockLab {
private static final ReentrantLock BOOKS = new ReentrantLock();
private static final ReentrantLock QUOTA = new ReentrantLock();
private static final AtomicLong ROUNDS = new AtomicLong();
private static final AtomicLong COLLISIONS = new AtomicLong();
private static final AtomicLong SUCCESS = new AtomicLong();
public static void main(String[] args) throws Exception {
long windowMillis = args.length > 0 ? Long.parseLong(args[0]) : 2000;
String mode = args.length > 1 ? args[1] : "forced";
CyclicBarrier bothHold = new CyclicBarrier(2);
CyclicBarrier bothTried = new CyclicBarrier(2);
CyclicBarrier bothReleased = new CyclicBarrier(2);
Thread a = Thread.ofPlatform().name("worker-a").start(
() -> work(BOOKS, QUOTA, bothHold, bothTried, bothReleased, mode, windowMillis));
Thread b = Thread.ofPlatform().name("worker-b").start(
() -> work(QUOTA, BOOKS, bothHold, bothTried, bothReleased, mode, windowMillis));
a.join();
b.join();
System.out.printf("mode=%s window=%dms rounds=%d collisions=%d success=%d%n",
mode, windowMillis, ROUNDS.get(), COLLISIONS.get(), SUCCESS.get());
}
private static void work(ReentrantLock first, ReentrantLock second,
CyclicBarrier bothHold, CyclicBarrier bothTried,
CyclicBarrier bothReleased, String mode, long windowMillis) {
long deadline = System.nanoTime() + windowMillis * 1_000_000L;
while (System.nanoTime() < deadline) {
first.lock();
boolean gotBoth = false;
try {
if ("forced".equals(mode) && !await(bothHold)) {
return;
}
ROUNDS.incrementAndGet();
if (second.tryLock()) {
gotBoth = true;
SUCCESS.incrementAndGet();
} else {
COLLISIONS.incrementAndGet();
}
if ("forced".equals(mode) && !await(bothTried)) {
return; // 双方都尝试过之后才允许释放
}
} finally {
if (gotBoth) {
second.unlock();
}
first.unlock();
}
if ("forced".equals(mode)) {
if (!await(bothReleased)) {
return;
}
} else if (!gotBoth) {
try {
Thread.sleep(1); // 对称退让:双方都等 1 ms
} catch (InterruptedException e) {
return;
}
}
}
}
private static boolean await(CyclicBarrier barrier) {
try {
barrier.await(1, TimeUnit.SECONDS);
return true;
} catch (Exception e) {
return false;
}
}
}
本机实跑,2 秒窗口,每种模式跑三次:
mode=forced window=2000ms rounds=363788 collisions=363788 success=0
mode=forced window=2000ms rounds=381036 collisions=381036 success=0
mode=forced window=2000ms rounds=310882 collisions=310882 success=0
mode=timed window=2000ms rounds=20224344 collisions=2249 success=20222095
mode=timed window=2000ms rounds=21110461 collisions=2251 success=21108210
mode=timed window=2000ms rounds=22061338 collisions=2218 success=22059120
forced 模式下 2 秒内完成了 31 万到 38 万个回合,碰撞次数等于回合数,成功次数为 0。线程一直在执行 lock/tryLock/unlock,没有一个是 BLOCKED,但业务状态没有前进一格,这就是活锁。
timed 模式的结果要诚实解读:2 000 万次回合里只有约 2 200 次碰撞,碰撞率约 0.011%,其余全部成功。自然活锁在本机没有稳定复现。 原因不是机制不成立,而是碰撞窗口太窄:两个线程各持一把锁、同时尝试另一把的时间窗只有几条指令;在 10 核机器上,只要有一方先完成 tryLock,它就会拿到两把锁并释放,另一方随后自然成功。要让它稳定出现,需要满足至少一个条件:竞争线程数远大于核数、临界区足够长、退让时长完全对称、或者把可用 CPU 限制到 1 核。把窗口放大到毫秒级(也就是 forced 做的事),活锁立刻变成 100%。
修复活锁的关键不是“多退让”,而是打破对称。对称退让等于把两个线程继续绑在同一相位上:
import java.util.concurrent.ThreadLocalRandom;
public final class Backoff {
public static long millis(int attempt) {
long ceiling = Math.min(500L, 50L << (attempt - 1));
long half = ceiling / 2;
return half + ThreadLocalRandom.current().nextLong(half + 1);
}
}
退避区间取 [ceiling/2, ceiling],两次重试的间隔就不会完全一样;再叠加指数增长和上限,碰撞概率随尝试次数快速下降。更彻底的做法是指定一个赢家:按线程 ID、会员 ID 或请求 ID 选主,只有主方立即重试,另一方固定退让一个更大的窗口。对称性是活锁的燃料,打破对称比调大 sleep 更有效。
写饥饿:非公平锁与持续到达
饥饿是“某个线程永远排不上队”。在 Java 里最常见的来源是非公平锁的插队:ReentrantLock(false) 的 tryAcquire 不检查队列里有没有等待者,新来的线程可以直接抢走刚释放的锁。一个已经 park 的写者被唤醒后,如果又有一个读者先抢到锁,它就只能继续 park。
下面的对照实验让 8 个读者线程持续抢同一把锁,1 个写者每 5 ms 申请一次,记录写者每次拿到锁的等待时间;然后把锁换成公平模式再跑一遍。
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
public final class StarvationLab {
private static final int READERS = 8;
private static long shared;
public static void main(String[] args) throws Exception {
run("nonfair", new ReentrantLock(false));
run("fair", new ReentrantLock(true));
}
private static void run(String label, ReentrantLock lock) throws Exception {
AtomicLong reads = new AtomicLong();
CountDownLatch start = new CountDownLatch(1);
Thread[] readers = new Thread[READERS];
for (int i = 0; i < READERS; i++) {
int index = i;
readers[index] = Thread.ofPlatform().name(label + "-reader-" + index).start(() -> {
await(start);
while (!Thread.currentThread().isInterrupted()) {
lock.lock();
try {
shared++;
reads.incrementAndGet();
} finally {
lock.unlock();
}
Thread.onSpinWait();
}
});
}
long[] waits = new long[40];
Thread writer = Thread.ofPlatform().name(label + "-writer").start(() -> {
await(start);
for (int i = 0; i < waits.length; i++) {
long t0 = System.nanoTime();
lock.lock();
long t1 = System.nanoTime();
try {
shared++;
} finally {
lock.unlock();
}
waits[i] = (t1 - t0) / 1000; // µs
sleep(5);
}
});
sleep(300);
start.countDown();
writer.join();
for (Thread r : readers) {
r.interrupt();
r.join();
}
long[] sorted = waits.clone();
Arrays.sort(sorted);
System.out.printf("%-7s reads=%d p50=%dus p90=%dus max=%dus%n",
label, reads.get(), sorted[20], sorted[36], sorted[39]);
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
三次运行的真实结果:
| 运行 | 模式 | 读者完成次数 | 写者 p50 | 写者 p90 | 写者 max |
|---|---|---|---|---|---|
| 1 | 非公平 | 15 048 721 | 0 µs | 9 µs | 171 µs |
| 1 | 公平 | 88 175 | 34 µs | 80 µs | 126 µs |
| 2 | 非公平 | 15 381 010 | 0 µs | 11 µs | 99 µs |
| 2 | 公平 | 93 258 | 31 µs | 56 µs | 804 µs |
| 3 | 非公平 | 14 932 945 | 0 µs | 112 µs | 177 µs |
| 3 | 公平 | 73 962 | 47 µs | 89 µs | 168 µs |
两个结论都很重要。
第一,写饥饿在本机没有复现。非公平锁下写者的 p50 是 0 µs,最坏 177 µs;把读者加到 32 个,写者 p50 升到 378–436 µs、最坏 1 036 µs,但仍然没有秒级等待;用 -XX:ActiveProcessorCount=1 把可用核数压到 1,最坏 173–200 µs。写者没有出现“永远轮不到”的情况。
原因要从“谁被饿死”说起:这个实验里的写者是新到达者,非公平锁恰好对它有利,它可以直接插队。真正会被饿死的是已经排队的线程:它被唤醒后,锁又被新来的读者抢走,只能重新 park。要稳定触发,需要读者到达速率持续高于写者被唤醒后的抢占速度,也就是读者线程数远大于核数、临界区比调度延迟更长、且读者形成不间断的到达流。本机的临界区只有几十纳秒,读者虽多,队列始终没有形成足够长的积压。
第二,公平是有代价的。公平模式下读者完成次数从 1 500 万掉到 7 万到 9 万,差了约 165–200 倍(两个数量级);写者的 p50 从 0 µs 升到 31–47 µs,还出现过 804 µs 的离群值。公平锁每次释放都要把锁交给队首线程,伴随一次 park/unpark 和上下文切换;对于纳秒级临界区,这个开销远大于临界区本身。公平只约束“顺序”,不约束“单次等待有多长”。
所以选择不是“公平一定好”,而是看谁在等、等多久算事故:
| 场景 | 建议 | 理由 |
|---|---|---|
| 读多写少、临界区纳秒级 | 非公平 | 写者是新到达者,通常能直接拿到;公平锁吞吐损失过大 |
| 写延迟有 SLO(如落库 < 50 ms) | 公平或读写锁 + 写优先 | 需要可预测的排队上界 |
| 写操作频率低但每次很长 | 分段/分片,避免全局互斥 | 公平也救不了长临界区 |
| 读者会重入或长期持有读锁 | ReentrantReadWriteLock 非公平需评估写者等待 | 读锁共享,写者要等当前读者全部退出 |
优先级反转:setPriority 在本机为什么不起作用
优先级反转的经典链条是:低优先级线程 L 持有锁,高优先级线程 H 等这把锁,中优先级线程 M 是 CPU 密集任务。调度器看到 H 在等锁、L 优先级低,就把 CPU 给了 M;M 一直跑,L 拿不到 CPU,也就永远不释放锁,H 被 M 间接拖住。问题不在于 H 等 L,而在于 M 插到了中间。
在讨论修复之前,先确认一个前提:Java 的线程优先级在目标平台上到底有没有生效。下面这段代码让两个线程跑同样的 CPU 忙循环,一个 MAX_PRIORITY、一个 MIN_PRIORITY,同时跑 2 秒,比较迭代次数。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
public final class PriorityLab {
public static void main(String[] args) throws Exception {
long millis = args.length > 0 ? Long.parseLong(args[0]) : 2000;
AtomicLong high = new AtomicLong();
AtomicLong low = new AtomicLong();
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
Thread highThread = Thread.ofPlatform().name("prio-high").start(
() -> burn(Thread.MAX_PRIORITY, high, start, done, millis));
Thread lowThread = Thread.ofPlatform().name("prio-low").start(
() -> burn(Thread.MIN_PRIORITY, low, start, done, millis));
start.countDown();
done.await();
System.out.printf("high=%d low=%d ratio=%.3f%n",
high.get(), low.get(), (double) high.get() / Math.max(1, low.get()));
Thread.sleep(1000);
}
private static void burn(int priority, AtomicLong counter, CountDownLatch start,
CountDownLatch done, long millis) {
Thread.currentThread().setPriority(priority);
try {
start.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
long deadline = System.nanoTime() + millis * 1_000_000L;
long n = 0;
while (System.nanoTime() < deadline) {
n++;
if ((n & 0xFFFF) == 0) {
Thread.onSpinWait();
}
}
counter.set(n);
done.countDown();
}
}
本机四次运行:
high=24866240 low=30767535 ratio=0.808
high=45191623 low=42698794 ratio=1.058
high=30703876 low=30700251 ratio=1.000
high=116360339 low=116569399 ratio=0.998
MAX_PRIORITY 线程完成的迭代数有时比 MIN_PRIORITY 少 19%,有时多 6%,有时几乎完全相等。差异完全落在噪声范围内,没有稳定方向。
更强的证据来自线程转储。把实验窗口拉长到 8 秒,在运行中抓一份 jcmd <pid> Thread.print -l:
"prio-high" #25 [26371] prio=10 os_prio=31 cpu=3274.16ms elapsed=3.28s ...
"prio-low" #26 [43267] prio=1 os_prio=31 cpu=3275.11ms elapsed=3.28s ...
Java 层 prio=10 和 prio=1 确实不同,但 os_prio 都是 31。也就是说,JVM 记录了 Java 优先级,却没有把它映射成 macOS 内核可用的调度优先级。3.28 秒内两个线程各消耗约 3.27 秒 CPU,几乎一比一。
结论很直接:在 macOS 上,Thread.setPriority 对 CPU 分配基本无效,不能拿它做业务优先级或实时性保证。 Linux 上会好一些,但默认的 ThreadPriorityPolicy=0 也只做很弱的映射;只有显式设置 -XX:+UseThreadPriorities -XX:ThreadPriorityPolicy=1 并以 root 运行时,Java 优先级才会转成 nice 值。即便如此,HotSpot 也不提供优先级继承:低优先级线程持有锁时,不会因为高优先级线程在等而临时提权。这条命令未在本机执行(需要 Linux 与 root),只作为排查方向列出。
因此,优先级反转的正确处置不是“把 H 的优先级调得更高”,而是:
- 缩短持锁时间:把 I/O、日志、远程调用移出临界区;
- 给等待设上界:
tryLock(timeout)或lockInterruptibly(),让 H 有机会失败退出而不是无限等; - 资源隔离:把高优先级任务放进独立线程池,避免和 CPU 密集任务共享同一组 worker;
- 不要依赖
setPriority:在本机它连“高优先级多拿 CPU”都做不到,更谈不上继承。
在 libraryd-conc 里落地
回到 libraryd-conc,三类问题的处置可以落到三个具体位置。
活锁:reserve() 的冲突重试统一走 RetryPolicy,禁止裸 while (retry) 循环和固定 sleep。重试次数、基础间隔、上限都要可配置;默认 3 次、50 ms 起、500 ms 封顶,退避区间带抖动。超过次数返回 ErrorCode.CONFLICT,让上层决定是否降级,而不是在锁里无限转。
写饥饿:库存扣减和借阅记录写入共用一把全局锁时,先看写者等待的 p99。如果写者 p99 超过 SLO,优先做分片(按 ISBN 哈希)而不是直接把锁改公平;分片能把竞争降到每个分片内部,公平锁只在分片内生效,吞吐损失可控。只有在“无法分片且写延迟必须可预测”时,才选公平锁,并接受实测中约两个数量级的吞吐下降。
优先级:代码库里禁止用 setPriority 表达业务优先级。需要区分轻重时,用独立线程池、信号量或队列优先级;这些机制至少在 JVM 内部是可观测、可测试的。
常见错误与边界
- 用固定 sleep 做退避。 两个线程固定睡 100 ms,醒来还是同时撞上;这叫“同步重试”,是活锁的加速器。必须有抖动或指定赢家。
- 把
Thread.yield()当退避。yield只是给调度器一个提示,不保证对方先跑;在 macOS 上它甚至可能立刻返回,两个线程继续抢。 - 看到
RUNNABLE就认为健康。 活锁的线程全是RUNNABLE,CPU 也很高;要看业务计数是否增长,而不是只看线程状态。 - 认为公平锁能解决所有饥饿,或在所有地方开公平锁。 公平只保证“排队的人按顺序拿锁”,不保证“排队的时间短”;本机实测纳秒级临界区下吞吐差约 165–200 倍,应按 SLO 选择性使用。
- 用
setPriority做限流或实时性保证。 macOS 上os_prio不随 Java 优先级变化,本机实测迭代数无稳定差异;Linux 上也需要特定 JVM 参数和权限,且没有优先级继承。
什么时候不要用重试: 非幂等操作(重复扣库存、重复下单)不能用“失败就重试”兜底,必须先做幂等键或去重表;否则活锁解决了,重复写又来了。重试只适合可重入、可去重、失败代价低的动作。
什么时候不要用公平锁: 临界区在纳秒到微秒级、读远多于写、且写延迟没有硬 SLO 时,公平锁的吞吐损失通常不值得;此时优先考虑分片或无锁读。
小结
活锁、饥饿、优先级反转的共同点是“没有环”。活锁靠强制交错复现:2 秒 31 万到 38 万个回合、成功 0 次;自然碰撞率只有约 0.011%,本机未稳定复现,原因是碰撞窗口太窄。修复靠打破对称,抖动退避或指定赢家,而不是加大 sleep。
写饥饿在本机同样没有复现:非公平锁下写者 p50 为 0 µs、最坏 177 µs;公平锁把写者 p50 抬到 31–47 µs,同时把读者吞吐从 1 500 万降到 7 万到 9 万,约 165–200 倍差距。真正会被饿死的是已经排队的线程,构造它需要持续到达流和更长的临界区。
Thread.setPriority 在 macOS 上基本无效:prio=10 与 prio=1 的 os_prio 都是 31,2 秒迭代数比值在 0.808 到 1.058 之间波动。不要用它做业务优先级;缩短临界区、设置等待上界、隔离线程池才是可控的手段。
下一节把这些结论收束成规范:全局锁顺序怎么定、tryLock(timeout) 怎么用才不掩盖问题、以及怎么用采样转储静态地找出反向边。
阅读导航:上一节 4.1 死锁的四个必要条件与检测 | 下一节 4.3 锁顺序规范与超时兜底
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。