[Java并发编程实战]线程池的使用之饥饿死锁的发生

一屋不扫何以扫天下?———《后汉书·陈蕃传》
这句话的意思是,从一点一滴的小事开始积累,才能做成一番大事业。

Executor框架核心之一就是利用线程池,所以接下来这几篇,详细介绍线程池相关的高级选项以及注意事项。

任务间隐性耦合的说明

虽说 Exectuor 将任务和执行策略解耦,但是实际上言过其实了。假如任务之间存在某种相互依赖关系,其中一个任务必须依赖另外一个的执行,这就又产生某种程度上的耦合。像这些类型的任务,我们需要注意,需要明确地指定一个执行策略。比如下面这些任务都是需要注意的:

  • 依赖性任务:任务之间相互依赖,隐形地给执行策略带来了约束,这样要求我们必须仔细的管理执行策略避免活跃度问题。比如,一个任务需等待另外一个耗时任务或者相互等待了,会产生什么样的结果?

  • 采用线程限制的任务

  • 对响应时间敏感的任务:将一个耗时的任务提交到单线程化的 Executor 中,或者将多个耗时的任务提交到只包含少量线程的线程池中,会削弱由 Executor 管理的服务的响应性。
  • 使用 ThreadLocal 的任务:ThreadLocal 让每个线程可以保留一份变量的私有版本。Executor的是实现是:在需求不高时回收空闲线程,需求增加时添加新的线程,如果任务抛出异常,就会用一个全新的线程取代出错的那个。所以,只有当 ThreadLocal 值的生命周期被限制在当前任务中时,在池的某线程中使用 ThreadLocal 才有意义。在线程池中,不应该使用 ThreadLocal 传递任务间的数值。

所以结论就是,当任务是同类的,独立的时候,线程池才会发挥出最佳的作用。

另外:

  • 如果将耗时的任务和短时任务混合在一起,除非线程池很大,否则会出现线程池拥堵,拖长服务时间,最差的情况是所有线程任务都在执行耗时任务。

  • 如果提交的任务依赖于其他任务,除非线程池是无限的,否则会有产生死锁的风险。

线程饥饿死锁概念

在线程池中如果一个任务依赖于其他任务的执行,就可能产生死锁。在一个大的线程池中,如果所有线程执行的任务都阻塞在线程池中,等待着仍然处于同一队列中的其他任务,那么这就会发生线程饥饿死锁。换句话说,只要池任务开始了无限期阻塞,其目的是等待一些资源或条件,此时只有另一个池任务的执行才能使那些条件成立。除非能保证线程池足够大,否则会发生线程饥饿死锁

下面举个线程饥饿死锁的,创建只有一个线程的线程池,用于串行执行任务。创建两个任务 Task1 和 Task2,其中 Task1 从队列中取出元素, Task2 向队列添加元素。其中,队列为阻塞队列,当队列为空时,Task1 将会一直阻塞等待 Task2 执行,但是此时只有一个线程只能执行一个任务,所以这个 Task1 将会永远阻塞,Task2 将永远无法执行。这就是任务之间相互依赖的饥饿死锁。

代码清单如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadDeadLockTest{

//创建一个阻塞队列
private static BlockingQueue Q = new ArrayBlockingQueue(10);
//线程池线程数量
private static final int THREAD_SIZE = 1;

@SuppressWarnings("unchecked")
public static void main(String[] args) {
//创建一个固定线程的线程池
ExecutorService service = Executors.newFixedThreadPool(THREAD_SIZE);
service.submit(new Task1());
service.submit(new Task2(1));
service.shutdown();
}

//任务1取出阻塞队列的值并打印
static class Task1 implements Callable {
@Override
public Object call() throws Exception {
System.out.println("Task1 is running");
//取出阻塞队列的值,如果没有则会阻塞
int value = (int) Q.take();
System.out.println("Task1 finished, value = " + value);
return null;
}
}

//任务2,往阻塞队列增加元素
static class Task2 implements Callable {
private int val;
public Task2(int value) {
val = value;
}
@Override
public Object call() throws Exception {
System.out.println("Task2 put value = " + val);
//往阻塞队列增加元素
Q.put(1);
return null;
}
}

}

执行结果:

从这里看出,Task1 一直在运行并且没有结束,Task2 永远无法执行。这个例子就简单的说明了饥饿死锁发生的情况。

上面也说过,除非线程池足够大,才能避免饥饿死锁的发生。所以,我们把上面的代码的线程数量改为2:

1
THREAD_SIZE = 2;

执行结果如下:

运行结果正常,不会发生饥饿死锁啦,因为线程池足够大。

本文完结。

坚持原创技术分享,您的支持将鼓励我继续创作!