《Java 并发编程·性能诊断与事故》4.2 活锁、饥饿与优先级反转

用可运行的重试碰撞实验复现活锁,给出 2 秒内 31 万到 38 万个回合、成功 0 次的真实计数,对照非公平锁与公平锁下写者的等待分位与吞吐差距,实测 macOS 上 Thread.setPriority 的 os_prio 不变、迭代数无稳定差异,最后给出带抖动的退避与锁选择建议。

承接的问题

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 7210 µs9 µs171 µs
1公平88 17534 µs80 µs126 µs
2非公平15 381 0100 µs11 µs99 µs
2公平93 25831 µs56 µs804 µs
3非公平14 932 9450 µs112 µs177 µs
3公平73 96247 µs89 µs168 µ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 的优先级调得更高”,而是:

  1. 缩短持锁时间:把 I/O、日志、远程调用移出临界区;
  2. 给等待设上界:tryLock(timeout) 或 lockInterruptibly(),让 H 有机会失败退出而不是无限等;
  3. 资源隔离:把高优先级任务放进独立线程池,避免和 CPU 密集任务共享同一组 worker;
  4. 不要依赖 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 锁顺序规范与超时兜底

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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