Java故障案例分析第一期:父子任务使用不当线程池死锁

引言

在Java多线程编程中,线程池是提高性能和资源利用率的常用工具。然而,当父子任务使用同一线程池时,可能导致潜在的死锁问题。本文将深入分析一个实际案例,阐述为何这种设计可能引发死锁,以及如何排查这类问题。

案例背景

考虑以下的伪代码,展示了一个可能导致死锁的场景:

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

class Scratch {
    private static final ExecutorService pool1 = Executors.newFixedThreadPool(2);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 50; i++) {
            pool1.submit(() -> {
                // 一些任务逻辑
                outerTask();
            });
        }
        try {
            boolean allDone = pool1.awaitTermination(10000, TimeUnit.MILLISECONDS);
            if (allDone) {
                System.out.println("任务完成!");
            } else {
                System.err.println("任务超时未完成!");
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    private static void outerTask() {
        Future<?> future = pool1.submit(() -> {
            innerTask();
        });
        try {
            // 获取结果
            future.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void innerTask() {
        // 一些任务逻辑
    }
}

简单解释下这个代码, 我们有一个固定线程数大小为2的线程池, 然后向线程池提交任务, 这个任务直接调用outerTask, 这个outerTask不做任何事情, 只通过线程池异步调用innerTask, 但是注意这里使用了同一个线程池提交innerTask.

最后通过awaitTermination等待线程池执行完毕线程终止就结束, 设置了超时10s, 如果任务都完成了打印"任务完成"否则打印"任务超时未完成", 而由于outerTask和innerTask内部都没有其他逻辑, 理论上应该是很快执行完毕, 打印"任务完成", 但实际如何呢, 执行一下, 结果是:

复制代码
任务超时未完成!

好, 这是肯定的😳. 那我们分析下为什么? 这是一个线程故障因此首先想到通过jstack打印堆栈分析:

看到的线程调用栈为:

php 复制代码
"pool-1-thread-1@852" tid=0x19 nid=NA waiting
  java.lang.Thread.State: WAITING
	  at jdk.internal.misc.Unsafe.park(Unsafe.java:-1)
	  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:221)
	  at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:500)
	  at java.util.concurrent.FutureTask.get(FutureTask.java:190)
	  at Scratch.outerTask(scratch_18.java:32) // 注意这里
	  at Scratch.lambda$main$0(scratch_18.java:11)
	  at Scratch$$Lambda$14/0x00000008010029f0.run(Unknown Source:-1)
	  at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:577)
	  at java.util.concurrent.FutureTask.run$$$capture(FutureTask.java:317)
	  at java.util.concurrent.FutureTask.run(FutureTask.java:-1)
	  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	  at java.lang.Thread.run(Thread.java:1589)

可以看到大量pool-1-thread-1开头线程阻塞在了outerTask提交任务的地方, 同时通过查看线程池的workQueue对象可以看到有很多任务堆积:

原因分析

子任务需要等待父任务完成,而父任务内部的子任务通过同一个线程池提交,又需要等待线程池有空闲线程才能得到执行,但父任务需要等待子任务执行完才能执行完毕释放出空闲线程, 陷入了"死锁"。

但在测试环境中可能无法发现,只要线程池线程数量够多,测试环境的并发请求数不够是发现不了这个问题的,只有并发请求数量足够才可能触发而这往往是上到生产环境才可能发生了,通常会造成严重事故,重启或者扩容后在一定时间内看上去恢复正常了但过不久可能又会出现阻塞情况(在我的公司实际发生过这种故障,开发不停重启和扩容但过一段时间仍然会发生这个问题,排查了很长时间才发现问题原因)

解决方案

为避免父子任务使用同一线程池造成死锁,可以考虑使用独立线程池:将父任务和子任务分别提交到不同的线程池,避免共享线程池资源,减少死锁的可能性。

java 复制代码
private static final ExecutorService parentPool = Executors.newFixedThreadPool(1);
private static final ExecutorService childPool = Executors.newFixedThreadPool(1);

总结

作为第一篇文章,这个故障实际非常基础,但却十分值得注意,因为这个故障很常见而且容易被误导为机器数量不够导致重启或扩容后依然无法恢复。

相关推荐
小bo波2 小时前
使用Thread子类创建线程 VS 使用Runnable接口创建线程的区别
java·多线程·thread·并发编程·runnable
SamDeepThinking3 小时前
高并发场景下,CompletableFuture与ForkJoinPool该如何取舍?
java·后端·面试
张不才6 小时前
CPU 100% 了怎么办?Java 性能排障的标准化操作
java·后端
shepherd1117 小时前
吞吐量提升 10 倍:高并发大批量数据处理任务的架构演进与性能调优
java·后端·架构
plainGeekDev10 小时前
单例模式 → object 声明
android·java·kotlin
用户2986985301411 小时前
Java 实现 Word 文档文本与图片提取的方法
java·后端
SimonKing12 小时前
铁子,IntelliJ IDEA 2026.1.3来了,升不升?
java·后端·程序员
咖啡八杯1 天前
GoF设计模式——策略模式
java·后端·spring·设计模式
用户128526116021 天前
我把祖传Java项目重构后,接口响应从3s砍到了200ms,只改了这几行代码
java