《Java 并发编程·JUC 源码与容器》2.3 锁的中断、超时与可重入

lockInterruptibly 与 tryLock(timeout) 在 JDK 25 统一走 acquire 大循环,返回值区分成功、超时与中断;Thread.interrupted() 负责在 park 醒来时收走中断标志。实测中断耗时、250ms 超时返回 false 的时刻,并用反射把 state 推到 Integer.MAX_VALUE 验证重入溢出。

承接的问题

2.1 讲了锁怎么排队,2.2 讲了条件队列怎么转移。还剩三种“等待的边界”没有落到源码:等待能不能被中断、能不能只等一段时间、同一线程能不能重复拿同一把锁。卷一第 6 章讲过中断语义,卷一 9.2 给过 lockInterruptibly() 和 tryLock(timeout) 的用法;本节只回答实现层的问题:

  • 中断一个在 AQS 队列里 park 的线程,源码怎么把它摘出来?
  • 超时和中断走的是同一条路径吗?返回值怎么区分?
  • 重入计数存在哪里,溢出时为什么抛 Error 而不是异常?

版本差异仍然先交代。JDK 8 的超时路径叫 doAcquireNanos,中断路径叫 doAcquireInterruptibly;JDK 25 把两者合并进一个 acquire(Node, int, boolean shared, boolean interruptible, boolean timed, long time) 大循环,上面两个方法名在源码里已经不存在。对外 API acquireInterruptibly 和 tryAcquireNanos 还在,只是内部实现换了。

中断:先检查标志,再进队列

ReentrantLock.lockInterruptibly() 先走 Sync.lockInterruptibly(),再走 AQS 的 acquireInterruptibly():

// JDK 25 源码摘录,已删减
final void lockInterruptibly() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (!initialTryLock())
        acquireInterruptibly(1);
}

public final void acquireInterruptibly(int arg)
    throws InterruptedException {
    if (Thread.interrupted() ||
        (!tryAcquire(arg) && acquire(null, arg, false, true, false, 0L) < 0))
        throw new InterruptedException();
}

两处 Thread.interrupted() 的作用不同:

  • Sync.lockInterruptibly() 里的那次是入口检查:线程进来时如果已经带着中断标志,直接抛 InterruptedException,连锁都不试。这符合 Javadoc 的“中断优先于重入获取”。
  • acquireInterruptibly() 里的那次是入队前检查:如果 tryAcquire 失败,就带着 interruptible = true 进入 acquire 主循环;主循环返回负数表示被中断,转成 InterruptedException。

Thread.interrupted() 会清除标志,isInterrupted() 不会。JUC 到处用前者,是因为它要把“已经看到中断”这件事收进一个局部布尔变量,避免重复处理。主循环的尾部是这句话:

// JDK 25 源码摘录,已删减
node.clearStatus();
if ((interrupted |= Thread.interrupted()) && interruptible)
    break;

|= 是关键:Thread.interrupted() 每次调用都会清标志,如果第一次读到 true,后面再读到 false 也不能把 interrupted 改回去。这个局部变量一直活到循环结束,最后交给取消逻辑:可中断的获取返回负值,让上层抛 InterruptedException;不可中断的 lock() 则把标志重新补回线程,让调用方在拿到锁之后还能看到它。

这解释了一个常见疑问:lock() 不响应中断,但中断并没有丢。线程在 lock() 里被中断时不会抛异常,它会继续等到锁,拿到锁后 Thread.currentThread().isInterrupted() 为 true。这是“延迟响应”,不是“忽略”。

超时:统一 acquire 的三种返回值

tryLock(timeout, unit) 先调 Sync.tryLockNanos(),再调 AQS 的 tryAcquireNanos():

// JDK 25 源码摘录,已删减
final boolean tryLockNanos(long nanos) throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    return initialTryLock() || tryAcquireNanos(1, nanos);
}
// JDK 25 源码摘录,已删减
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
    throws InterruptedException {
    if (!Thread.interrupted()) {
        if (tryAcquire(arg))
            return true;
        if (nanosTimeout <= 0L)
            return false;
        int stat = acquire(null, arg, false, true, true,
                           System.nanoTime() + nanosTimeout);
        if (stat > 0)
            return true;
        if (stat == 0)
            return false;
    }
    throw new InterruptedException();
}

