qinghuan
← JUC + JVM / 全部笔记

单元 90 / JUC + JVM

P0 · 必会主线L2 · 进阶

线程协作与中断:用明确协议等待结果并结束任务

先明确线程等待的条件,再选择协作工具,给任务安排有界提交、超时、中断响应和资源清理。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能让生产者与消费者在有界队列上工作,并在中断后退出而不吞掉取消信号。

学习理由、准备要求与知识点

为什么学它

并发任务需要明确的等待、取消和退出协议;sleep 不能替代可靠协调。

先读这些内容

知识点清单

  • 中断标志
  • InterruptedException
  • 保留中断
  • CountDownLatch
  • 有界 BlockingQueue
  • 超时等待
  • 取消协议
  • finally 清理

运行前先确认JDK17 Semaphore、BlockingQueue与ExecutorCompletionService,源码固定17.0.12+7。批处理示例使用可协作取消的本地计算,不保证任意外部IO在中断后立即停止。

01先弄懂它是什么

线程要等待时,先说明在等什么。CountDownLatch 可等一组参与者结束,CyclicBarrier 可等每轮大家会合,Phaser 可管理参与者变化的阶段;Semaphore 限制同时使用资源的数量,BlockingQueue 用来交接任务或结果。它们都可能让线程停下来,但条件不同,不能只因为会阻塞就互相替换。

中断是在请求线程配合停止或离开等待,不是强行杀死它。有的阻塞方法抛 InterruptedException,计算循环则常需主动检查中断标记。已经发生的外部修改也不会因此自动回滚。因此设计取消时,要说明在哪些地方检查、怎样关闭资源,以及取消后记录什么结果,避免调用方已放弃而后台永远继续。

线程数有限,待办任务数也要有限。即使只有十个线程,一次提交一百万个任务,队列仍可能留住接近一百万个任务对象,以及这些任务引用的数据。批处理时可以规定:最多保留多少个已提交但尚未收回结果的任务,每完成一个,再补交一个。这个上限就是“活跃窗口”。剩余输入只保留轻量索引,或按需逐条读取,避免全部装进任务队列。

文中这几个词,先认一认

许可 Permit
表示还允许多少个任务进入受限区域的名额,用完后其他任务需要等待或放弃。
协作取消
任务在约定位置检查中断或取消信号,主动结束并清理资源。
完成队列
谁先完成就先把谁的 Future 放出来,调用方不必按提交顺序等待。
有界活跃窗口
任意时刻只保留最多 k 个已提交且尚未回收结果的任务,避免一次把全部输入塞进队列。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

Semaphore.acquire成功才意味着拿到了许可,必须只在此后安排对应release。若acquire被中断却仍无条件release,许可总量会逐渐超过初始限制。Semaphore没有锁那样的线程所有权检查,所以另一个线程可以释放许可,灵活性也意味着应用更需要维护“获取几次、归还几次”的不变量。

BlockingQueue 会替调用方处理“队列满了怎么放、队列空了怎么取”。put 和 take 在条件不满足时等待;offer 和 poll 可以立即返回,也有带超时的版本。队列没有统一的关闭按钮,需要自己约定结束方式。

“毒丸”就是一种特殊消息,消费者读到它便按约定退出。多个消费者时,只放一条通常只能被其中一个取走;还要考虑生产者是否仍在提交,以及结束消息能否成功入队。关闭标志或上层生命周期管理也需要同样明确的退出规则。

ExecutorCompletionService把执行与结果回收分开:提交返回Future,完成后的Future进入完成队列。协调者take得到当前任意已完成任务,能及时释放窗口位置,不必因为输入第一项很慢而暂时忽略后面已完成项。保留原索引即可同时实现按完成顺序调度和按输入顺序输出。

正在加载流程图…

活跃窗口随完成事件推进;取消是请求,底层任务仍必须协作。
查看流程图源码(Mermaid)
flowchart TD
 I["输入任务与并发上限k"] --> S["先提交至多k项"]
 S --> W["等待一个完成Future"]
 W --> R["读取结果并写入原索引"]
 R --> N{"还有待提交任务"}
 N -->|有| A["补充一个任务"]
 A --> W
 N -->|无且仍有活跃任务| W
 N -->|全部完成| D["返回有序结果"]
 W -->|中断或失败| C["取消剩余任务并清理执行器"]

