引言
上一篇的死锁本质是线程饥饿死锁的范畴,它产生的直接原因是得不到需要的资源,比如, 线程池 调度过程中,某个任务因得不到线程资源,一直等待、继而 “饿死” 的一种死锁情况。经典案例就是“哲学家进餐”,最终出现蹲在餐桌旁却被“饿死”的惨剧!
这种死锁常发生在单线程或者工作线程数特别小的线程池中, Timer 或者线程池大小为 1 的 ThreadPoolExecutor 容易发生资源饥饿死锁 ,一起来看看它的产生过程和破解方案吧。
线程饥饿死锁概述
《 Java 并发编程实践》中对线程饥饿死锁的解释是这样的:
比如说 ,在单线程的线程池 P 中,如果在一个任务 A 执行的过程中,又提交了另一个任务 B 到同一个线程池,并且 A 需要等待 B 任务的结果。那么,这必定会因工作线程资源的限制【只有一个工作线程】,而导致线程饥饿死锁。
死锁原因分析
分析上述死锁产生原因 :
- 第一个 A 任务在工作队列中,并等待第二个任务 B 的结果;
- 由于工作线程只有一个,所以第二个任务 B 将处于等待队列中,等待第一个任务执行完成后释放工作线程;
- 任务 A 和 任务 B 就会因为线程资源紧张,而陷入无限等待中。
这是典型的线程饥饿死锁现象,即使是在 多线程 的线程池中,如果提交的任务之间相互依赖执行结果的话,也可能会由于工作线程数量不足而陷入死锁状态。
单线程饥饿死锁测试
我们来创建一个单线程的 Executor 的实例,并实现一个任务之间相互依赖的场景:先定义一个 RanderPageTask 任务,它会把另一个 LoadFileTask 的任务提交给同一个线程池、并等待它的返回结果,执行测试类,看看会发生什么情况。
首先,定义 RanderPageTask 类,在它的 main 方法中提交一个 LoadFileTask 任务,代码如下:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class ThreadDeadLock {
ExecutorService exec = Executors.newSingleThreadExecutor();
/**
* 该任务会提交另外一个任务到线程池,并且等待任务的执行结果
* @author bh
*/
public class RenderPageTask implements Callable<String>{
@Override
public String call() throws Exception {
System.out.println("RenderPageTask 依赖LoadFileTask任务返回的结果...");
Future<String> header ,footer;
header = exec.submit(new LoadFileTask("header.html"));
footer = exec.submit(new LoadFileTask("footer.html"));
String page = renderBody();
return header.get()+page+footer.get();
}
public String renderBody(){
return "render body is ok.";
}
}
public static void main(String[] args) {
ThreadDeadLock lock = new ThreadDeadLock();
Future<String> result = lock.exec.submit(lock.new RenderPageTask());
try {
System.out.println("last result:"+result.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}finally{
lock.exec.shutdown();
}
}
}
接着,编写 LoadFileTask 任务类的代码:
import java.util.concurrent.Callable;
public class LoadFileTask implements Callable<String> {
private String fileName;
public LoadFileTask(String fileName){
this.fileName = fileName;
}
@Override
public String call() throws Exception {
System.out.println("LoadFileTask execute call...");
return fileName;
}
}
最后,运行这个测试类,果然看不到第二个任务执行完成的日志信息:
破解方法
在这个简单的案例里,可以把单线程的 Executor 换成无限大容量的线程池,以避免死锁,修改线程池的类型:
ExecutorService exec = Executors.newCachedThreadPool();
再次运行测试类,发现主程序在拿到第二个任务返回的结果后,销毁线程池,程序结束:
稍微想一下,发现这个方案是有缺陷的。因为无限大容量的线程池是不现实的,尤其是对任务密集型的应用来说,它可能会耗尽 JVM 内存资源,最后产生 OOM 异常。
从这个案例中,我们能够得到几点启示:
- 提交到线程池中的任务之间相互依赖时,需要警惕死锁的发生;
- 线程池工作线程数量有限、且任务之间相互依赖时,要考虑一下是否存在任务被抛弃执行、导致其他任务等不到被抛弃任务的结果的情况;
- 工作线程的数量,它是影响线程池任务调度的瓶颈,避免提交相互依赖的任务才是上策。
资源有限导致的死锁
当多个线程共享相同的资源集时,如果某个线程在等待某个资源 A ,同时又持有另一种资源 B ,这就可能发生死锁。类似锁顺序死锁中的场景,资源集在开发中很常见,数据库连接池,任务调度线程池等,它们都属于多线程竞争的资源。
非锁资源竞争导致的死锁问题,笔者并没有找到相关的资料,这算是笔者杜撰的一个概念吧。主要想说的是开题介绍的那种死锁问题。
使用线程池执行任务时,应该避免相互依赖的任务被提交到同一个线程池中。看到这里,大家可能会发现,线程饥饿死锁的根源也是资源不足,导致有些线程因等待而 “饿死” 的情况。
Timer 定时器避坑指南
java.util 包中的 Timer 类可以实现定时任务。对比较简单的任务调度需求来说,它基本上是够用的,但是使用它也容易采坑,所以需要 警惕异常导致定时器终结 的问题。
跟踪源码,我们可以知道 Timer 类,它是创建了一个单线程去执行定时任务,该线程会受运行时异常的影响,一旦遭遇运行时异常,它就会结束。
正常情况下,下面的示例中,定时任务每隔 3 秒执行一次,线程不结束、 程序不会终止:
如果任务异常,则会怎么样呢?我们放开 TimerTask 任务中抛出异常的语句,继续执行:
衍生思考,为什么一个线程运行过程中遇到运行时异常会导致该线程结束呢?
先自己定义一个线程,验证一下运行时异常对其的影响。
上述结果显示, JVM 底层打印了堆栈日志,尽管 while(true) 为真,当程序抛出异常后,线程还是结束了。因为该线程没有捕获运行时异常,它执行中断后, JVM 会调用 Thread.getUncaughtExceptionHandler() 来查询该线程的 UncaughtExceptionHandler ,并将线程和异常作为参数传递给 handler 的 uncaughtException() 方法进行处理。
默认情况下,线程的 UncaughtExceptionHandler 是其所属的 ThreadGoup 实例,它的定义是这样的:
public class ThreadGroup implements Thread.UncaughtExceptionHandler
接口实现方法为:
红框部分打印出了遭遇异常的线程名称和异常堆栈,和前面的测试结果相印证。
结论 :Timer 中执行任务的是单线程,它没有捕获运行时异常,为了保证定时器能够稳健运行, 提交的 TimerTask 任务最好进行异常捕获 ,这样即使某一轮的任务执行失败,也不会影响下一次的执行。
线程池避坑指南
线程池使用的时候:需要注意线程数和提交任务个数的不匹配问题。
以前面的 Timer 为例, Timer 也是一个线程池,它只有一个工作线程。如果我们向它提交了两个任务,一个任务与示例一样,是一个无限循环的逻辑;显然,另一个任务可能永远不会获得执行了(异常情况除外)。
编写一个简单的验证示例,执行结果如下:
在使用 Timer 或其他线程池工具类时,应该 关注工作线程的个数和待提交的任务数之间的匹配度 ,如果有独占线程的任务,要保证其他任务能够获得执行的机会。
启示录
死锁的核心还是 “等待资源而不得”,可能是锁资源,也可能是运行资源。多线程环境下,资源竞争是不可避免的,所以在编码过程中,经常想一想各种可能的死锁情况,心存敬畏,编码更谨慎一些,距离死锁的可能性也会更小一些吧!