acquire 的返回值只有三种:正数表示拿到锁,0 表示超时,负数表示被中断。tryAcquireNanos 把正数映射成 true、0 映射成 false、负数落空后抛 InterruptedException。所以超时和中断不是两个分支,而是同一个循环的两种退出原因。

时间参数是绝对截止时间 System.nanoTime() + nanosTimeout,不是每次 park 的相对时间。主循环里用 time - System.nanoTime() 算剩余量,剩余小于等于 0 就 break,再由取消逻辑返回 0。这样即使线程被虚假唤醒多次,总等待时间也不会超过预算。

公平性在这里有一个容易忽略的差异:Sync.tryLockNanos() 先调 initialTryLock()。非公平锁的 initialTryLock() 不查队列,所以带超时的 tryLock 在非公平锁上仍然可以插队;公平锁的 initialTryLock() 会查 hasQueuedThreads(),队列非空时直接失败,进入 tryAcquireNanos 后 tryAcquire 还会再查 hasQueuedPredecessors()。也就是说,公平锁的 tryLock(timeout) 遵守公平,非公平锁的不遵守,而两者共用同一段超时代码。

doAcquireNanos 在 JDK 25 已经不存在;读旧版源码时,把它理解为 acquire(null, arg, false, true, true, deadline) 即可。旧版里“自旋一次、判断截止时间、parkNanos”的三段式,在新版里被压进主循环的 else 分支,逻辑等价。

重入:state 就是计数,溢出是 Error

ReentrantLock 的重入计数直接存在 AQS 的 state 里。NonfairSync.initialTryLock() 的重入分支:

// JDK 25 源码摘录,已删减
} else if (getExclusiveOwnerThread() == current) {
    int c = getState() + 1;
    if (c < 0) // overflow
        throw new Error("Maximum lock count exceeded");
    setState(c);
    return true;
}

释放时做减法,只有减到 0 才真正释放:

// JDK 25 源码摘录,已删减
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (getExclusiveOwnerThread() != Thread.currentThread())
        throw new IllegalMonitorStateException();
    boolean free = (c == 0);
    if (free)
        setExclusiveOwnerThread(null);
    setState(c);
    return free;
}

三个边界:

  • 未持有锁就 unlock():getExclusiveOwnerThread() != current,抛 IllegalMonitorStateException。这是运行时异常,不是 Error,说明是调用方用错了 API。
  • 重入次数达到 Integer.MAX_VALUE 再加一:state + 1 溢出成负数,c < 0 命中,抛 Error("Maximum lock count exceeded")。用 Error 而不是异常,是因为此时锁的计数已经不可信,继续运行可能把 state 解释成“未持有”或“负重入”,属于对象不变量被破坏。
  • unlock() 次数多于 lock():第一次多出来的 unlock 会把 state 从 0 减到 -1 吗?不会。此时 owner 已经是 null,getExclusiveOwnerThread() != current 先命中,抛 IllegalMonitorStateException,走不到 setState(-1)。

Sync.tryLock()(无参 tryLock)的重入分支也有同样的 if (++c < 0) 检查,所以溢出边界对 lock()、tryLock()、lockInterruptibly() 一致。2.2 的 await 用 savedState 保存“当前重入次数”,醒来后通过 reacquire 一次性恢复,正是建立在这个计数语义上。

三项实测

把中断、超时、重入和溢出放进一个程序。溢出部分用反射把 state 直接推到 Integer.MAX_VALUE - 1,避免真的调用二十多亿次 lock():

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;

public final class LockProbe {
    public static void main(String[] args) throws Exception {
        interruptCase();
        timeoutCase();
        reentrantCase();
        overflowCase();
    }