按顺序看,每一步做了什么

  1. 选择对应业务条件的同步工具,而非仅按是否阻塞选型。
  2. 明确许可、任务和结果的生命周期及归还责任。
  3. 限制已提交任务数量,完成一个再补充一个。
  4. 等待路径传播中断,失败路径取消剩余工作。
  5. 通过结果、活跃峰值和清理状态验证协议。

03对照实现代码

Semaphore的许可递减与失败重试 (新标签页)↗

OpenJDK17.0.12+7 · Semaphore.Sync#nonfairTryAcquireShared / tryReleaseShared

原创公开API用法模型。真实源码使用AQS state与CAS维护许可,等待队列和中断由同步器处理;此代码重点展示获取成功后才归还的资源协议。

核心机制代码 · java
import java.util.concurrent.Semaphore;

final class PermitScopeModel {
    static void run(Semaphore permits, Runnable work) throws InterruptedException {
        permits.acquire(); // 如果此处被中断,后续try尚未进入,也不会错误归还。
        try {
            work.run(); // 只有取得许可才进入受限区域。
        } finally {
            permits.release(); // 正常或异常退出均恰好归还本次许可。
        }
    }
}

非公平获取先读可用许可,减去所需数量;不够就报告失败,够则用 CAS 保存剩余数量,竞争失败再重试。同步器根据这个结果决定直接通过还是入队等待。许可只是“还能放几个人进去”的计数,不负责管理真正的数据库连接等对象。

释放路径把许可加回state并检查溢出,再通过共享释放机制让等待者有机会继续。源码不会自动验证释放线程是否曾经获取许可,也不会替应用识别重复归还同一连接。实际资源池需要独立维护对象是否已借出及是否已归还,不能只依赖一个Semaphore计数。

公平获取会查看是否已有排队者,但无超时 tryAcquire 不遵守相同公平规则。公平也不保证严格按用户代码开始调用的时间完成,线程可能在不同步骤被调度。每次所需许可数量不同时,大请求还可能挡住后面本来能满足的小请求。

acquire响应中断时抛InterruptedException并清除相应标记;方法签名允许时应继续抛给上层决定取消。如果必须在Runnable边界处理,常见方式是恢复中断标记后退出,而不是捕获后继续无限重试。不可中断获取只适合经过认真设计的边界,不能为了少写catch而替换。

代码先调用谁,再调用谁

  1. 业务调用acquire请求许可
  2. AQS尝试共享获取并用CAS扣减state
  3. 许可不足时进入可中断等待
  4. 获取成功后执行受限工作
  5. finally归还许可并触发共享等待者重新尝试

04动手写一个例子

最多k个活跃任务的有序并行映射

对整数数组并行执行纯映射函数,最多保持k个已提交未回收任务,输出顺序与输入一致。任一任务失败或协调线程被中断时,取消剩余任务并传播错误;参数非法提前拒绝。

输入 1
[3,1,2],k=2,函数平方
输出 1
[9,1,4],与实际完成顺序无关
输入 2
[1,2,3],函数遇2抛异常
输出 2
传播ExecutionException并请求取消剩余任务

协调线程独占next、done与结果数组,每次提交任务时附带原始索引。初始只提交min(k,n)项,随后从完成队列take一项,回收其结果,再补充至多一项。没有共享写入游标的多个调度者,因此窗口管理无需额外锁,工作线程只返回不可变Indexed值。

每次取到完成Future后先从活跃集合移除,再get读取结果;若get抛出异常,finally会取消仍在集合中的任务并关闭执行器。失败任务本身已经完成,不需要重复取消。取消与任务实际结束之间可能有距离,映射函数应短小或主动检查中断,不能在其中隐藏不可取消的无限工作。

归纳证明窗口上界:初始提交不超过k;每次补充前已经回收一个,所以活跃数量不会超过旧上界。每个索引只提交一次、完成后只写自己的结果位置,因此输出不受调度顺序影响。空输入直接返回,不为没有工作创建线程池。

