您的位置 首页 java

Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨

引言

上一篇的死锁本质是线程饥饿死锁的范畴,它产生的直接原因是得不到需要的资源,比如, 线程池 调度过程中,某个任务因得不到线程资源,一直等待、继而 “饿死” 的一种死锁情况。经典案例就是“哲学家进餐”,最终出现蹲在餐桌旁却被“饿死”的惨剧!

这种死锁常发生在单线程或者工作线程数特别小的线程池中, Timer 或者线程池大小为 1 的 ThreadPoolExecutor 容易发生资源饥饿死锁 ,一起来看看它的产生过程和破解方案吧。

线程饥饿死锁概述

Java 并发编程实践》中对线程饥饿死锁的解释是这样的:

比如说 ,在单线程的线程池 P 中,如果在一个任务 A 执行的过程中,又提交了另一个任务 B 到同一个线程池,并且 A 需要等待 B 任务的结果。那么,这必定会因工作线程资源的限制【只有一个工作线程】,而导致线程饥饿死锁。

死锁原因分析

分析上述死锁产生原因

  1. 第一个 A 任务在工作队列中,并等待第二个任务 B 的结果;
  2. 由于工作线程只有一个,所以第二个任务 B 将处于等待队列中,等待第一个任务执行完成后释放工作线程;
  3. 任务 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;
	}
}  

最后,运行这个测试类,果然看不到第二个任务执行完成的日志信息:

Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨

破解方法

在这个简单的案例里,可以把单线程的 Executor 换成无限大容量的线程池,以避免死锁,修改线程池的类型:

 ExecutorService exec = Executors.newCachedThreadPool();  

再次运行测试类,发现主程序在拿到第二个任务返回的结果后,销毁线程池,程序结束:

Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨


稍微想一下,发现这个方案是有缺陷的。因为无限大容量的线程池是不现实的,尤其是对任务密集型的应用来说,它可能会耗尽 JVM 内存资源,最后产生
OOM 异常。

从这个案例中,我们能够得到几点启示:

  1. 提交到线程池中的任务之间相互依赖时,需要警惕死锁的发生;
  2. 线程池工作线程数量有限、且任务之间相互依赖时,要考虑一下是否存在任务被抛弃执行、导致其他任务等不到被抛弃任务的结果的情况;
  3. 工作线程的数量,它是影响线程池任务调度的瓶颈,避免提交相互依赖的任务才是上策。

资源有限导致的死锁

当多个线程共享相同的资源集时,如果某个线程在等待某个资源 A ,同时又持有另一种资源 B ,这就可能发生死锁。类似锁顺序死锁中的场景,资源集在开发中很常见,数据库连接池,任务调度线程池等,它们都属于多线程竞争的资源。

非锁资源竞争导致的死锁问题,笔者并没有找到相关的资料,这算是笔者杜撰的一个概念吧。主要想说的是开题介绍的那种死锁问题。

使用线程池执行任务时,应该避免相互依赖的任务被提交到同一个线程池中。看到这里,大家可能会发现,线程饥饿死锁的根源也是资源不足,导致有些线程因等待而 “饿死” 的情况。

Timer 定时器避坑指南

java.util 包中的 Timer 类可以实现定时任务。对比较简单的任务调度需求来说,它基本上是够用的,但是使用它也容易采坑,所以需要 警惕异常导致定时器终结 的问题。

跟踪源码,我们可以知道 Timer 类,它是创建了一个单线程去执行定时任务,该线程会受运行时异常的影响,一旦遭遇运行时异常,它就会结束。

正常情况下,下面的示例中,定时任务每隔 3 秒执行一次,线程不结束、 程序不会终止:

Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨


如果任务异常,则会怎么样呢?我们放开 TimerTask 任务中抛出异常的语句,继续执行:

Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨


衍生思考,为什么一个线程运行过程中遇到运行时异常会导致该线程结束呢?
先自己定义一个线程,验证一下运行时异常对其的影响。

Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨


上述结果显示, JVM 底层打印了堆栈日志,尽管
while(true) 为真,当程序抛出异常后,线程还是结束了。因为该线程没有捕获运行时异常,它执行中断后, JVM 会调用 Thread.getUncaughtExceptionHandler() 来查询该线程的 UncaughtExceptionHandler ,并将线程和异常作为参数传递给 handler 的 uncaughtException() 方法进行处理。

默认情况下,线程的 UncaughtExceptionHandler 是其所属的 ThreadGoup 实例,它的定义是这样的:

 public   class ThreadGroup implements Thread.UncaughtExceptionHandler  

接口实现方法为:

Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨


红框部分打印出了遭遇异常的线程名称和异常堆栈,和前面的测试结果相印证。

结论 :Timer 中执行任务的是单线程,它没有捕获运行时异常,为了保证定时器能够稳健运行, 提交的 TimerTask 任务最好进行异常捕获 ,这样即使某一轮的任务执行失败,也不会影响下一次的执行。

线程池避坑指南

线程池使用的时候:需要注意线程数和提交任务个数的不匹配问题。

以前面的 Timer 为例, Timer 也是一个线程池,它只有一个工作线程。如果我们向它提交了两个任务,一个任务与示例一样,是一个无限循环的逻辑;显然,另一个任务可能永远不会获得执行了(异常情况除外)。

编写一个简单的验证示例,执行结果如下:

Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨


在使用
Timer 或其他线程池工具类时,应该 关注工作线程的个数和待提交的任务数之间的匹配度 ,如果有独占线程的任务,要保证其他任务能够获得执行的机会。

启示录

死锁的核心还是 “等待资源而不得”,可能是锁资源,也可能是运行资源。多线程环境下,资源竞争是不可避免的,所以在编码过程中,经常想一想各种可能的死锁情况,心存敬畏,编码更谨慎一些,距离死锁的可能性也会更小一些吧!

文章来源:智云一二三科技

文章标题:Java 死锁篇:哲学家就餐、线程饥饿死锁等问题探讨

文章地址:https://www.zhihuclub.com/179549.shtml

关于作者: 智云科技

热门文章

网站地图