线程饥饿锁

发布时间 2023-09-18 16:39:29作者: ~鲨鱼辣椒~

故障描述

为提高系统吞吐量,优化接口的响应速度,让页面响应时间更短,将某个聚合接口的多个串行调用更改为异步并行的方式

上线后,不到一会出现大量的线程池资源耗尽的异常告警,异常日志

Exception in thread "main" java.util.concurrent.ExecutionException: 
java.util.concurrent.RejectedExecutionException: 
Task java.util.concurrent.FutureTask@42936575
[Not completed, task = xxxxx] rejected from 
java.util.concurrent.ThreadPoolExecutor@33f18ac
[Running, pool size = X, active threads = X, queued tasks = N,
 completed tasks = M]

大量任务被拒绝,原因是线程池和队列均已被耗尽,看到该异常第一反应是线程池的最大线程数和队列设置太小,但是仔细分析发现业务代码写的有问题,线程池提交的任务存在相互依赖

在大小有限的线程池中,执行有相互依赖的任务,可能产生死锁

故障代码

为复现问题,定义一个有限大小的线程池,线程池大小:2,队列大小:1,拒绝策略:AbortPolicy


private static final ExecutorService poolExecutor = new ThreadPoolExecutor(2, 2,
            0L, TimeUnit.MILLISECONDS,
            new ArrayBlockingQueue<>(1),
            new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build(),
            new ThreadPoolExecutor.AbortPolicy() {
                @Override
                public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
                    log.error("rejectedExecution");
                    super.rejectedExecution(r, e);
                }
            }
    );

模拟有问题的业务代码,任务有依赖且等待执行结果:

public static void main(String[] args) throws ExecutionException, InterruptedException {
    Future<Object> futureA = poolExecutor.submit(() -> {
        Future<Object> future = poolExecutor.submit(() -> null);
        return future.get();
    });
    Future<Object> futureB = poolExecutor.submit(() -> {
        Future<Object> future = poolExecutor.submit(() -> null);
        return future.get();
    });

    futureA.get();
    futureB.get();

    poolExecutor.shutdown();
}

以上代码的执行顺序如下所示

image-20230918162143249

提交到线程池的任务如果相互依赖,多个任务也被同一线程池调度执行,A任务在等待B任务完成的同时,占用的线程不会结束,如果流量足够,线程池里的线程都被A任务占用完而不会结束,那么在任务队列的B任务永远不会有线程去执行,从而出现了线程饥饿锁的出现。

如何避免

  1. 设置更大的线程池大小或者选择不受限制的线程池

    虽然更大的线程池能够减少或者避免线程饥饿锁的出现,但是线程资源是宝贵的,不可能无限创建,该方法有些不合理

  2. 使用java.util.concurrent.Future#get(long, java.util.concurrent.TimeUnit)

    使用带超时时间的Future.get虽然能够让后续的任务尽快返回,不阻塞接口,但是后续请求的功能是非正常的,这种方式明显不合理

  3. 使用线程池拒绝策略为:CallerRunsPolicy

    将拒绝策略更改为CallerRunsPolicy,因为线程池的大小以及流量无法确定,那么在线程异常时将异步执行退化为串行执行,也不失为一种避免线上故障的方法,但是在饥饿锁的场景下会让web容器的线程也受到影响

  4. 使用不同的线程池隔离有互相依赖的任务

    有相互依赖的任务,隔离到不同的线程池中执行,使得相互之间不再竞争使用相同的线程池资源,理论上的好方法,但是在实际业务中,我们经常无法区分出哪些业务应该归拢到一个线程池中,哪些应该分开创建线程池,而且线程资源宝贵,仅仅是因为相互依赖的任务就创建不同的线程池也不是一个好的方式

  5. 使用CompletableFuture + 自定义线程池来编排存在相互依赖的任务

public static void main(String[] args)  {

    CompletableFuture<Void> futureA = CompletableFuture.supplyAsync(() -> null)
        .thenComposeAsync(result1 -> CompletableFuture.supplyAsync(() -> null));

    CompletableFuture<Void> futureB = CompletableFuture.supplyAsync(() -> null)
        .thenComposeAsync(result2 -> CompletableFuture.supplyAsync(() -> null));

    // 等待所有的任务完成
    CompletableFuture.allOf(futureA, futureB).join();
}

使用 CompletableFuture 可以更清晰地表达任务之间的依赖关系,而不需要显式地操作线程池和 Future,更方便地编排存在相互依赖的任务,避免线程饥饿问题,并提供更灵活和强大的异步编程能力。