在我的Java并发学习笔记专栏的前五篇文章中,讲述了关于Java锁机制、乐观锁和悲观锁以及AQS、Reentrantlock、volatile关键字、ThreadLocal类、ConcurrentHashMap等关于Java并发的内容。
本篇将讲述Java的JUC包中开发常用的线程池,包括线程池的优点、核心参数、拒绝策略等。
线程的使用过程需要经历三个阶段:创建 → 运行 → 销毁。其中,线程的创建和销毁这两个步骤是比较消耗资源而影响性能的,尤其是在大规模的并发场景下。
每次创建一个线程运行一个任务后销毁它,不免觉得有点浪费。那可不可以创建线程后并不断复用它来运行多个线程任务,进而优化性能?于是我们引入了线程池。
下面引用JavaGuide中对线程池的介绍:
线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
简单来说,线程池是一个充满了线程的池子,当我们要执行一个任务的时候,就会从线程池中取出一个线程来执行,任务执行完了后就把线程还回线程池,进而起到复用线程的作用,减少了线程的创建和销毁。
《Java 并发编程的艺术》提到的使用线程池的好处可以归为3类:
先创建一个任务类,打印当前线程的名字,然后sleep一秒钟:
class MyTask implements Runnable {
int i;
public MyTask(int i) {
this.i = i;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " --> " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
我们先使用CachedThreadPool来尝试运行100次该任务:
public class Sample {
public static void main(String[] args) {
ExecutorService e = Executors.newCachedThreadPool();
for (int i = 0; i < 100; i++) e.execute(new MyTask(i));
}
}
运行结果的部分截图:
可以看到线程编号最大来到了100,也就是说线程池创建了100个线程,这是由于sleep导致前面运行的线程没来得及执行完任务后回池子。
接下来我们使用FixedThreadPool来尝试运行100次该任务,newFixedThreadPool方法参数为10:
public class Sample {
public static void main(String[] args) {
ExecutorService e = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) e.execute(new MyTask(i));
}
}
运行结果的部分截图:
运行结果每隔1秒刷新一次,线程编号最大只有10次。可以发现其实线程池中最多只有10个线程,同时不断复用这些线程,一旦它们执行完了一个任务,就会立刻接着去执行下一个任务。
接下来我们使用SingleThreadExecutor来尝试运行100次该任务:
public class Sample {
public static void main(String[] args) {
ExecutorService e = Executors.newSingleThreadExecutor();
for (int i = 0; i < 100; i++) e.execute(new MyTask(i));
}
}
运行结果的部分截图:
可以看到这种情况下只有一个线程在执行任务。
接下来我们分别点开newCachedThreadPool、newFixedThreadPool、newSingleThreadExecutor方法查看源码,发现其实它们都是调用了ThreadPoolExecutor构造方法,填入特定参数,以此返回了特定的ThreadPoolExecutor对象。
ThreadPoolExecutor是什么呢?为什么对它的构造方法传入不同的参数就能得到具有不同功能的线程池呢?我们下面就来看看ThreadPoolExecutor这个类。
宏观上看,开发者将任务提交给ThreadPoolExecutor,然后ThreadPoolExecutor分配工作线程来执行任务,任务执行完成后,工作线程会回到ThreadPoolExecutor中等待后续任务的分配。
继承关系(箭头表示继承):
ThreadPoolExecutor → AbstractExecutorService → ExecutorService → Executor
在《阿里巴巴Java开发手册》“并发处理”这一章节中提到,线程资源必须通过线程池提供,而不允许在应用中自行显式创建线程。原因是:
使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。
在ThreadPoolExecutor类中可以看到有以下7个常数:
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
这几个常数代表什么呢?为什么要这么设计?
先看下面五个常数的名称,它们分别代表ThreadPoolExecutor不同的状态:
五个状态之间的转换图如图:
我们发现在上面的常量当中,先设置了COUNT_BITS,值为Integer.SIZE - 3,一个int类型占用4个字节,也即是32位,所以Integer.SIZE的大小是32,则COUNT_BIT大小是32 - 3 = 29。
在下面表示五个状态的常量使用个位数左移COUNT_BITS位后的做过来表示,这是什么目的?其实这是因为ThreadPoolExecutor使用一个int变量来记录线程池状态和工作线程数这两个信息,线程池状态记录在int变量的高三位,工作线程数记录在int变量的低29位。
五个状态常量计算之后的结果是这样的,注意它们只有高三位不同:
RUNNING: 11100000 00000000 00000000 00000000
SHUTDOWN: 00000000 00000000 00000000 00000000
STOP: 00100000 00000000 00000000 00000000
TIDYING: 01000000 00000000 00000000 00000000
TERMINATED: 01100000 00000000 00000000 00000000
在《阿里巴巴Java开发手册》中提到说:
强制不允许使用Executor来创建线程池,必须使用ThreadPoolExecutor构造函数的方式来创建,因为这种处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
ThreadPoolExecutor类中提供四个构造方法,其中三个传入参数较少的构造方法都是直接调用传入参数最多的构造方法并传入默认值。
我们直接来看这个传入参数最多的构造方法:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
一共有7个传入参数,我们一一来看一下。
其中corePoolSize、maximumPoolSize、keepAliveTime、workQueue这四个参数是比较关键的。怎么理解它们呢?
我这里做一个比喻,我们把线程池看作是一家工厂,那么corePoolSize就是工厂核心员工数量,maximumPoolSize就是工厂最大容纳员工的数量,wordQueue就是工厂核心员工数量下员工忙不过来时的任务清单,keepAliveTime就是如果当前厂里总人数大于核心员工数且没有工厂新任务时,空闲的员工会等待的时间,如果过了这段时间还没任务就会被开除。
这家工厂拥有corePoolSize数量的员工(当然一开始工厂也没员工,这些员工也是由一开始有任务后而慢慢招进来的,不是说工厂一建立就立刻招员工),当有任务需要执行时就厂里现有的员工来执行,当现有员工忙不过来时,会先把暂时还没法开始执行的任务列在任务清单中,但是这个任务清单中的任务数量是有上限的,当达到了上限时但是还有新任务需要做时,就会开始招聘新员工来执行后面新加入的任务(注意,临时工优先执行后来的任务,也就是说队列里塞不进的任务会招临时工来做,原本在队列里的任务还是在队列里),同时在招聘新员工的时候,员工总数量不能超过工厂的最大容纳员工数量。
当厂里员工数大于核心员工数且此时厂里没有新任务需要执行时,空闲的员工如果在keepAliveTime时间之后还没工作可以做,那么他们就会被开除,注意这里不论他们是不是之前的老员工,就是说被开除的员工有可能之前是最少数量员工数中的员工,但是如果遇到了此时正在裁员的情况,如果新的员工此时正在工作而老员工没在工作,那么他也会被开除(真残酷)。
通过以上例子,大家应该能大概明白corePoolSize、maximumPoolSize、keepAliveTime、workQueue这四个参数的含义了。
线程池构造方法中的workQueue参数是需要传入一个实现了BlockingQueue接口的阻塞队列对象。JUC并发包提供了以下几种阻塞队列:
下面选择性地简单介绍一些工作队列:
接下来我们再来回看之前CachedThreadPool、FixedThreadPool、SingleThreadExecutor三种线程池是怎么实现的:
newCachedThreadPool:
可见,newCachedThreadPool方法中设置的核心线程数是0,最大线程数是int的最大值,即是无界的,空闲线程存活时间设为60s,任务队列使用的是SynchronousQueue,即任务队列不会存储元素,一旦有任务打算放入工作队列那么就会立刻将任务安排掉。
所以在CachedThreadPool中,一开始线程数是0,任务队列一旦存入任务就会立刻将任务安排掉,当有任务需要执行时,就直接创建线程来执行它,且线程数无限制。
对于CachedThreadPool的理解我们可以依据它的名字中的Cache联想到缓存,线程池中的线程在执行完任务后在存活时间内可能会用于继续执行新来的任务。
同时这就意味着极端情况下如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool会不断创建新线程,这个线程数是没有限制的,所以该线程池不会拒绝任务,这会耗尽cpu和内存资源。
同时CachedThreadPool的最大线程数是Integer.MAX_VALUE,使用它可能会创建大量线程,引起OOM(Out Of Memory),所以不推荐使用CachedThreadPool的原因。
newFixedThreadPool:
可见,newFixedThreadPool方法中设置的核心线程数和最大线程数都是n,空闲线程存活时间为0,任务队列使用的是LinkedBlockingQueue且没有初始化容量,那么这个工作队列是无界的,即是一个无限大的队列。其实这里的最大线程数是个无效参数,因为任务队列无穷大,不可能出现任务队列满了后再创建新线程的情况。
顾名思义,FixedThreadPool只能有Fixed也即固定数量的线程数,当这个固定数量的线程忙不过来时,不断将新任务放入队列中。
同时FixedThreadPool也是不推荐使用的,因为它的任务队列是无界队列,不会出现拒绝任务的情况,可能会导致OOM。
newSingleThreadExecutor:
不难发现其实SingleThreadExecutor与FixedThreadPool其实同理,等同于调用newFixedThreadPool(1),所以同理不推荐使用SingleThreadExecutor
ScheduledThreadPoolExecutor主要用来在给定的延迟后运行任务,或者定期执行任务。
ScheduledThreadPoolExecutor使用的任务队列DelayQueue封装了一个PriorityQueue,PriorityQueue会对队列中的任务进行排序,执行所需时间短的放在前面先执行 ( ScheduledFutureTask 的 time 变量小的先执行),如果执行所需时间相同则先提交的任务将被先执行 ( ScheduledFutureTask 的 squenceNumber 变量小的先执行)。
上文我们提到过拒绝策略。拒绝策略是规定当线程池不断接受任务直到当前线程池中线程数大于允许的最大线程数maximumPoolSize时如何拒绝这些任务。
ThreadPoolExecutor 中定义一些 RejectedExecutionHandler 类型变量的对象作为拒绝策略:
线程池中的线程个数的选择是有讲究的,线程个数过多或过少都会存在问题:
可以使用一个简单并且适用面广的公式:
那么如何判断当前情况是CPU密集型任务还是I/O密集型任务?
Runnable和Callable:
execute() 和 submit():
shutdown()和shutdownNow():
SHUTDOWN
,线程池不再接收新任务,但是任务队列中的任务得执行完毕STOP
,线程池会终止当前正在运行的任务,并停止处理排队任务并返回正在等待执行的ListisShutdown()和isTerminated()
关于线程池就先写到这,其实关于线程池还有很多细节本文没有写到,后续可能会接着更新本篇文章吧。
2022.4.1 增加了ScheduledThreadPoolExecutor、工作队列类型、线程池大小确定的内容
参考: