中断协议、取消协作与上下文生命周期
Java SE 17 / OpenJDK 17u
jdk-17.0.12+7。已核对该标签的Thread.java(interrupt/interrupted/isInterrupted)、ThreadLocal.java(ThreadLocalMap Entry)与ThreadPoolExecutor/FutureTask/CompletableFuture的取消路径;本机实验运行 Oracle JDK 17.0.12。Thread.interrupt的协议语义来自 API 契约,标志位字段与ThreadLocalMap结构属于固定版本实现。本页是 C02 中断协议、取消协作、ThreadLocal 与上下文传播的唯一权威讲解:锁获取路径上的取消(lockInterruptibly/tryLock 超时/await 超时)已在锁语义与公平性与AQS/Condition讲清,JMM 视角的 volatile 停止标志在JMM 可见性,线程池接纳与关闭状态机在ThreadPoolExecutor,Future 结果状态机在FutureTask/CompletableFuture——本页互链不复制。虚拟线程的中断与上下文差异(每任务一线程的 ITL 构造时快照、ThreadLocal 每任务一份拷贝、Scoped Values 预览轨道)已由 U12 在虚拟线程正文按版本讲清,本页互链不展开。
中断是协作协议,不是强制终止
Thread.interrupt() 只做两件事:给目标线程设置一个布尔标志;如果目标正阻塞在响应中断的点上(sleep、join、Object.wait、Condition.await、LockSupport.park、可中断通道与锁获取),让它以 InterruptedException 的形式醒来。它不释放监视器、不回滚业务、不停止 CPU 上正在跑的代码、不释放池资源。一个既不检查标志、也不阻塞在可中断点上的任务,对中断完全无感——cancel(true)、shutdownNow() 都只能“尽力”。
所以每个可取消的任务都是一个协议参与方,至少要回答四问:循环里查不查 Thread.currentThread().isInterrupted()?阻塞点抛出的 InterruptedException 怎么处理?finally 里清理什么?被中断后用什么状态对外报告(取消、部分完成、失败)?这四问没有答案的任务,任何取消 API 都救不了它。反过来,调用方也要回答:取消的是结果(Future 终态)、线程(是否退出)还是业务(外部副作用是否撤销)——三层经常被合并成一句“把任务取消”,这正是事故来源。
interrupt 设置标志与阻塞点抛出
标志位的读写规则是协议核心:isInterrupted() 读取不清除;静态 Thread.interrupted() 读取并清除当前线程的标志,连续调用第二次返回 false;InterruptedException 抛出时标志已被清除。这三条组合出一个高频事故:任务捕获 InterruptedException 后既不恢复标志也不退出,中断信号被吃掉,上层(池的 shutdownNow、FutureTask.cancel(true))以为已经请求取消,任务却继续跑。
原码:interrupt 设置标志并通知 VM
原码连续摘录(仅统一公共缩进,截取方法局部):Thread.java,jdk-17.0.12+7,L994–L1012。
public void interrupt() {
if (this != Thread.currentThread()) {
checkAccess();
// thread may be blocked in an I/O operation
synchronized (blockerLock) {
Interruptible b = blocker;
if (b != null) {
interrupted = true;
interrupt0(); // inform VM of interrupt
b.interrupt(this);
return;
}
}
}
interrupted = true;
// inform VM of interrupt
interrupt0();
}interrupted = true 就是标志位的全部——一个普通 volatile 字段赋值;interrupt0() 通知 VM 唤醒阻塞在 park/wait 上的线程。blocker 分支处理正阻塞在可中断 I/O(InterruptibleChannel)上的线程:先设标志再调通道的 interrupt,让 InterruptibleChannel 抛 ClosedByInterruptException。注意普通 InputStream 阻塞读不经过 blocker,中断只设标志、读不会醒——这是“可中断 I/O”与“传统流 I/O”的边界,不能把“interrupt 了”说成“读一定会退出”。
原码:interrupted 清除,isInterrupted 不清除
原码连续摘录(仅统一公共缩进,截取方法局部):Thread.java,jdk-17.0.12+7,L1027–L1038 与 L1049–L1051。
public static boolean interrupted() {
Thread t = currentThread();
boolean interrupted = t.interrupted;
// We may have been interrupted the moment after we read the field,
// so only clear the field if we saw that it was set and will return
// true; otherwise we could lose an interrupt.
if (interrupted) {
t.interrupted = false;
clearInterruptEvent();
}
return interrupted;
} public boolean isInterrupted() {
return interrupted;
}注释解释了“先读后清”的原因:只在确实看到标志为真时才清除,避免把读取瞬间新到的中断丢失。这个清除语义是“消费型读取”——Thread.interrupted() 适合“我要处理这个中断了”的场合;只想知道状态就用 isInterrupted()。PoolAsyncProbe 第 7 节实测四步:sleep 被中断后在 catch 里读 isInterrupted() 为 false(抛出即清除);Thread.currentThread().interrupt() 恢复后为 true;接着 Thread.interrupted() 返回 true 并再次清除;再调返回 false。开发期常见误解“catch 到异常后标志还在”被这组断言直接推翻(该误解按红测记录,见实验证据)。
标准处理模板:能退出就退出循环并返回/抛出;不能立即退出(还要完成一段不可截断的收尾)就恢复标志 Thread.currentThread().interrupt() 让上层感知;吞掉异常继续长循环是最差选择。
不响应中断的任务
无响应有两类。一类是纯计算/自旋:任务从不检查标志、不进入可中断阻塞,中断只留下一个没人读的标志位。PoolAsyncProbe 第 1 节的 while (!stopFlag.get()) Thread.onSpinWait() 在 shutdownNow() 300ms 后仍存活(观察值),直到自己的 stopFlag 翻转才退出;第 8 节同款任务在 FutureTask.cancel(true) 后把计算完整跑完(效果计数到 1),只是结果被结果槽丢弃。另一类是阻塞在不响应中断的 API 上:传统 InputStream 的阻塞读、部分原生库调用。对这类任务,“取消”只剩三种真实手段:任务自己提供取消标志/超时参数;换可中断实现(InterruptibleChannel);或进程级退出(丢弃整个 JVM)。
shutdownNow 的 javadoc 明确“尽力中断……不响应中断的任务可能永不终止”——这不是实现缺陷而是协议后果。池的 TERMINATED 会被这样的 Worker 无限推迟,awaitTermination 的超时返回 false 是唯一诚实的信号:停机脚本要把它当作“仍有任务未退出”处理(记录线程名与栈,人工或更强手段介入),而不是重试调用 shutdownNow。
取消与中断的协作:三层分离
把一次取消拆成三层独立核对:结果层(Future/CompletableFuture 终态变为 CANCELLED)、线程层(执行线程是否退出)、业务层(外部副作用是否撤销)。FutureTask.cancel(true) 在结果层 CAS 赢得终态后向 runner 发 interrupt(取消竞争原码),线程层能否退出完全取决于任务是否响应;业务层(已发出的 SQL/付款/消息)与取消协议无关,要靠幂等键与对账。CompletableFuture.cancel 只动结果槽,从不 interrupt 任何线程——它的 mayInterruptIfRunning 参数在此实现中不起控制作用(PoolAsyncProbe 第 8 节实测:阶段取消后工作线程继续阻塞在闩锁上,释放后 supplier 照常执行完毕)。shutdownNow 对池内所有线程发 interrupt,等价于对每个在途任务做“线程层请求”,结果层还留给各自的 Future。
协作取消的清单可以直接当评审表用:循环每步或每批检查标志/截止时间;阻塞点选可中断版本;catch (InterruptedException e) 恢复标志再退出;finally 释放资源并写出任务终态;对外报告区分“已取消未执行”“执行到一半取消”“已完成但结果被丢弃”。PoolAsyncProbe 第 8 节的三组对照覆盖了这张表的两个极端:不检查中断的自旋任务(结果取消、线程不退、计算完成)与在闩锁上等待的协作任务(InterruptedException 观察、即刻退出、零副作用)。
超时与取消不共享一条协议
orTimeout/completeOnTimeout 是完成策略:到点把结果槽推到终态(异常或默认值),不 interrupt、不撤销底层计算(两种超时的原码)。想要“超时后把资源也要回来”,必须另持任务句柄(执行器 + 可取消的 Future)并对它执行取消协议,或让任务自己带 deadline 检查。二者组合时要注意共享终态:orTimeout 返回 this,多个调用者看到同一个阶段的同一个超时终态;先 orTimeout 再慢速 cancel,取消大概率输给超时。反向组合同样常见:cancel(true) 已 interrupt 执行线程,而 orTimeout 计时器还在跑——正常完成后 Canceller 会取消计时器,但若任务对中断的响应比超时窗口更长,超时仍可能先到。每条等待都该问:它竞争的是同一个结果槽,还是各自的资源?
ThreadLocal 的值属于线程
ThreadLocal 的语义是“每个线程一份独立副本”,不是“每个任务一份”。池化线程活多久、跑多少个请求,它的 ThreadLocalMap 就被多少次读写复用——上一个请求 set 的身份、事务、trace,若不 remove,下一个同线程任务直接读到(PoolAsyncProbe 第 9 节实测:任务一 set("request-1") 不清理,任务二 get() 读到 "request-1";改成 finally { remove(); } 后任务读到 null)。这就是“池里串请求”的机制:不是数据竞争,而是值的归属单位(线程)比使用单位(请求/任务)长。
原码:弱键强值的 Entry
原码连续摘录(仅统一公共缩进,截取类定义局部):ThreadLocal.java,jdk-17.0.12+7,L329–L337。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}键是被弱引用包裹的 ThreadLocal 对象,值是被 Entry 强引用的任意对象。把 ThreadLocal 实例丢掉后 GC 回收键,Entry 变成 stale(get()==null 而 value 还在),值并不因此消失;该文件前方的注释明确 stale entry 只保证在“表快满时”的后续操作中被清理。于是“弱键防泄漏”是个半真命题:它防的是键侧(ThreadLocal 对象本身),防不了值侧——长生命周期线程 + 大值 + 不 remove,值链 Thread → ThreadLocalMap → Entry → value 完整可达,堆Dump里表现为一个旧线程持有几十 MB 的 byte[]/Map。PoolAsyncProbe 第 10 节用反射观察(需 --add-opens,观察值非契约):丢掉键引用、System.gc() 提示后,表里出现 live=0、stale=1——键已清、值还活着,与源码注释一致。正确结论:remove() 的责任在任务侧,try/finally 里成对出现,不能指望弱键或池的清理代替。
上下文跨线程不传播:InheritableThreadLocal 与 capture-restore
InheritableThreadLocal 的继承发生在子线程构造时:父线程在 new Thread(...).start() 那一刻的值被快照进子线程。池的工作线程只构造一次、之后被成千上万个提交者复用,于是“提交时的父线程上下文”与“构造时的父线程上下文”几乎必然不同——PoolAsyncProbe 第 9 节实测:池线程预热后主线程才 itl.set("parent-late"),任务读到 null;对照组现场 new 的线程能读到值。结论是 InheritableThreadLocal 适配“每任务一线程”的模型,不适配池/CompletableFuture 阶段切换,也不适配任何“先提交后执行”的异步边界。
明确协议是 capture-restore 装饰器:提交方在提交点捕获不可变上下文快照;任务包装器在执行线程上、进入业务代码前安装快照;finally 里恢复线程先前的值(或 remove)。PoolAsyncProbe 第 9 节按此实现:装饰任务读到 trace=req-abc,结束后池线程回到先前的 {trace=pool-resident}——既不串号,也不把上一个任务的值漏给下一个。生态位上,Spring 的 TaskDecorator、阿里的 TransmittableThreadLocal 就是把这个协议工程化(代理 Runnable/线程池);本页以 JDK 原语讲清机制,选型时仍要核对:捕获点是否覆盖所有提交口、值是否不可变、恢复是否在 finally、异步链每个跳板(池、thenApplyAsync 的执行器、消息消费线程)是否都过装饰器。任何一个口子漏掉,串号就以“偶发、难复现”的形态出现。
观测、排障与修复复验
症状到定位的对照:服务“停不下来”(kill 后进程挂着)通常是非 daemon 工作线程未终结——jstack <pid> 看线程名(给 ThreadFactory 命名是排障前提),池线程阻塞在 ThreadPoolExecutor.getTask 的 poll/take 说明空闲在等任务,awaitTermination 超时且栈在工作任务里说明任务不响应关闭。日志里出现“上一个请求的身份”指向 ThreadLocal 未清理:在线程名上确认同一池线程先后处理两个请求,再查任务是否有 finally remove。cancel(true) 后任务还在跑,用 jstack 看任务栈:停在自旋/纯计算即无响应任务,停在 sleep/park 但异常被吞即中断被吃——栈帧里能看到 catch 所在行。修复后复验口径:重跑关闭流程记录 awaitTermination 是否在预算内返回 true;重放两个请求验证第二个读到自己的上下文;对取消用例断言“线程退出时间”与“效果计数”两个观测点,而不是只看 Future 终态。PoolAsyncProbe 的输出本身就是这些复验断言的可运行版本。另有一个开发期常见“实验挂死”要认得:忘了关闭的池持有非 daemon 线程,JVM main 结束后进程不退出——这正是“未关闭池”的最小症状,本票开发过程中真实发生过一次(红测脚本漏关池,如实记录在证据里)。
设计案例:同步订单接口拆出异步审计(算例,非实测)
订单接口 200ms 预算内要同步落库,审计/通知允许最终一致,决定拆到后台。方案对比(假设到达率 100 单/s,每单审计 20ms、保留对象 4KiB):
| 维度 | 方案一:专用单线程池 + capture-restore 装饰器 | 方案二:直接 CompletableFuture.supplyAsync(默认池)+ 显式传参 |
|---|---|---|
| 上下文 | 装饰器统一安装 trace/租户,finally 恢复,串号面收窄到漏包装的提交口 | 无 ThreadLocal,trace 作为参数穿过每层调用,串号不可能但签名侵入 |
| 容量 | 100/s × 20ms ≈ 2 线程饱和值,配 core=2/max=4 + 有界队列 256 + Abort → 上游感知过载 | commonPool 全局共享并行度,审计慢会与同池的其他 Async 阶段互相饥饿,无独立拒绝面 |
| 内存 | 队列上界 256×4KiB ≈ 1MiB,加上执行中 ≤ 4 任务,可预算 | commonPool 队列无独立上界,审计积压不可单独观测 |
| 取消/停机 | 停机四步作用于自己的池:shutdown→awaitTermination(30s)→处置 drain 列表→幂等重投;任务内查中断标志 | 停机没有独立把手,依赖 JVM 退出与消息重投兜底 |
| 验证 | 拒绝计数、队列年龄、两请求上下文隔离、停机演练四项都可断言 | 只能验证功能路径,过载与停机行为不可见 |
两个方案都“能跑”,差别在过载出口、停机责任与观测面是否成立。选方案一的前提是接受装饰器的维护成本(每个提交口都要过包装);选方案二的前提是审计永远轻量且可丢。演进触发:审计开始调用慢依赖(20ms → 800ms)时,方案一要在池内加 deadline 并限制在途,方案二会直接把饥饿扩散给全局。恢复责任:被 Abort 的审计事件要有持久意图(任务表/outbox),不能靠内存队列声称不丢。以上为推导算例,非实测负载。
三轮递进追问
第一轮:cancel(true) 返回 true 后 300ms,任务线程还在跑,可能是什么?逐一排除:任务不检查标志、阻塞在不响应中断的 API、异常被吞、以及“任务已退出但你观察的是另一个线程”。评分点:先核对三层(结果/线程/业务),再选观测手段(jstack 栈、效果计数)。
第二轮:Thread.interrupted() 和 isInterrupted() 都能读标志,为什么池的任务里常用后者、测试断言里常用前者?任务里不清除才能把“收到中断”传给下一层检查点;断言消费型读取恰好验证清除语义。评分点:两种读取的清除差异与适用场景。
第三轮:审计异步化后,偶发“A 租户看到 B 租户的审计内容”,从哪里开始查?先抓线程名证明同一线程先后处理两请求,再查提交口是否都过 capture-restore、值是否可变对象共享、恢复是否在 finally;InheritableThreadLocal 出现在池方案里直接判错。评分点:串号机制(线程归属>请求归属)+ 装饰器三要素 + 复验口径。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java SE 17 Thread.interrupt | Java SE 17 | 中断标志、清除语义与“非活动线程”边界 |
| Java SE 17 ThreadLocal | Java SE 17 | 副本语义、remove 建议与内存一致性行为 |
| Java SE 17 InheritableThreadLocal | Java SE 17 | 构造时继承的契约 |
| Java SE 17 ThreadPoolExecutor.shutdownNow | Java SE 17 | 尽力中断、不响应中断的任务可能永不终止 |
| OpenJDK 17u Thread | jdk-17.0.12+7 | interrupt(L994–L1012)、interrupted(L1027–L1038)、isInterrupted(L1049–L1051) |
| OpenJDK 17u ThreadLocal | jdk-17.0.12+7 | ThreadLocalMap.Entry 弱键强值(L329–L337)与 stale entry 清理注释 |
本地 PoolAsyncProbe poolasync | Oracle JDK 17.0.12 | 中断标志四步、不响应任务存活、三层取消、串请求/remove、ITL 失效、装饰器、stale entry 观察 |
访问日期:2026-10-25。标志字段、blocker 路径与 ThreadLocalMap 结构属于固定版本实现,不是 API 契约;interrupt 的协议语义(设置标志、可中断点抛出并清除)是 API 契约。升级 JDK 需重新核对实现摘录。