头图

引言

在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打印堆栈分析:

看到的线程调用栈为:

"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对象可以看到有很多任务堆积:

image.png

原因分析

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

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

解决方案

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

private static final ExecutorService parentPool = Executors.newFixedThreadPool(1);
private static final ExecutorService childPool = Executors.newFixedThreadPool(1);

总结

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


烈香
1 声望0 粉丝

专攻Redis的程序员,同时也在研究eBPF,有一个开源项目Kyanos:[链接] 帮助你快速排查应用网络问题。