父子任务共用线程池导致死锁的核心原因是资源竞争与循环等待。当父任务在执行过程中提交子任务到同一线程池,并等待子任务完成时,如果线程池的线程资源被父任务完全占用,子任务无法获取线程执行,父任务又因等待子任务结果而无法释放线程,最终形成死锁。

原因分析

  1. 线程池资源竞争
    父任务占用了线程池的所有线程,子任务因无可用线程而被放入队列等待。
  2. 父任务等待子任务结果
    父任务通过 Future.get() 等方法阻塞等待子任务完成,而子任务无法执行(因线程被父任务占用)。
  3. 循环依赖
    子任务需要父任务释放线程才能执行,父任务需要子任务完成才能释放线程,形成死锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.*;

public class DeadlockDemo {
// 创建固定大小为2的线程池(关键点:线程池过小)
private static final ExecutorService executor = Executors.newFixedThreadPool(2);

public static void main(String[] args) {
// 提交父任务
executor.submit(() -> parentTask("Parent-1"));
executor.submit(() -> parentTask("Parent-2"));
}

static void parentTask(String name) {
System.out.println(name + " 开始执行");

// 提交子任务,异步
Future<?> childFuture = executor.submit(() -> {
System.out.println(name + " 的子任务开始执行");
try {
Thread.sleep(100); // 模拟工作
} catch (InterruptedException ignored) {}
System.out.println(name + " 的子任务结束");
});

try {
// 父任务阻塞等待子任务完成(致命点)
childFuture.get();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(name + " 结束");
}
}

过程分析

假设线程池大小为2(如示例所示):

  1. 初始状态
    • Parent-1 占用线程1
    • Parent-2 占用线程2
  2. 父任务提交子任务
    • Parent-1 提交 Child-1 → 进入任务队列
    • Parent-2 提交 Child-2 → 进入任务队列
  3. 父任务等待子任务
    • Parent-1 调用 childFuture.get()阻塞等待 Child-1 执行
    • Parent-2 调用 childFuture.get()阻塞等待 Child-2 执行
  4. 死锁形成
    • 线程池中所有线程(线程1、2)都被父任务占用且处于阻塞状态
    • 子任务(Child-1, Child-2)在队列中等待空闲线程
    • 但父任务不释放线程 → 子任务永远得不到执行 → 父任务永远等不到结果

这种情况下,只有当工作队列是有界的,且父任务在阻塞前提交多个子任务使工作队列被占满,从而创建新线程才有可能让死锁解开。(有一种父子任务共用线程池不会出现死锁的情况——队列是 SynchronousQueue 这种无存储功能的)

在这种父子共用线程池的场景,建议有一个兜底的操作——在父任务在等待子任务结果时设置一个超时时间。虽然这个不能完全避免死锁的出现,但是可以避免永久死锁。

1
2
3
4
5
6
7
8
9
10
try {
childFuture.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
// 1. 取消卡住的子任务
childFuture.cancel(true);
// 2. 记录错误/告警
log.error("子任务执行超时", e);
// 3. 执行备用方案
fallbackStrategy();
}

那怎么完全避免这种死锁呢:

  1. 父任务和子任务用独立线程池;
  2. 避免阻塞等待,使用 CompletableFuture 异步回调