    static void interruptCase() throws Exception {
        ReentrantLock lock = new ReentrantLock();
        lock.lock();
        CountDownLatch started = new CountDownLatch(1);
        AtomicLong caughtAt = new AtomicLong();
        Thread waiter = Thread.ofPlatform().name("waiter").start(() -> {
            started.countDown();
            try {
                lock.lockInterruptibly();
                lock.unlock();
            } catch (InterruptedException e) {
                caughtAt.set(System.nanoTime());
                System.out.println("interrupt: isInterrupted="
                        + Thread.currentThread().isInterrupted());
            }
        });
        started.await();
        while (!lock.hasQueuedThread(waiter)) {
            Thread.onSpinWait();
        }
        long t0 = System.nanoTime();
        waiter.interrupt();
        waiter.join();
        System.out.printf("interrupt: elapsedMs=%.1f, isLocked=%s%n",
                (caughtAt.get() - t0) / 1_000_000.0, lock.isLocked());
        lock.unlock();
    }

    static void timeoutCase() throws Exception {
        ReentrantLock lock = new ReentrantLock();
        lock.lock();
        AtomicLong elapsed = new AtomicLong();
        boolean[] result = new boolean[1];
        Thread t = Thread.ofPlatform().start(() -> {
            long start = System.nanoTime();
            try {
                result[0] = lock.tryLock(250, TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            elapsed.set(System.nanoTime() - start);
        });
        t.join();
        System.out.printf("tryLock(250ms): result=%s, elapsedMs=%.1f%n",
                result[0], elapsed.get() / 1_000_000.0);
        lock.unlock();
    }

    static void reentrantCase() {
        ReentrantLock lock = new ReentrantLock();
        lock.lock();
        lock.lock();
        lock.lock();
        System.out.println("reentrant: holdCount=" + lock.getHoldCount());
        lock.unlock();
        lock.unlock();
        lock.unlock();
        System.out.println("reentrant: isLocked=" + lock.isLocked());
    }

    static void overflowCase() throws Exception {
        ReentrantLock lock = new ReentrantLock();
        Field syncField = ReentrantLock.class.getDeclaredField("sync");
        syncField.setAccessible(true);
        Object sync = syncField.get(lock);
        Method setState = Class.forName("java.util.concurrent.locks.AbstractQueuedSynchronizer")
                .getDeclaredMethod("setState", int.class);
        setState.setAccessible(true);
        lock.lock();
        setState.invoke(sync, Integer.MAX_VALUE - 1);
        lock.lock();
        System.out.println("overflow: holdCount=" + lock.getHoldCount());
        try {
            lock.lock();
        } catch (Error e) {
            System.out.println("overflow: " + e.getMessage());
        }
        setState.invoke(sync, 1);
        lock.unlock();
        System.out.println("overflow: isLocked=" + lock.isLocked());
    }
}

编译与运行(反射写 java.util.concurrent.locks 需要 --add-opens,只用于实验,生产代码不要这样做):

javac --release 25 LockProbe.java
java --add-opens java.base/java.util.concurrent=ALL-UNNAMED \
     --add-opens java.base/java.util.concurrent.locks=ALL-UNNAMED \
     -cp . LockProbe

本机输出(实验时 load average 约 86):

interrupt: isInterrupted=false
interrupt: elapsedMs=0.1, isLocked=true
tryLock(250ms): result=false, elapsedMs=260.1
reentrant: holdCount=3
reentrant: isLocked=false
overflow: holdCount=2147483647
overflow: Maximum lock count exceeded
overflow: isLocked=false

逐行解释:

  • 等待线程被 interrupt() 后,caughtAt 立刻记录,耗时 0.1 ms;isInterrupted=false 说明 InterruptedException 抛出时清掉了标志。主线程的 isLocked=true 说明等待者没有拿到锁,catch 里不能 unlock()。
  • tryLock(250ms) 返回 false,实测 260.1 ms,多出的约 10 ms 来自定时器精度和线程被唤醒后重新上 CPU 的调度延迟。高负载下超时只会“晚到”,不会“早退”;如果它远早于 250 ms 返回 false,只可能是传入的超时已经小于等于 0,中断会抛 InterruptedException,不会返回 false。
  • holdCount=3 验证重入计数;三次 unlock 后 isLocked=false。
  • 溢出实验里 holdCount 显示 2147483647,再 lock() 抛 Error,消息是 Maximum lock count exceeded。实验结束前用反射把 state 重置为 1 再 unlock(),否则这把锁会一直处于“持有 Integer.MAX_VALUE 次”的状态。

在 libraryd-conc 里落地

LockedLoanService 的 reserve() 已经在 2.1 用了 tryLock(200ms):等不到锁就返回 CONFLICT,由上层决定重试或降级。returnBook() 更适合可中断等待,因为归还通常发生在请求线程或关闭流程里:

// LockedLoanService 中替换后的 returnBook 方法
@Override
public boolean returnBook(String loanId) {
    try {
        lock.lockInterruptibly();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new LoanException(ErrorCode.INTERRUPTED, "归还时等待锁被中断");
    }
    try {
        return loans.findById(loanId)
                .map(loan -> {
                    loans.save(loan);
                    return true;
                })
                .orElse(false);
    } finally {
        lock.unlock();
    }
}

这段代码的边界很清楚:

  • 中断只可能发生在 lockInterruptibly() 里,也就是还没拿到锁、还没改任何状态的时候;此时抛 INTERRUPTED 不会留下半完成的归还记录。
  • 一旦进入 try 块,临界区里的代码不响应中断;finally 保证释放锁。如果临界区里有 IO 或远程调用,中断要等它返回后才生效,这也是“锁内不做慢操作”的另一个理由。
  • reserve() 的 tryLock(200ms) 与这里形成互补:读路径怕排队,写路径怕不能取消。两者都失败时,LoanException 的 ErrorCode 分别是 CONFLICT 和 INTERRUPTED,上层可以据此决定重试还是终止。

如果服务要优雅停机,线程池 shutdownNow() 会给工作线程发中断;正在 lockInterruptibly() 里排队的归还请求会立刻退出,而不是等到停机超时。这个行为依赖 returnBook() 没有吞掉 InterruptedException,也没有在 catch 里继续重试。

这个设计的代价

  • 可中断不等于可取消。 中断只保证“等待阶段”能退出;如果线程已经进入临界区,中断要等临界区结束。把 lockInterruptibly() 当成“随时能停”的保证,会在长临界区上失算。
  • 超时不是重试策略。 tryLock(200ms) 返回 false 只说明这段时间没抢到锁,不说明服务过载;直接循环重试会把锁竞争放大。正确做法是上层给总预算,超时后降级或快速失败。
  • 吞掉 InterruptedException 会破坏停机。 catch 里既不恢复标志也不抛出,线程池会以为任务正常结束,shutdownNow() 的传播链在这里断掉。至少要 Thread.currentThread().interrupt()。
  • 重入是便利,不是设计目标。 深层重入会让锁的持有时间难以估算,也容易掩盖锁顺序问题;能拆成“外层持锁、内层无锁方法”就不要依赖重入计数。
  • 溢出 Error 不可恢复。 不要 catch (Error) 后继续用这把锁;对象不变量已经坏了,正确做法是让线程退出、重建服务实例。这个边界现实中几乎碰不到,但源码明确把它定义成 Error,读源码时要理解这个选择。

小结

  • lockInterruptibly() 在入口用 Thread.interrupted() 清标志并优先抛异常;acquire 主循环用 interrupted |= Thread.interrupted() 累积中断,可中断时返回负值,不可中断时把标志补回。
  • tryLock(timeout) 走 tryAcquireNanos,内部是带绝对截止时间的统一 acquire;返回值正数成功、0 超时、负数中断。JDK 25 已没有 doAcquireNanos。
  • 重入计数就是 AQS 的 state;溢出抛 Error("Maximum lock count exceeded"),未持锁 unlock() 抛 IllegalMonitorStateException。
  • 本机实测:中断等待线程 0.1 ms 退出且锁仍由主线程持有;tryLock(250ms) 在 260.1 ms 返回 false;重入 3 层计数正确;state 推到 Integer.MAX_VALUE 后下一次 lock() 抛 Error。

阅读导航:上一节 2.2 Condition 的条件队列 | 下一节 3.1 ReentrantReadWriteLock 的 state 拆分

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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