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);

总结

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

相关推荐
秋天的怀念_David9 分钟前
算法一千零一夜 · 第十四夜 幻方与奇妙的二进制
java·程序人生·算法
thefool11226613 分钟前
网络编程^
java
Thinker QAQ1 小时前
并发编程(十一):Shared Memory 与 Message Passing——关系与组合使用
java·python·go·并发编程·shared memory·message passing
齐 飞1 小时前
idea中git误操作撤回git reset --hard ‘HEAD@{1}‘
java·git·intellij-idea
你的保护色1 小时前
多AS互联BGP4综合实验设计
java
某不知名網友2 小时前
ROS2进阶:服务通信、自定义SRV接口、参数服务器与基于服务的参数通信详解
java·开发语言·microsoft
Wang's Blog2 小时前
Java 项目实战: 交友项目 - MongoDB 入门:体系结构和命令行操作
java·mongodb·交友
小溪学编程2 小时前
将 Bean 放入 Spring 容器中的方式有哪些?
java·后端·spring
java_nnnn2 小时前
LeetCode刷题滑动窗口(水果成篮+找到字符串中所有字母的异位词)Java
java·开发语言·leetcode
用户3721574261352 小时前
Java 读取、修改和清空 PDF 文档属性(元数据)
java