线程池
简单来说使用线程池有以下几个目的:
- 线程是稀缺资源,不能频繁的创建。
- 解耦作用;线程的创建于执行完全分开,方便维护。
- 应当将其放入一个池子中,可以给其他任务进行复用。
使用线程池的好处
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
new Thread的弊端:
- 新建和销毁线程对象都比较好资源,比性能差。
- 线程缺乏统一管理,可能无限制新建线程,相互之间竞争,及可能占用过多系统资源导致死机或oom。
- 缺乏更多功能,如定时执行、定期执行、线程中断。
相比new Thread,Java提供的四种线程池的好处在于:
- 重用存在的线程,减少对象创建、消亡的开销,性能佳。
- 可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。
- 提供定时执行、定期执行、单线程、并发数控制等功能。
线程池原理
谈到线程池就会想到池化技术,其中最核心的思想就是把宝贵的资源放到一个池子中;每次使用都从里面获取,用完之后又放回池子供其他人使用,有点吃大锅饭的意思。
那在 Java 中又是如何实现的呢?
在 JDK 1.5 之后推出了相关的 api,常见的创建线程池方式有以下几种:
- Executors.newCachedThreadPool():创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程,是一个无限线程池。
- Executors.newFixedThreadPool(nThreads):创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- Executors.newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
- Executors.newSingleThreadExecutor():创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
ExecutorService基于池化的线程来执行用户提交的任务,通常可以简单的通过Executors提供的工厂方法来创建ThreadPoolExecutor实例。
线程池解决的两个问题:
1)线程池通过减少每次做任务的时候产生的性能消耗来优化执行大量的异步任务的时候的系统性能。
2)线程池还提供了限制和管理批量任务被执行的时候消耗的资源、线程的方法。另外ThreadPoolExecutor还提供了简单的统计功能,比如当前有多少任务被执行完了。
如果上面的方法创建的实例不能满足我们的需求,实际开发中也不建议使用Executors工具来创建线程池,我们可以自己通过参数来配置ThreadPoolExecutor,实例化一个线程池实例。
其实看这三种方式创建的源码就会发现:
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
实际上还是利用 ThreadPoolExecutor 类实现的。
所以我们重点来看下 ThreadPoolExecutor 是怎么玩的。
首先是创建线程的 api:
ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler)
这几个核心参数的作用:
- corePoolSize 为线程池的基本大小。
- maximumPoolSize 为线程池最大线程大小。
- keepAliveTime 和 unit 则是线程空闲后的存活时间。
- workQueue 用于存放任务的阻塞队列。
- handler 当队列和最大线程池都满了之后的饱和策略。
如何配置线程
流程聊完了再来看看上文提到了几个核心参数应该如何配置呢?
有一点是肯定的,线程池肯定是不是越大越好。
通常我们是需要根据这批任务执行的性质来确定的。
- IO 密集型任务:由于线程并不是一直在运行,所以可以尽可能的多配置线程,比如 CPU 个数 * 2
- CPU 密集型任务(大量复杂的运算)应当分配较少的线程,比如 CPU 个数相当的大小。
当然这些都是经验值,最好的方式还是根据实际情况测试得出最佳配置。
了解了这几个参数再来看看实际的运用。
通常我们都是使用:
threadPool.execute(new Job());
这样的方式来提交一个任务到线程池中,所以核心的逻辑就是 execute() 函数了。
线程池的状态
在具体分析之前先了解下线程池中所定义的状态,这些状态都和线程的执行密切相关:
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;
- RUNNING 自然是运行状态,指可以接受任务执行队列里的任务
- SHUTDOWN 指调用了 shutdown() 方法,不再接受新任务了,但是队列里的任务得执行完毕。
- STOP 指调用了 shutdownNow() 方法,不再接受新任务,同时抛弃阻塞队列里的所有任务并中断所有正在执行任务。
- TIDYING 所有任务都执行完毕,在调用 shutdown()/shutdownNow() 中都会尝试更新为这个状态。
- TERMINATED 终止状态,当执行 terminated() 后会更新为这个状态。
用图表示为:
然后看看 execute() 方法是如何处理的:
1、 获取当前线程池的状态。
2、 当前线程数量小于 coreSize 时创建一个新的线程运行。
3、 如果当前线程处于运行状态,并且写入阻塞队列成功。
4、 双重检查,再次获取线程状态;如果线程状态变了(非运行状态)就需要从阻塞队列移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
5、 如果当前线程池为空就新创建一个线程并执行。
6、 如果在第三步的判断为非运行状态,尝试新建线程,如果失败则执行拒绝策略。
线程池的处理流程
ThreadPoolExecutor会根据corePoolSize和maximumPoolSize来动态调整线程池的大小:poolSize。
当任务通过executor提交给线程池的时候:
- 如果这个时候当前池子中的工作线程数小于corePoolSize,则新创建一个新的工作线程来执行这个任务,不管工作线程集合中有没有线程是处于空闲状态。
- 如果池子中有比corePoolSize大的但是比maximumPoolSize小的工作线程,任务会首先被尝试着放入队列,这里有两种情况需要单独说一下:a、如果任务被成功的放入队列,则看看是否需要开启新的线程来执行任务,只有当当前工作线程数为0的时候才会创建新的线程,因为之前的线程有可能因为都处于空闲状态或因为工作结束而被移除。b、如果放入队列失败,则才会去创建新的工作线程。
- 如果corePoolSize和maximumPoolSize相同,则线程池的大小是固定的。
通过将maximumPoolSize设置为无限大,我们可以得到一个无上限的线程池。
除了通过构造参数设置这几个线程池参数之外我们还可以在运行时设置。
优雅的关闭线程池
当线程池不在被引用并且工作线程数为0的时候,线程池将被终止。从上文提到的 5 个状态就能看出如何来关闭线程池。
其实无非就是两个方法 shutdown() / shutdownNow()。(如果我们忘记调用 shutdown,为了让线程资源被释放,我们还可以使用keepAliveTime和allowCoreThreadTimeOut来达到目的。)
但他们有着重要的区别:
- shutdown() 执行后停止接受新任务,会把队列的任务执行完毕。
- shutdownNow() 也是停止接受新任务,但会中断所有的任务,将线程池状态变为 stop。
两个方法都会中断线程,用户可自行判断是否需要响应中断。
shutdownNow() 要更简单粗暴,可以根据实际场景选择不同的方法。
我通常是按照以下方式关闭线程池的:
long start = System.currentTimeMillis();
for (int i = 0; i <= 5; i++) {
pool.execute(new Job());
}
pool.shutdown();
while (!pool.awaitTermination(1, TimeUnit.SECONDS)) {
LOGGER.info("线程还在执行。。。");
}
long end = System.currentTimeMillis();
LOGGER.info("一共处理了【{}】", (end - start));
pool.awaitTermination(1, TimeUnit.SECONDS)
会每隔一秒钟检查一次是否执行完毕(状态为 TERMINATED),当从 while 循环退出时就表明线程池已经完全终止了。
线程池隔离
线程池看似很美好,但也会带来一些问题。
如果我们很多业务都依赖于同一个线程池,当其中一个业务因为各种不可控的原因消耗了所有的线程,导致线程池全部占满。这样其他的业务也就不能正常运转了,这对系统的打击是巨大的。
比如我们 Tomcat 接受请求的线程池,假设其中一些响应特别慢,线程资源得不到回收释放;线程池慢慢被占满,最坏的情况就是整个应用都不能提供服务。
所以我们需要将线程池 进行隔离。通常的做法是按照业务进行划分:
比如下单的任务用一个线程池,获取数据的任务用另一个线程池。这样即使其中一个出现问题把线程池耗尽,那也不会影响其他的任务运行。
线程调度策略
(1) 抢占式调度策略
Java运行时系统的线程调度算法是抢占式的。Java运行时系统支持一种简单的固定优先级的调度算法。如果一个优先级比其他任何处于可运行状态的线程都高的线程进入就绪状态,那么运行时系统就会选择该线程运行。新的优先级较高的线程抢占了其他线程。但是Java运行时系统并不抢占同优先级的线程。换句话说,Java运行时系统不是分时的。然而,基于Java Thread类的实现系统可能是支持分时的,因此编写代码时不要依赖分时。当系统中的处于就绪状态的线程都具有相同优先级时,线程调度程序采用一种简单的、非抢占式的轮转的调度顺序。
(2) 时间片轮转调度策略
有些系统的线程调度采用时间片轮转调度策略。这种调度策略是从所有处于就绪状态的线程中选择优先级最高的线程分配一定的CPU时间运行。该时间过后再选择其他线程运行。只有当线程运行结束、放弃(yield)CPU或由于某种原因进入阻塞状态,低优先级的线程才有机会执行。如果有两个优先级相同的线程都在等待CPU,则调度程序以轮转的方式选择运行的线程。
总结
线程池这块的知识的确博大精深,我也还有待继续深入学习。