为什么这样写是对的活跃Future数量始终不超过k;每个输入索引最多提交一次,已回收结果准确写入对应索引。

时间 调度与回收O(n),另加映射计算成本额外空间 活跃任务O(k),输入快照与输出O(n)

每个任务提交和回收各一次,HashSet操作通常均摊O(1);线程调度、函数耗时与竞争不包含在纯算法操作数中。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 60 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.function.IntUnaryOperator;

public class Main {
    record Indexed(int index,int value) {}
    static int[] map(int[] input,int k,IntUnaryOperator function)
            throws InterruptedException,ExecutionException {
        Objects.requireNonNull(input); Objects.requireNonNull(function);
        if(k<=0) throw new IllegalArgumentException();
        if(Thread.interrupted()) throw new InterruptedException();
        int[] data=input.clone(), result=new int[data.length];
        if(data.length==0) return result;
        ExecutorService executor=Executors.newFixedThreadPool(Math.min(k,data.length));
        CompletionService<Indexed> completed=new ExecutorCompletionService<>(executor);
        Set<Future<Indexed>> active=new HashSet<>();
        int next=0, done=0;
        try {
            while(next<data.length && active.size()<k) {
                int index=next++;
                active.add(completed.submit(() -> new Indexed(index,function.applyAsInt(data[index]))));
            }
            while(done<data.length) {
                Future<Indexed> future=completed.take(); // 按完成顺序回收,及时腾出窗口。
                active.remove(future);
                Indexed value=future.get(); result[value.index()]=value.value(); done++;
                if(next<data.length) {
                    int index=next++;
                    active.add(completed.submit(() -> new Indexed(index,function.applyAsInt(data[index]))));
                    // 每回收一项最多补一项,保持活跃任务不超过k。
                }
            }
            return result;
        } finally {
            for(Future<Indexed> future:active) future.cancel(true); // 失败或中断时发出协作取消。
            executor.shutdownNow();
        }
    }
    static void check(boolean ok) { if(!ok) throw new AssertionError(); }
    public static void main(String[] args) throws Exception {
        check(Arrays.equals(map(new int[]{3,1,2},2,x->x*x),new int[]{9,1,4}));
        check(map(new int[0],3,x->x).length==0);
        check(Arrays.equals(map(new int[]{0,-2},1,x->x+1),new int[]{1,-1}));
        AtomicInteger active=new AtomicInteger(),peak=new AtomicInteger();
        int[] many=new int[100]; Arrays.fill(many,1);
        int[] output=map(many,3,x->{
            int now=active.incrementAndGet(); peak.accumulateAndGet(now,Math::max);
            try { return x*2; } finally { active.decrementAndGet(); }
        });
        check(Arrays.stream(output).sum()==200 && peak.get()<=3);
        try { map(new int[]{1,2,3},2,x->{if(x==2)throw new IllegalStateException();return x;}); throw new AssertionError(); }
        catch(ExecutionException expected) { check(expected.getCause() instanceof IllegalStateException); }
        Thread.currentThread().interrupt();
        try { map(new int[]{1},1,x->x); throw new AssertionError(); }
        catch(InterruptedException expected) { check(!Thread.currentThread().isInterrupted()); }
        finally { Thread.interrupted(); }
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 空输入不创建执行器。
  • k大于输入长度只创建所需工作者。
  • 失败结果保留真实异常原因。
  • 协调线程预先中断时不提交新任务。
进阶选读 · 阶段屏障与动态参与者

场景数据导入分为读取、校验、提交三阶段,每轮参与任务数不同,部分任务可能失败退出。

做法使用Phaser等阶段工具时明确注册、到达与注销责任,异常路径也必须释放参与者;将业务失败状态独立保存,并设置阶段超时和整体终止策略。避免尚未注册的任务与协调者提前推进阶段。

复杂度每个参与者每阶段常量次协调调用;实际争用与层级结构相关,大规模参与可采用分层Phaser减少单点争用。

好处与代价阶段工具适合明确会合点,但动态注册与失败清理更复杂;独立任务批处理通常用完成队列更直接。

05项目里怎么用

并发额度应贴近真正稀缺资源,例如数据库连接、远端允许并发或内存预算。线程数量不等于资源可用数量,拿着连接再等待另一种许可可能形成资源循环依赖。统一资源获取顺序,尽量在进入受限区域之前完成不依赖该资源的准备工作。

发出取消、任务真的结束、业务已经提交,是三个不同事实,应分别记录。阻塞 IO 是否响应中断由具体 API 决定,还要配合连接和读取超时。调用 Future.cancel 后,不能立即认定线程已停止、连接已归还或外部动作被撤销。

提交窗口适合大批量任务,也适合Agent工具批调用或向量检索分片查询。每完成一项再补充,可以自然限制在途请求;若结果消费者本身很慢,也应限制完成结果积压。输入流、活跃任务与输出缓冲都需要容量边界,才能形成完整的资源控制链。

容易踩的坑

  • acquire失败或被中断仍release,导致许可膨胀。
  • 吞掉InterruptedException后继续无限循环。
  • 只限制线程数却一次提交海量任务。
  • 一个毒丸无法正确关闭多个消费者。
  • 持有资源等待另一个许可形成循环依赖。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
Semaphore直接限制资源并发数量无所有权检查,易重复释放连接或远端调用额度
CountDownLatch一次性完成会合简单不能重置,失败状态需另外保存初始化或固定批次完成
CompletionService窗口及时回收完成任务并限制在途量协调者需处理失败和取消大批量独立并行任务
Phaser支持阶段推进与动态参与注册注销与失败协议更复杂多轮分阶段协作

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01Semaphore为什么不等价于资源池?

展开参考答案与推导

它只管理许可计数,不管理具体对象的借出、归还和健康状态,应用仍需维护对象生命周期。

02中断为什么需要协作?

展开参考答案与推导

它主要设置请求标记或让部分阻塞API抛异常,无法安全强制撤销任意计算与外部副作用。

03固定线程池为什么仍可能占用无界内存?

展开参考答案与推导

提交任务可积压在队列中,每个闭包还可能持有大对象,线程数只限制执行并发。

换个情况,还会处理吗

01系统运行越久允许并发越多,怀疑Semaphore哪里出错?

展开参考答案与推导

检查未成功acquire也release、重复归还和异常分支归还次数,按许可获取与释放事件核对不变量。

02取消批任务后仍有远端请求完成,是否一定是取消失效?

展开参考答案与推导

不一定,取消请求不保证底层IO终止,可能已提交;检查API取消能力、超时和最终业务结果。

03第一个任务很慢导致结果回收不及时怎么办?

展开参考答案与推导

用CompletionService按完成顺序回收,并以原索引保存输出;同时维护有界活跃窗口避免海量提交。

写代码试一试

01实现有界并行映射。

输入 / 条件
[3,1,2],k2,平方
输出 / 预期
[9,1,4]

提示 完成队列加原索引。

展开参考答案与推导

按正文窗口调度,失败与中断统一清理。

02测试许可在异常路径不泄漏。

输入 / 条件
初始许可1,工作抛异常
输出 / 预期
结束后可用许可仍1

提示 acquire在try之前成功完成。

展开参考答案与推导

分别测试正常、业务异常、获取前已中断三条路径,禁止在未获取时归还。

03设计两个消费者的有界队列关闭协议。

输入 / 条件
一个生产者结束,两个消费者等待
输出 / 预期
两个消费者最终都退出

提示 明确毒丸数量或共享关闭状态。

展开参考答案与推导

若用毒丸则保证每个消费者最终取得一个且生产已结束;处理投递中断和队列满,或采用显式可关闭队列。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能让生产者与消费者在有界队列上工作,并在中断后退出而不吞掉取消信号。

本篇重点加练

有界队列只限制局部缓冲,取消协议还要决定已接纳任务、生产者与消费者怎样收敛。

  • 有界 BlockingQueue
  • 取消协议

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    工作线程先收到中断,再执行阻塞等待;另一个版本在捕获异常后继续执行。逐步跟踪标志读取、清除和传播责任。

    提交内容
    中断状态表
    怎样算完成
    • 标明使用的具体 API
    • 区分查询标志和清除标志

    练到的知识点中断标志 · InterruptedException · 保留中断

  2. 作业 02原理问答

    三个文件扫描器产出结果,一个汇总器消费结果。比较完成计数与有界传输各自表达的事件,并讨论某扫描器提前失败。

    提交内容
    协作图与结束条件
    怎样算完成
    • 区分生产结束和消费结束
    • 覆盖无结果任务

    练到的知识点CountDownLatch · 有界 BlockingQueue

  3. 作业 03原理问答

    处理链有三个有界队列,每阶段可在队列外预取一批数据。估算总体在途数据上界,并列出使估算失效的隐藏缓冲。

    提交内容
    资源上界与假设清单
    怎样算完成
    • 包含正在处理和预取
    • 容量单位统一

    练到的知识点有界 BlockingQueue

  4. 作业 04代码推演

    任务取消发生在入队成功与调用者获得回执之间。画出调用者、生产者及消费者的局部状态,定义哪些任务仍有处理资格。

    提交内容
    接纳与取消时间线
    怎样算完成
    • 区分取消请求与已确认取消
    • 结果未知有独立状态

    练到的知识点取消协议

综合应用

  1. 作业 05设计评审

    用户取消批次后要求 500 毫秒内返回控制权,但个别计算暂时不可中断。定义总期限、等待期限和任务最终状态。

    提交内容
    取消契约与时间预算
    怎样算完成
    • 包含部分完成结果
    • 不承诺无法观测的外部撤销

    练到的知识点超时等待 · 取消协议

  2. 作业 06代码推演

    任务依次取得许可、打开文件、向队列提交,再等待汇总。分别在四步之间抛异常,列出每个路径需释放的资源及责任人。

    提交内容
    异常路径与资源表
    怎样算完成
    • 避免未获得就释放
    • 覆盖清理本身失败

    练到的知识点finally 清理

  3. 作业 07编程实践

    实现只处理临时目录文本的本地批扫描器,限制队列和工作者数,支持取消及单文件失败,最后输出每个文件的处理状态。

    提交内容
    源码、测试文件与资源记录
    怎样算完成
    • 实验结束无线程残留
    • 取消和异常都可重放

    练到的知识点中断标志 · InterruptedException · 保留中断 · CountDownLatch · 有界 BlockingQueue · 超时等待 · 取消协议 · finally 清理

  4. 作业 08设计评审

    一个消费者处理失败后退出,其他消费者还在工作,生产端准备发送停止信号。评审停止信号数量、顺序和队列满时的行为。

    提交内容
    结束协议审查
    怎样算完成
    • 包含未消费任务
    • 停止通知本身不能无限等待

    练到的知识点有界 BlockingQueue · 取消协议

  5. 作业 09编程实践

    写本地三阶段流水线,任务只转换小字符串,限制各阶段缓冲并注入中途取消;统计接纳、完成、取消及未决任务。

    提交内容
    程序和收敛报告
    怎样算完成
    • 所有线程最终结束或明确超时报错
    • 账本覆盖阶段间在途任务

    练到的知识点有界 BlockingQueue · 取消协议

进阶挑战 · 掌握前置后选做

  1. 作业 10面试场景

    一个消费任务退出后生产者全部阻塞,主线程又在等完成门闩。设计故障传播协议并提出可证明所有参与者能退出的验收场景。

    提交内容
    等待关系图与验收表
    怎样算完成
    • 覆盖多个退出顺序
    • 每种等待都有唤醒或到期机制的说明

    练到的知识点保留中断 · 有界 BlockingQueue · 超时等待 · 取消协议 · finally 清理

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 跨域迁移

    把取消协议迁移到离线文件同步,某文件已写出部分内容时才取消。设计“取消完成”对接收端文件可见性的含义。

    提交内容
    跨域取消契约
    怎样算完成
    • 区分停止计算和撤回已交付内容
    • 包含重新启动后的状态

    练到的知识点有界 BlockingQueue · 取消协议

  2. 作业 12思维拓展 · 反例构造

    构造所有队列容量均很小、线程数也固定,但系统仍无限积累待处理数据的案例,要求额外缓冲来源清晰。

    提交内容
    反例资源图
    怎样算完成
    • 不能靠增加队列容量破坏前提
    • 标明未计入的对象持有者

    练到的知识点有界 BlockingQueue · 取消协议

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