Java并发(六):并发工具类、死锁与进阶
Java并发(六):并发工具类、死锁与进阶
导语:本篇收尾,三条线各自独立成节——AQS 衍生的三大同步工具(CountDownLatch / CyclicBarrier / Semaphore)、死锁的四个必要条件与排查手段,以及异步编排与线程演进(Future 局限、CompletableFuture、ForkJoinPool、虚拟线程)。共 12 题。
一、并发工具类
1. CountDownLatch 的原理与使用场景?
答: CountDownLatch 是一个倒计时门闩,基于 AQS 的共享模式实现:
- 构造时指定计数
N,AQS 的state即剩余计数; - 主线程调用
await()阻塞(把当前线程挂到 AQS 等待队列); - 其他线程完成任务后调用
countDown(),即state减一(共享模式释放,会依次唤醒队列上的等待线程); - 计数归零时,
await()返回,所有等待线程放行。
关键特性:一次性——计数归零后无法重置,也不能重复使用。
典型场景:
- 主线程等待 N 个并发任务(初始化、批量加载)全部完成后汇总结果;
- 模拟高并发压测:用一个 latch 让所有线程同时开始(
await()作为发令枪)。
2. CyclicBarrier 的原理与使用场景?
答: CyclicBarrier 是循环屏障:让一组线程各自到达屏障点(调用 await())后互相等待,直到所有线程都到达才一起放行。
- 底层:
ReentrantLock+Condition,并用"代际(generation)"概念支持循环; - 可重置复用:所有线程通过屏障后,屏障自动重置,可直接用于下一轮(也可手动
reset()); - 屏障动作:
new CyclicBarrier(N, barrierAction)中的barrierAction会在所有线程到达后、放行前由最后一个到达的线程执行一次。
典型场景:多阶段并行计算——每阶段所有线程到齐后再统一进入下一阶段(如多线程分片计算后统一汇总再开始下一轮)。
3. Semaphore 的原理与使用场景?
答: Semaphore(信号量)用于控制同时访问某资源的线程数量,基于 AQS 共享模式:
new Semaphore(N)表示有 N 个许可,AQS 的state就是剩余许可数;acquire():获取许可,无许可则阻塞(可中断、可超时);release():释放许可,唤醒等待线程;- 支持公平/非公平(
new Semaphore(N, fair))。
与锁的区别:锁保证互斥(并发度 = 1),信号量只限制并发度(并发度 = 许可数),并不保证互斥——许可数为 3 时允许 3 个线程同时进入。
典型场景:
- 限流:限制同时访问数据库连接、下游接口的并发数;
- 资源池:管理有限数量的连接/对象,用完后归还(
release())。
4. CountDownLatch 与 CyclicBarrier 的区别?
答:
| 维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 可复用性 | 一次性,计数归零即失效 | 可循环重置,支持多轮 |
| 角色关系 | 不对等:一方 await() 等待,其他方 countDown() 计数 | 对等:所有线程都是参与者,各自 await() 等到齐 |
| 触发条件 | 计数减到 0 | 到达线程数达到 N |
| 等待方与计数方 | 可以是不同线程(主线程等 N 个工作任务) | 必须是同一组线程(互相等) |
| 底层实现 | AQS 共享模式(state 递减) | ReentrantLock + Condition + 代际 |
| 额外能力 | 无 | 支持屏障动作 barrierAction |
| 异常语义 | 计数不受影响 | 某个线程出错会导致屏障破损(BrokenBarrierException) |
一句话区分:CountDownLatch 是"等别人干完活我就走",CyclicBarrier 是"大家都到齐了才一起走"。
二、死锁
5. 什么是死锁?死锁的四个必要条件是什么?
答: 死锁指两个或以上线程互相持有对方所需的锁并相互等待,导致所有相关线程永久阻塞、无法推进。
四个必要条件(Coffman 条件,必须同时满足才会死锁):
| 条件 | 含义 |
|---|---|
| 互斥 | 资源同一时刻只能被一个线程占用 |
| 占有并等待 | 线程已持有至少一个资源,同时又在等待其他被占用的资源 |
| 不可剥夺 | 已被持有的资源不能被其他线程强行抢占,只能由持有者主动释放 |
| 循环等待 | 存在一个线程与资源的环形等待链(T1 等 T2 持有,T2 等 T1 持有) |
记忆技巧:"互斥、占有等待、不可剥夺、循环等待"——破坏其中任意一条即可避免死锁,这也是各种解决方案的理论依据。
6. 如何避免和排查死锁?
答:
1)预防——破坏四个必要条件之一
| 破坏目标 | 手段 |
|---|---|
| 循环等待(最实用) | 统一加锁顺序:所有线程按同一全局顺序(如按对象 ID 排序)获取锁 |
| 占有并等待 | 一次性申请全部资源,或申请失败时释放已持有的资源 |
| 不可剥夺 | 使用定时锁 tryLock(timeout),超时后释放已持有的锁并退避重试 |
| 互斥 | 尽量用无锁方案(CAS、不可变对象、并发容器)减少锁的使用 |
配套原则:
- 缩小锁粒度、缩短持锁时间;
- 开放调用:调用外部方法(尤其是可控性差的第三方 API)时不要持有锁;
- 避免嵌套锁,锁内不要做耗时 IO。
2)避免——银行家算法
"避免"与"预防"不同:它在资源分配前用银行家算法判断——假设分配后,系统是否仍处于安全状态(存在某种线程推进序列,使所有线程都能依次拿到所需资源并完成)。仅当安全才分配,否则让线程等待。该算法理论优美但实际系统极少使用,属于经典考点。
3)排查
| 工具 | 用法 |
|---|---|
jstack <pid> | 导出线程栈,搜索 Found one Java-level deadlock 与 BLOCKED 环,会直接给出"哪个线程在等哪把锁、被谁持有" |
jconsole / VisualVM | 图形化"检测死锁"按钮 |
Arthas | thread -b 一键找出阻塞其他线程的线程 |
| JFR | 事件流式记录 jdk.JavaMonitorEnter,适合生产低开销诊断 |
注意
jstack会触发安全点(safepoint)、可能短暂停顿应用,生产环境优先考虑 JFR 或 Arthas 的轻量命令。
7. 请写一个死锁示例。
答: 两个线程以相反顺序获取同一组锁,即形成循环等待:
Object lockA = new Object();
Object lockB = new Object();
new Thread(() -> {
synchronized (lockA) {
sleep(100); // 放大时序窗口,让冲突必然发生
synchronized (lockB) { } // 持有 A 等 B
}
}, "t1").start();
new Thread(() -> {
synchronized (lockB) {
sleep(100);
synchronized (lockA) { } // 持有 B 等 A —— 与 t1 形成环
}
}, "t2").start();解决方法:让两个线程都按 先 A 后 B 的相同顺序加锁,即可消除"循环等待"条件。
面试延伸:如果想写得更"生产化",可以改成用
tryLock(timeout):拿不到第二把锁就释放第一把并重试,破坏"不可剥夺"条件,避免真的卡死。
8. 死锁、活锁、饥饿的区别?
答:
| 概念 | 现象 | 本质 | 例子 |
|---|---|---|---|
| 死锁 | 所有相关线程全部停滞,互相等待 | 循环等待,谁都无法推进 | 上述 A/B 锁示例 |
| 活锁 | 线程一直在运行(不断重试/回退),但整体毫无进展 | 都在"礼让",状态反复回到原点 | 两人在窄路相遇,同时向同一边让路,又同时走向另一边 |
| 饥饿 | 个别线程长期拿不到资源,但系统整体在推进 | 资源分配不公平 | 非公平锁下某线程持续被插队;低优先级线程被高优先级长期压制 |
解决思路:
- 死锁 → 破坏四个必要条件(见第 6 题);
- 活锁 → 引入随机退避(错开重试时机),避免步调一致的"礼让";
- 饥饿 → 使用公平锁、避免无限期长任务独占、设置优先级或配额。
三、并发进阶
9. Future 的局限是什么?FutureTask 的原理是什么?
答:
Future 表示一个异步计算的结果,提供 get()(阻塞获取)、get(timeout)、cancel()、isDone()。
FutureTask 是 Future 的标准实现,同时实现了 Runnable,因此可以直接交给 Thread 或线程池执行:
FutureTask<Integer> task = new FutureTask<>(() -> 1 + 1);
new Thread(task).start();
Integer result = task.get(); // 阻塞直到完成原理:内部用 AQS 的 state 表示任务状态机(NEW → COMPLETING → NORMAL / EXCEPTIONAL / CANCELLED / INTERRUPTED),并用一个 waiters 单向链表保存等待结果的线程。run() 执行完成后设置结果并唤醒等待者;get() 时若未完成则入队 park() 等待。
Future 的三大局限(这正是引入 CompletableFuture 的原因):
- 只能阻塞获取:
get()会卡住调用线程,没有"完成后自动回调"的能力; - 无法链式编排:多个异步任务之间无法形成依赖流水线,只能手写代码串接;
- 缺乏组合能力:没有
allOf/anyOf这类多任务汇总工具; - 异常处理弱:任务异常被包成
ExecutionException,只能在get()时捕获。
10. CompletableFuture 是什么?为什么需要它?
答: CompletableFuture(JDK 8)同时实现了 Future 与 CompletionStage,是 Java 异步编排的标准工具,正是为了解决 Future 的上述局限。
| 能力 | 方法 | 说明 |
|---|---|---|
| 创建 | supplyAsync / runAsync | 有 / 无返回值,可指定 Executor |
| 转换 | thenApply / thenAccept / thenRun | 加工结果 / 消费结果 / 不关心结果只做后续动作 |
| 串行依赖 | thenCompose | 把"返回 Future 的函数"扁平化串联,避免嵌套 |
| 合并独立任务 | thenCombine | 组合两个无依赖异步任务的结果 |
| 多任务汇总 | allOf / anyOf | 全部完成 / 任一完成 |
| 异常处理 | exceptionally / handle / whenComplete | 兜底值 / 有返回值的统一处理 / 只做收尾不改结果 |
CompletableFuture
.supplyAsync(() -> queryUser(id)) // 异步查用户
.thenCompose(u -> queryOrderAsync(u)) // 依赖用户结果,异步查订单
.thenApply(order -> order.getAmount()) // 转换为金额
.exceptionally(e -> 0L) // 异常兜底
.thenAccept(System.out::println); // 消费最终结果必知的坑:xxxAsync 系列方法若不显式传入 Executor,会使用 ForkJoinPool.commonPool(),而它的并行度默认只有 CPU 核数 - 1。若在其中执行阻塞式 IO,会占满 commonPool,拖垮同一 JVM 内所有使用并行流与默认 CompletableFuture 的代码。
结论:IO 密集型任务必须传入自定义线程池(
xxxAsync(..., executor)),让异步编排与业务线程池解耦。
11. ForkJoinPool 与工作窃取(Work-Stealing)是什么?
答: ForkJoinPool(JDK 7)是为分治(Divide and Conquer)并行计算设计的线程池,核心算法是工作窃取。
分治模型:把大任务递归 fork() 拆成小任务,再用 join() 合并结果(RecursiveTask<T> 有返回值,RecursiveAction 无返回值)。
工作窃取机制:
- 每个工作线程维护自己的双端队列(deque);
- 自己产生的任务从队头取(LIFO)——优先执行刚拆分的子任务,利于递归收敛与 CPU 缓存局部性;
- 空闲线程从其他线程的队尾"窃取"(FIFO)——偷最"老"的任务,减少与所有者线程的竞争。
与传统线程池的区别:
| 维度 | ThreadPoolExecutor | ForkJoinPool |
|---|---|---|
| 任务队列 | 全局共享一个阻塞队列 | 每个线程各自一个双端队列 |
| 任务关系 | 彼此独立 | 可递归拆分与合并 |
| 窃取机制 | 无 | 有(空闲线程主动偷任务) |
| 适用 | 通用异步任务 | CPU 密集型、可分解的并行计算 |
注意事项:
- 适合 CPU 密集型;任务中若有阻塞 IO,会占着工作线程不放(需用
ManagedBlocker告知框架); - 并行流
parallelStream()底层就是ForkJoinPool.commonPool(),并行度默认为 CPU 核数 - 1——绝不要在 commonPool 里跑阻塞任务,否则会影响 JVM 内所有并行流; - 自定义时可通过
ForkJoinPool构造参数指定并行度与工作线程工厂。
12. 虚拟线程(Virtual Threads)是什么?与传统线程有何区别?
答: 虚拟线程是 JDK 21 正式引入(JEP 444,JDK 19/20 预览,Project Loom)的轻量级用户态线程。
| 维度 | 平台线程(传统 Thread) | 虚拟线程 |
|---|---|---|
| 映射关系 | 1:1 映射 OS 线程 | M:N 映射到少量载体(carrier)线程 |
| 调度者 | 操作系统 | JVM |
| 创建成本 | 高(默认栈约 1MB),几千个即瓶颈 | 极低(按需增长的小栈),可轻松创建百万级 |
| 阻塞代价 | 阻塞即挂起 OS 线程,代价高 | 阻塞时自动卸载(unmount) 并让出载体线程 |
| 是否需要池化 | 需要(创建昂贵) | 不需要(应每任务一个) |
| 适用场景 | 通用 | IO 密集型、大量并发等待 |
关键特性:
- 阻塞式 IO 会自动卸载:虚拟线程调用阻塞 API(
Socket、同步 JDBC 等)时,JVM 会把它从载体线程上摘下,让载体线程去服务其他虚拟线程,IO 完成后再挂载回来——这是吞吐量提升的根本原因; - CPU 密集型无收益:仍需平台线程真正并行,反而增加调度开销;
- 不要池化:直接
Thread.startVirtualThread(task)或Executors.newVirtualThreadPerTaskExecutor(),每个任务new一个即可; - 慎用
ThreadLocal:百万级虚拟线程会放大ThreadLocal的内存占用,JDK 正在用ScopedValue作为替代方向。
版本修正(重要):JDK 21 中,虚拟线程在
synchronized块内阻塞会"钉住(pin)"载体线程,导致该载体线程无法服务其他虚拟线程——当时因此建议热点synchronized改用ReentrantLock。但 JDK 24 的 JEP 491(Synchronize Virtual Threads without Pinning)已解决该问题,
synchronized阻塞时也能正常卸载虚拟线程。因此:
- 若被问到"虚拟线程的坑",标准答法是"JDK 21 的
synchronizedpinning 问题,JDK 24 已通过 JEP 491 修复";- 若仍回答"必须把
synchronized换成ReentrantLock",务必补上 JDK 版本前提,否则在 JDK 24+ 上已不成立;- 另需注意:native 方法/JNI 中的阻塞仍会钉住载体线程,这一点 JEP 491 未覆盖。
