面试题总结-并发编程系列(1)-线程与线程池
本文的大部分内容总结自:拉勾课程 《Java并发编程》 作者:徐隆曦
1 线程
1.1 如何实现线程?
实现线程的方式到底有几种?
1.1.1 实现 Runnable 接口
public class RunnableThread implements Runnable { @Override public void run() { System.out.println("用实现Runnable接口实现线程"); } }
1.1.2 继承 Thread 类
第 2 种方式是继承 Thread 类,如代码所示,与第 1 种方式不同的是它没有实现接口,而是继承 Thread 类,并重写了其中的 run() 方法。相信上面这两种方式你一定非常熟悉,并且经常在工作中使用它们。
public class ExtendsThread extends Thread { @Override public void run() { System.out.println("用Thread类实现线程"); } }
但是,你点击进去查看 Thread 的源码,发现 Thread 是实现了 Runnable 接口的。
1.1.3 线程池创建线程
对于线程池而言,本质上是通过线程工厂创建线程的,默认采用 DefaultThreadFactory ,它会给线程池创建的线程设置一些默认值,比如:线程的名字、是否是守护线程,以及线程的优先级等。但是无论怎么设置这些属性,最终它还是通过 new Thread() 创建线程的 ,只不过这里的构造函数传入的参数要多一些,由此可以看出通过线程池创建线程并没有脱离最开始的那两种基本的创建方式,因为本质上还是通过 new Thread() 实现的。
DefaultThreadFactory源码:
static class DefaultThreadFactory implements ThreadFactory { DefaultThreadFactory() { SecurityManager s = System.getSecurityManager(); group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); namePrefix = "pool-" + poolNumber.getAndIncrement() + "-thread-"; } @Override public Thread newThread(Runnable r) { Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0); if (t.isDaemon()) t.setDaemon(false); if (t.getPriority() != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY); return t; } }
1.1.4 有返回值的 Callable 创建线程
但是,无论是 Callable 还是 FutureTask,它们首先和 Runnable 一样,都是一个任务,是需要被执行的,而不是说它们本身就是线程。它们可以放到线程池中执行,如代码所示, submit() 方法把任务放到线程池中,并由线程池创建线程,不管用什么方法,最终都是靠线程来执行的,而子线程的创建方式仍脱离不了最开始讲的两种基本方式,也就是实现 Runnable 接口和继承 Thread 类。
public class CallableTask implements Callable<Integer> { @Override public Integer call() throws Exception { return new Random().nextInt(); } public static void main(String[] args) throws Exception { //创建线程池 ExecutorService service = Executors.newFixedThreadPool(10); //提交任务,并用 Future提交返回结果 Future<Integer> future = service.submit(new CallableTask()); System.out.println(future.get()); } }
1.1.5 其他写法
匿名内部类写法:
/** *描述:匿名内部类创建线程 */ new Thread(new Runnable() { @Override public void run() { System.out.println(Thread.currentThread().getName()); } }).start();
lambda 表达式:
new Thread(() -> System.out.println(Thread.currentThread().getName())).start(); }
实际上,匿名内部类或 lambda 表达式创建线程,它们仅仅是在语法层面上实现了线程,并不能把它归结于实现多线程的方式,如匿名内部类实现线程的代码所示,它仅仅是用一个匿名内部类把需要传入的 Runnable 给实例出来。
1.1.6 总结
1.1.6.1 实现方式只有一种
通过以上几种方式,会发现它们最终都是基于 Runnable 接口或继承 Thread 类实现的。 接下来,我们进行更深层次的探讨,为什么说这两种方式本质上是一种呢?
@Override public void run() { if (target != null) { target.run(); } }
首先,启动线程需要调用 start() 方法,而 start() 方法最终还会调用 run() 方法,我们先来看看第一种方式中 run() 方法究竟是怎么实现的,可以看出 run() 方法的代码非常短小精悍,第 1 行代码 if (target != null) ,判断 target 是否等于 null,如果不等于 null,就执行第 2 行代码 target.run(),而 target 实际上就是一个 Runnable,即使用 Runnable 接口实现线程时传给Thread类的对象。
然后,我们来看第二种方式,也就是继承 Thread 方式,实际上,继承 Thread 类之后,会把上述的 run() 方法重写,重写后 run() 方法里直接就是所需要执行的任务,但它最终还是需要调用 thread.start() 方法来启动线程,而 start() 方法最终也会调用这个已经被重写的 run() 方法来执行它的任务,这时我们就可以彻底明白了,事实上创建线程只有一种方式,就是构造一个 Thread 类,这是创建线程的唯一方式。
1.1.6.2 实现 Runnable 接口比继承 Thread 类实现线程要好
首先,我们从代码的架构考虑,实际上,Runnable 里只有一个 run() 方法,它定义了需要执行的内容,在这种情况下,实现了 Runnable 与 Thread 类的解耦,Thread 类负责线程启动和属性设置等内容,权责分明。
综上所述,我们应该优先选择通过实现 Runnable 接口的方式来创建线程。
1.2 线程的6种状态
在 Thread 类中,定义了一个内部枚举类 State, 定义了线程的所有状态。
public enum State { // 新创建,未启动 NEW, // 可运行 RUNNABLE, // 被阻塞 BLOCKED, // 等待 WAITING, // 计时等待 TIMED_WAITING, // 被终止 TERMINATED; }
整个线程的状态流程图如下:
New 新创建、未启动:表示线程被创建但尚未启动的状态。线程被创建之后,还没有调用 start() 方法,线程的代码就还没有运行,此时的状态就是 New。
流转条件:当调用了 start() 方法之后,new 会转换为 Runnable。
Runnable 可运行:Java 中的 Runable 状态对应操作系统线程状态中的两种状态,分别是 Running 和 Ready,也就是说,Java 中处于 Runnable 状态的线程有可能正在执行,也有可能没有正在执行,正在等待被分配 CPU 资源。所以,如果一个正在运行的线程是 Runnable 状态,当它运行到任务的一半时,执行该线程的 CPU 被调度去做其他事情,导致该线程暂时不运行,它的状态依然不变,还是 Runnable,因为它有可能随时被调度回来继续执行任务。
Blocked 被阻塞:从流程图箭头的流转方向可以看出,从 Runnable 状态进入 Blocked 状态只有一种可能,就是进入 synchronized 保护的代码时没有抢到 monitor 锁,无论是进入 synchronized 代码块,还是 synchronized 方法,都是一样。
流转条件:当处于 Blocked 的线程抢到 monitor 锁,就会从 Blocked 状态回到Runnable 状态。
Waiting 等待:线程进入 Waiting 状态有三种可能性:没有设置 Timeout 参数的 Object.wait() 方法。没有设置 Timeout 参数的 Thread.join() 方法。LockSupport.park() 方法。
Blocked 与 Waiting 的区别是 Blocked 在等待其他线程释放 monitor 锁,而 Waiting 则是在等待某个条件,比如 join 的线程执行完毕,或者是 notify()、notifyAll() 。
流转条件:只有当执行了 LockSupport.unpark(),或者 join 的线程运行结束,或者被中断时才可以进入 Runnable 状态。
流转条件:如果其他线程调用 notify() 或 notifyAll() 来唤醒它,它会直接进入 Blocked 状态,需要等待拿到nomitor锁之后才能执行。
Timed Waiting 计时等待:在 Waiting 上面是 Timed Waiting 状态,这两个状态是非常相似的,区别仅在于有没有时间限制,Timed Waiting 会等待超时,由系统自动唤醒,或者在超时前被唤醒信号唤醒。以下情况会让线程进入 Timed Waiting 状态。
-
设置了时间参数的 Thread.sleep(long millis) 方法;
-
设置了时间参数的 Object.wait(long timeout) 方法;
-
设置了时间参数的 Thread.join(long millis) 方法;
-
设置了时间参数的 LockSupport.parkNanos(long nanos) 方法和 LockSupport.parkUntil(long deadline) 方法。
流转条件:Timed Waiting 中执行 notify() 和 notifyAll() 也是一样的道理,它们会先进入 Blocked 状态,然后抢夺锁成功后,再回到 Runnable 状态。
流转条件:如果它的超时时间到了且能直接获取到锁、join的线程运行结束、被中断、调用了LockSupport.unpark(),会直接恢复到 Runnable 状态,而无需经历 Blocked 状态。
Terminated 终止:要想进入这个状态有两种可能:run() 方法执行完毕,线程正常退出。出现一个没有捕获的异常,终止了 run() 方法,最终导致意外终止。
注意:
线程的状态是需要按照箭头方向来走的,比如线程从 New 状态是不可以直接进入 Blocked 状态的,它需要先经历 Runnable 状态。
线程生命周期不可逆:一旦进入 Runnable 状态就不能回到 New 状态;一旦被终止就不可能再有任何状态的变化。所以一个线程只能有一次 New 和 Terminated 状态,只有处于中间状态才可以相互转换。
1.3 线程的常用方法
1.3.1 如何正确关闭一个线程?
应该用 interrupt 来请求中断,而不是强制停止,因为这样可以避免数据错乱,也可以让线程有时间结束收尾工作。
已经被舍弃的 stop()、suspend() 和 resume(),它们由于有很大的安全风险比如死锁风险而被舍弃。
而 volatile 标注关闭状态的方式,某些特殊的情况下,比如线程被长时间阻塞的情况,就无法及时感受中断,所以 volatile 是不够全面的停止线程的方法。
1.3.2 为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?
主要有两点原因:
(1)因为 Java 中每个对象都有一把称之为 monitor 监视器的锁,由于每个对象都可以上锁,这就要求在对象头中有一个用来保存锁信息的位置。这个锁是对象级别的,而非线程级别的,wait/notify/notifyAll 也都是锁级别的操作,它们的锁属于对象,所以把它们定义在 Object 类中是最合适,因为 Object 类是所有对象的父类。
(2)因为如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。
1.3.3 wait/notify 和 sleep 方法的异同?
对比 wait/notify 和 sleep 方法的异同,主要对比 wait 和 sleep 方法,我们先说相同点:
(1)它们都可以让线程阻塞。
(2)它们都可以响应 interrupt 中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出 InterruptedException 异常。
但是它们也有很多的不同点:
(1)wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。
(2)在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁。
(3)sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复。
以上就是关于 wait/notify 与 sleep 的异同点。
1.4 线程安全
1.4.1 什么是线程安全?
事实上,Brian Goetz 想表达的意思是,如果某个对象是线程安全的,那么对于使用者而言,在使用时就不需要考虑方法间的协调问题,比如不需要考虑不能同时写入或读写不能并行的问题,也不需要考虑任何额外的同步问题,比如不需要额外自己加 synchronized 锁,那么它才是线程安全的,可以看出对线程安全的定义还是非常苛刻的。
1.4.2 线程安全的常见问题
1.4.2.1 运行结果错误
多线程同时操作一个变量导致的运行结果错误。
我们定义一个 int 类型的静态变量 i,然后启动两个线程,分别对变量 i 进行 10000 次 i++ 操作。理论上得到的结果应该是 20000,但实际结果却远小于理论结果,比如可能是12996,也可能是13323,每次的结果都还不一样,这是为什么呢?
第一个步骤是读取;第二个步骤是增加;第三个步骤是保存。
1.4.2.2 发布和初始化导致线程安全问题
对象发布和初始化时导致的线程安全问题,我们创建对象并进行发布和初始化供其他类或对象使用是常见的操作,但如果我们操作的时间或地点不对,就可能导致线程安全问题。
举个例子:一个线程负责初始化数据,另一个线程读取数据使用。当初始化还没结束,读取的数据可能就是空的。
1.4.2.3 活跃性问题
线程安全问题统称为活跃性问题,最典型的有三种,分别为死锁、活锁和饥饿。
死锁:是指两个线程之间相互等待对方资源,但同时又互不相让,都想自己先执行。
活锁:活锁与死锁非常相似,也是程序一直等不到结果,但对比于死锁,活锁是活的,什么意思呢?因为正在运行的线程并没有阻塞,它始终在运行中,却一直得不到结果。
饥饿:指线程需要某些资源时始终得不到,尤其是CPU 资源,就会导致线程一直不能运行而产生的问题。
1.4.3
访问共享变量或资源
依赖时序的操作
不同数据之间存在绑定关系
没有声明自己是线程安全的任何情况
1.4.4
缓存失效:
(1)反复创建线程系统开销比较大,每个线程创建和销毁都需要时间,如果任务比较简单,那么就有可能导致创建和销毁线程消耗的资源比线程执行任务本身消耗的资源还要大。
(2)过多的线程会占用过多的内存等资源,还会带来过多的上下文切换,同时还会导致系统不稳定。
2.1.2 线程池解决问题思路
针对上面的两点问题,线程池有两个解决思路。
首先,针对反复创建线程开销大的问题,线程池用一些固定的线程一直保持工作状态并反复执行任务。
其次,针对过多线程占用太多内存资源的问题,解决思路更直接,线程池会根据需要创建线程,控制线程的总数量,避免占用过多内存资源。
2.1.3 如何使用线程池
线程池就好比一个池塘,池塘里的水是有限且可控的,比如我们选择线程数固定数量的线程池,假设线程池有 5 个线程,但此时的任务大于 5 个,线程池会让余下的任务进行排队,而不是无限制的扩张线程数量,保障资源不会被过度消耗。如代码所示,我们往 5 个线程的线程池中放入 10000 个任务并打印当前线程名字,结果会是怎么样呢?
2.1.4 使用线程池比手动创建线程主要有三点好处
(1)线程池可以解决线程生命周期的系统开销问题,同时还可以加快响应速度。因为线程池中的线程是可以复用的,我们只用少量的线程去执行大量的任务,这就大大减小了线程生命周期的开销。而且线程通常不是等接到任务后再临时创建,而是已经创建好时刻准备执行任务,这样就消除了线程创建所带来的延迟,提升了响应速度,增强了用户体验。
(2)线程池可以统筹内存和 CPU 的使用,避免资源使用不当。线程池会根据配置和任务数量灵活地控制线程数量,不够的时候就创建,太多的时候就回收,避免线程过多导致内存溢出,或线程太少导致 CPU 资源浪费,达到了一个完美的平衡。
2.2 常见的线程池有哪些?
JDK自带的几种线程池,都可以使用 java.util.concurrent.Executors 创建。
2.2.1 FixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }
2.2.2 CachedThreadPool
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }
CahedThreadPool,可以称作可缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。
当我们提交一个任务后,线程池会判断已创建的线程中是否有空闲线程,如果有空闲线程则将任务直接指派给空闲线程,如果没有空闲线程,则新建线程去执行任务,这样就做到了动态地新增线程。
问题:最大线程数设置成了 Integer.MAX_VALUE,如果线程数量无限制的增多,会占用大量内容,并发生OOM。
2.2.3 ScheduledThreadPool
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); }
public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); }
ScheduledThreadPool,它支持定时或周期性执行任务。比如每隔 10 秒钟执行一次任务,而实现这种功能的方法主要有 3 种,如代码所示:
ScheduledExecutorService service = Executors.newScheduledThreadPool(10); service.schedule(new Task(), 10, TimeUnit.SECONDS); service.scheduleAtFixedRate(new Task(), 10, 10, TimeUnit.SECONDS); service.scheduleWithFixedDelay(new Task(), 10, 10, TimeUnit.SECONDS);
那么这 3 种方法有什么区别呢?
第一种方法 schedule 比较简单,表示延迟指定时间后执行一次任务,如果代码中设置参数为 10 秒,也就是 10 秒后执行一次任务后就结束。
第二种方法 scheduleAtFixedRate 表示以固定的频率执行任务,它的第二个参数 initialDelay 表示第一次延时时间,第三个参数 period 表示周期,也就是第一次延时后每次延时多长时间执行一次任务。
第三种方法 scheduleWithFixedDelay 与第二种方法类似,也是周期执行任务,区别在于对周期的定义,之前的 scheduleAtFixedRate 是以任务开始的时间为时间起点开始计时,时间到就开始执行第二次任务,而不管任务需要花多久执行;而 scheduleWithFixedDelay 方法以任务结束的时间为下一次循环的时间起点开始计时。
问题:最大线程数设置成了 Integer.MAX_VALUE,如果线程数量无限制的增多,会占用大量内容,并发生OOM。而 DelayedWorkQueue 也是一个无界队列。
2.2.4 SingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }
SingleThreadExecutor,它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。
问题:与 FixedThreadPool 相同,都使用了 LinkedBlockingQueue
2.2.5 SingleThreadScheduledExecutor
public static ScheduledExecutorService newSingleThreadScheduledExecutor() { return new DelegatedScheduledExecutorService(new ScheduledThreadPoolExecutor(1)); }
SingleThreadScheduledExecutor,它实际和第三种 ScheduledThreadPool 线程池非常相似,它只是 ScheduledThreadPool 的一个特例,内部只有一个线程。
问题:与 ScheduledThreadPool 相同。
2.2.6 总结
总结上述的五种线程池,我们以核心线程数、最大线程数,以及线程存活时间三个维度进行对比,如表格所示。
第一个线程池 FixedThreadPool,它的核心线程数和最大线程数都是由构造函数直接传参的,而且它们的值是相等的,所以最大线程数不会超过核心线程数,也就不需要考虑线程回收的问题,如果没有任务可执行,线程仍会在线程池中存活并等待任务。
第二个线程池 CachedThreadPool 的核心线程数是 0,而它的最大线程数是 Integer 的最大值,线程数一般是达不到这么多的,所以如果任务特别多且耗时的话,CachedThreadPool 就会创建非常多的线程来应对。
同理,你可以课后按照同样的方法来分析后面三种线程池的参数,来加深对知识的理解。
2.2.7 ForkJoinPool
如图所示,我们有一个 Task,这个 Task 可以产生三个子任务,三个子任务并行执行完毕后将结果汇总给 Result,比如说主任务需要执行非常繁重的计算任务,我们就可以把计算拆分成三个部分,这三个部分是互不影响相互独立的,这样就可以利用 CPU 的多核优势,并行计算,然后将结果进行汇总。这里面主要涉及两个步骤,第一步是拆分也就是 Fork,第二步是汇总也就是 Join,到这里你应该已经了解到 ForkJoinPool 线程池名字的由来了。
2.3 自定义线程池
2.3.1 自定义线程池的基本用法
一般我们自定义线程池,都会放到一个配置类里,并使用注解 @Bean,把线程池声明成一个Bean。
@Configuration public class ExecutorUtils { @Bean
public static AsyncTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setThreadNamePrefix("Async-Executor "); // 线程名称前缀 executor.setCorePoolSize(5); // 核心线程数 executor.setMaxPoolSize(10); // 最大线程数 executor.setKeepAliveSeconds(30000); // 空闲线程的存活时间,单位:秒 executor.setQueueCapacity(100); // 等待队列容量 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());// 拒绝策略 executor.initialize(); return executor; } public static AsyncTaskExecutor getAsyncExecutor() { return taskExecutor(); } }
自定义线程池的使用有2种方式:
(1)微服务的启动类或配置类上,使用@EnableAsync 注解,启用了 Spring 异步方法执行功能。再需要异步执行的方法上,使用 @Async 注解,异步执行的任务会自动使用这个自定义的线程池。
注意:当使用 @EnableAsync 注解后,Spring容器会自动寻找一个 TaskExecutor 实例,或一个名为 taskExecutor 的 java.util.concurrent.Executor 实例;如果找不到,就会使用默认的 SimpleAsyncTaskExecutor 线程池处理异步方法调用。 (2)我上面使用 static 定义成了静态方法,所以我们也可以显示的调用。
ExecutorUtils.getAsyncExecutor().submit(new Runnable() { @Override public void run() { } }); // 基于lambda的简写方式 ExecutorUtils.getAsyncExecutor().submit(() -> { });
2.3.2 自定义线程池的参数
threadFactory:线程工厂,它的作用是生产线程以便执行任务。我们可以选择使用默认的线程工厂,创建的线程都会在同一个线程组,并拥有一样的优先级,且都不是守护线程,我们也可以选择自己定制线程工厂,以方便给线程自定义命名,不同的线程池内的线程通常会根据具体业务来定制不同的线程名。
corePoolSize:核心线程数,线程池初始化时线程数默认为 0,当有新的任务提交后,会创建新线程执行任务,如果不做特殊设置,此后线程数通常不会再小于 corePoolSize ,因为它们是核心线程,即便未来可能没有可执行的任务也不会被销毁。
workQueue:等待队列,当任务持续增加,核心线程数处理不过来时,新的任务会被放到workQueue中,等待核心线程有空闲时,从队列提取等待被执行的任务。
maxPoolSize:最大线程数,如果 workQueue 也满了,就会按照最大线程数,持续创建新的线程数来处理新任务。
Handler :拒绝策略,当线程池达到了最大线程数的数量后,还有新的任务进来,这时应处理不了,就会启动拒绝策略。
2.3.3 线程池常用的阻塞队列
LinkedBlockingQueue:对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue,可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。这里需要注意,由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程。
SynchronousQueue:对应的线程池是 CachedThreadPool。线程池 CachedThreadPool 的最大线程数是 Integer 的最大值,可以理解为线程数是可以无限扩展的。CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反,FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展,所以 CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。
我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么就需要注意设置最大线程数要尽可能大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况。
DelayedWorkQueue:对应的线程池分别是 ScheduledThreadPool 和 SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。
2.3.4 线程池的拒绝策略
Java 在 ThreadPoolExecutor 类中为我们提供了 4 种默认的拒绝策略来应对不同的场景,都实现了 RejectedExecutionHandler 接口,如图所示:
AbortPolicy:这种拒绝策略在拒绝任务时,会直接抛出一个类型为 RejectedExecutionException 的 RuntimeException,让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。
DiscardPolicy,这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失。
DiscardOldestPolicy,如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与第二种不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险。
CallerRunsPolicy,相对而言它就比较完善了,当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。这样做主要有两点好处。
(1)第一点新提交的任务不会被丢弃,这样也就不会造成业务损失。
(2)第二点好处是,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。
2.4 定义多少线程数合适?
2.4.1 CPU 密集型任务
针对这种情况,我们最好还要同时考虑在同一台机器上还有哪些其他会占用过多 CPU 资源的程序在运行,然后对资源使用做整体的平衡。
2.4.2 耗时 IO 型任务
第二种任务是耗时 IO 型,比如数据库、文件的读写,网络通信等任务,这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。对于这种任务最大线程数一般会大于 CPU 核心数很多倍,因为 IO 读写速度相比于 CPU 的速度而言是比较慢的,如果我们设置过少的线程数,就可能导致 CPU 资源的浪费。而如果我们设置更多的线程数,那么当一部分线程正在等待 IO 的时候,它们此时并不需要 CPU 来计算,那么另外的线程便可以利用 CPU 去执行其他的任务,互不影响,这样的话在任务队列中等待的任务就会减少,可以更好地利用资源。
线程数 = CPU 核心数 *(1+平均等待时间/平均工作时间)
2.4.3 结论
综上所述我们就可以得出一个结论:
线程的平均工作时间所占比例越高,就需要越少的线程;
线程的平均等待时间所占比例越高,就需要越多的线程;
针对不同的程序,进行对应的实际测试就可以得到最合适的选择。
2.5 如何关闭线程池?
在 ThreadPoolExecutor 类中涉及关闭线程池的方法,如下所示:
void shutdown; boolean isShutdown; boolean isTerminated; boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException; List<Runnable> shutdownNow;
注意:只有在自己方法里新定义的线程池,才需要主动关闭。 在配置类里定义的线程池是不需要关闭的。
2.5.1 shutdown()
shutdown(),它可以安全地关闭一个线程池,调用 shutdown() 方法之后线程池并不是立刻就被关闭,因为这时线程池中可能还有很多任务正在被执行,或是任务队列中有大量正在等待被执行的任务,调用 shutdown() 方法后线程池会在执行完正在执行的任务和队列中等待的任务后才彻底关闭。但这并不代表 shutdown() 操作是没有任何效果的,调用 shutdown() 方法后如果还有新的任务被提交,线程池则会根据拒绝策略直接拒绝后续新提交的任务。
2.5.2 isShutdown()
isShutdown(),它可以返回 true 或者 false 来判断线程池是否已经开始了关闭工作,也就是是否执行了 shutdown 或者 shutdownNow 方法。这里需要注意,如果调用 isShutdown() 方法的返回的结果为 true 并不代表线程池此时已经彻底关闭了,这仅仅代表线程池开始了关闭的流程,也就是说,此时可能线程池中依然有线程在执行任务,队列里也可能有等待被执行的任务。
2.5.3 isTerminated()
isTerminated(),这个方法可以检测线程池是否真正“终结”了,这不仅代表线程池已关闭,同时代表线程池中的所有任务都已经都执行完毕了,因为我们刚才说过,调用 shutdown 方法之后,线程池会继续执行里面未完成的任务,不仅包括线程正在执行的任务,还包括正在任务队列中等待的任务。比如此时已经调用了 shutdown 方法,但是有一个线程依然在执行任务,那么此时调用 isShutdown 方法返回的是 true ,而调用 isTerminated 方法返回的便是 false ,因为线程池中还有任务正在在被执行,线程池并没有真正“终结”。直到所有任务都执行完毕了,调用 isTerminated() 方法才会返回 true,这表示线程池已关闭并且线程池内部是空的,所有剩余的任务都执行完毕了。
2.5.4 awaitTermination()
awaitTermination(),它本身并不是用来关闭线程池的,而是主要用来判断线程池状态的。比如我们给 awaitTermination 方法传入的参数是 10 秒,那么它就会陷入 10 秒钟的等待,直到发生以下三种情况之一:
(1)等待期间(包括进入等待状态之前)线程池已关闭并且所有已提交的任务(包括正在执行的和队列中等待的)都执行完毕,相当于线程池已经“终结”了,方法便会返回 true;
(2)等待超时时间到后,第一种线程池“终结”的情况始终未发生,方法返回 false;
(3)等待期间线程被中断,方法会抛出 InterruptedException 异常。
也就是说,调用 awaitTermination 方法后当前线程会尝试等待一段指定的时间,如果在等待时间内,线程池已关闭并且内部的任务都执行完毕了,也就是说线程池真正“终结”了,那么方法就返回 true,否则超时返回 fasle。
我们则可以根据 awaitTermination() 返回的布尔值来判断下一步应该执行的操作。
2.5.5 shutdownNow()
shutdownNow(),也是 5 种方法里功能最强大的,它与第一种 shutdown 方法不同之处在于名字中多了一个单词 Now,也就是表示立刻关闭的意思。在执行 shutdownNow 方法之后,首先会给所有线程池中的线程发送 interrupt 中断信号,尝试中断这些任务的执行,然后会将任务队列中正在等待的所有任务转移到一个 List 中并返回,我们可以根据返回的任务 List 来进行一些补救的操作,例如记录在案并在后期重试。s
2.6.1 线程复用原理
线程池可以把线程和任务进行解耦,线程归线程,任务归任务,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制。在线程池中,同一个线程可以从 BlockingQueue 中不断提取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中,不停地检查是否还有任务等待被执行,如果有则直接去执行这个任务,也就是调用任务的 run 方法,把 run 方法当作和普通方法一样的地位去调用,相当于把每个任务的 run() 方法串联了起来,所以线程数量并不增加。
接下来具体看看代码是如何实现的,我们从 execute 方法开始分析,源码如下所示:
public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); // 当先线程数小于核心线程数,通过addWorker创建新线程 if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 当前线程数大于或等于核心线程数或者 addWorker 失败了 if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); // 判断线程池状态,如果是关闭状态,那么就移除刚刚添加到任务队列中的任务,并执行拒绝策略 if (!isRunning(recheck) && remove(command)) reject(command); // 线程池是Running状态,判断线程数是否为0,0为的话就创建线程。 else if (workerCountOf(recheck) == 0) addWorker(null, false); // 尝试添加线程数,添加失败就执行拒绝策略。 } else if (!addWorker(command, false)) reject(command); }
2.6.2 线程复用源码解析
接下来判断当前线程数是否小于核心线程数,如果小于核心线程数就调用 addWorker() 方法增加一个 Worker,这里的 Worker 就可以理解为一个线程:
if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); }
同理,如果传入 false 代表增加线程时判断当前线程是否少于 maxPoolSize,小于则增加新线程,大于等于则不增加,所以这里的布尔值的含义是以核心线程数为界限还是以最大线程数为界限进行是否新增线程的判断。addWorker() 方法如果返回 true 代表添加成功,如果返回 false 代表添加失败。
我们接下来看下一部分代码:
if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); if (!isRunning(recheck) && remove(command)) reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false); } else if (!addWorker(command, false)) reject(command);
if (!isRunning(recheck) && remove(command)) reject(command);
下面我们再来看后一个 else 分支:
else if (workerCountOf(recheck) == 0) addWorker(null, false);
能进入这个 else 说明前面判断到线程池状态为 Running,那么当任务被添加进来之后就需要防止没有可执行线程的情况发生(比如之前的线程被回收了或意外终止了),所以此时如果检查当前线程数为 0,也就是 workerCountOf(recheck**) == 0,那就执行 addWorker() 方法新建线程。
我们再来看最后一部分代码:
} else if (!addWorker(command, false)) reject(command);
执行到这里,说明线程池不是 Running 状态或线程数大于或等于核心线程数并且任务队列已经满了,根据规则,此时需要添加新线程,直到线程数达到“最大线程数”,所以此时就会再次调用 addWorker 方法并将第二个参数传入 false,传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize,小于则增加新线程,大于等于则不增加,也就是以 maxPoolSize 为上限创建新的 worker;addWorker 方法如果返回 true 代表添加成功,如果返回 false 代表任务添加失败,说明当前线程数已经达到 maxPoolSize,然后执行拒绝策略 reject 方法。如果执行到这里线程池的状态不是 Running,那么 addWorker 会失败并返回 false,所以也会执行拒绝策略 reject 方法。
runWorker(Worker w) { Runnable task = w.firstTask; while (task != null || (task = getTask()) != null) { try { task.run(); } finally { task = null; } } }
可以看出,实现线程复用的逻辑主要在一个不停循环的 while 循环体中。
(1)通过取 Worker 的 firstTask 或者通过 getTask 方法从 workQueue 中获取待执行的任务。
(2)直接调用 task 的 run 方法来执行具体的任务(而不是新建线程)。
在这里,我们找到了最终的实现,通过取 Worker 的 firstTask 或者 getTask方法从 workQueue 中取出了新任务,并直接调用 Runnable 的 run 方法来执行任务,也就是如之前所说的,每个线程都始终在一个大循环中,反复获取任务,然后执行任务,从而实现了线程的复用。