大家好,我是程序员牛肉。
最近在线程池这里又踩了一个坑:“线程池死锁”,不知道你们有没有遇到过这种情况。如果你不知道这种情况的话,那可得好好看看我这篇文章了。
我们先来回顾一下什么是死锁:死锁产生的必要条件是互斥、请求与保持、不可抢占、循环等待。所以当若干进程因竞争而无休止地相互等待他方释放已占有的资源时,系统会产生死锁。
简单的讲:如果资源A和B在同一时间只能被单个线程获取,此时线程A获取了资源A,等待线程B释放资源B,而线程B获取了资源B,等待线程A获取线程B。
[最常见的死锁其实就在我们的生活中,当你去一些地方办事的时候,你会遇见以下情况:找A的时候,A说你去找B办。找到B的时候,B说找A办。这个时候AB之间就构成死锁了。]
难道说线程池还会发生这种情况?当然会有,不然我写这篇文章干什么。
线程池引入了另一种死锁情况:父线程在占用了线程池内所有的资源后又向线程池提交了新的任务,并且要等这些任务完成后才释放资源,而这些新提交的任务根本就没机会被完成,一直被堆放在阻塞队列中。
这种情况在项目代码中,大部分都是因为父子线程都使用公共线程所造成的。
例如有一个公共线程池,最大线程数为2。此时有两个线程接收到了任务进行执行。而这个任务需要创建一个子线程来执行。
于是这两个线程又尝试使用公共线程池中的线程来执行任务。结果由于线程池中所有的两个线程都已经被占有,导致没有办法创建子线程来执行任务。
而线程池中的两个线程又因为自身任务没有被执行完毕而一直存活,导致迟迟不肯让渡线程来让子线程执行任务。因此子线程就被一直存放在了有界阻塞队列中。导致后续的所有请求都一直触发线程池的淘汰策略。
示例代码为:
// 创建单线程的线程池
ExecutorService pool = Executors.newSingleThreadExecutor();
pool.submit(() -> {
try {
// 输出日志信息,表示第一个任务开始执行
log.info("First");
// 向线程池提交第二个任务,并等待第二个任务执行完成
pool.submit(() -> log.info("Second")).get();
// 输出日志信息,表示第一个任务后续操作继续执行
log.info("Third");
} catch (InterruptedException | ExecutionException e) {
// 若出现异常,记录错误日志
log.error("Error", e);
}
});
在这种情况下,我们的父线程在占有了线程池的所有线程之后,仍然向线程池去提交任务并且使用get方法来获取其运行结果。
此时这个任务就会一直进入内部等待队列,等待父线程让出线程供自己执行。而父线程只有在自身任务执行完毕之后才会释放线程供子线程执行任务。在这种情况下就造成了线程池死锁
那我们要如何解决这个问题呢?
其实有一个最简单的解法:既然线程池死锁之后导致有界阻塞队列被占满,引发后续任务一直触发淘汰策略,那我们选择无界阻塞队列不就好了?
后续任务尽管不会被执行,但你就一直往阻塞队列里面加就完事了。
[在 Java 中,无界阻塞队列(Unbounded Blocking Queue)是一种特殊的数据结构,它实现了BlockingQueue
接口。与有界阻塞队列不同,无界阻塞队列理论上可以存储无限数量的元素。当向无界阻塞队列中添加元素时,它不会因为队列已满而阻塞(除非遇到系统资源限制,如内存不足等极端情况)]
这种方法也就图一乐,不会真有人在实际开发中使用这个方法吧?那真正的解决方法有哪些呢?
1.使用CompletableFuture的异步回调,避免阻塞线程:
ExecutorService pool = Executors.newSingleThreadExecutor();
pool.submit(() -> {
try {
log.info("First");
// 使用CompletableFuture异步提交子任务,并在子任务完成后执行后续逻辑
CompletableFuture.runAsync(() -> log.info("Second"), pool)
.thenRun(() -> log.info("Third"));
} catch (Exception e) {
log.log(Level.SEVERE, "Error", e);
}
});
[CompletableFuture.runAsync是 Java 8 引入的CompletableFuture类中的一个静态方法。它用于以异步的方式执行一个Runnable任务,即这个任务会在一个独立于当前线程的线程中执行。这种异步执行机制可以提高程序的并发性能,避免当前线程因为等待任务完成而被阻塞,使程序能够同时处理多个任务。]
使用了这个方法后,父线程就不会阻塞等待子线程调用的结果,这样就可以在执行完父线程后,让渡线程给子线程来执行任务。
2.拆分线程池,禁止父子线程共享一个线程池:
public static void main(String[] args) {
ExecutorService pool = Executors.newSingleThreadExecutor();
pool.submit(ThreadPoolExampleRewrittenWithMethods::firstTask);
pool.shutdown();
}
public static void firstTask() {
try {
log.info("First");
secondTask();
log.info("Third");
} catch (Exception e) {
log.log(Level.SEVERE, "Error", e);
}
}
public static void secondTask() {
ExecutorService pool = Executors.newSingleThreadExecutor();
pool.submit(() -> log.info("Second"));
pool.shutdown();
}
}
拆分线程池的方法最简单高效。但是需要注意的是:尽量不要在java代码中创建过多的线程。过多的线程也会拖慢整个项目的响应速度。
如果你对Java线程池的小坑点比较好奇的话,还可以看一看我之前写的这一篇文章:
今天关于java线程池的小坑点就介绍到这里了,希望通过我的文章,你可以了解这个比较罕见的线程池坑点。