一、线程池简介
在实际开发中,如果每个请求到达就创建一个新线程,开销是相当大的。服务器在创建和销毁线程上花费的时间和消耗的系统资源都相当大,甚至可能要比在处理实际的用请求的时间和资源要多的多。除了创建和销毁线程的开销之外,活动的线程也需要消耗系统资源。如果在一个jvm里创建太多的线程,可能会使系统由于过度消耗内存或“切换过度”而导致系统资源不足。这就引入了线程池概念。
线程池的核心思想就是:连接复用,通过建立一个数据库连接池以及一套连接使用、分配、管理策略,使得该线程池中的连接可以得到高效、安全的复用,避免了数据库连接频繁建立、关闭的开销。
在线程池中,它自己维护一些数据连接,需要使用的时候直接使用其中一个连接,用完之后不是关闭而是将它归还,等待其他操作。
二、线程池的实现
2.1 线程池实现简介
在Java1.5中提供了一个非常高效实用的多线程包:java.util.concurrent,提供了大量高级工具,可以帮助开发者编写高效易维护、结构清晰的Java多线程程序。这个是JDK自带实现线程池的包。
Java里面线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是ExecutorService。比较重要的几个类:
类名 | 接口 |
---|---|
ExecutorService | 真正的线程池接口。 |
ScheduledExecutorService | 能和Timer/TimerTask类似,解决那些需要任务重复执行的问题。 |
ThreadPoolExecutor | ExecutorService的默认实现(底层实现)。 |
ScheduledThreadPoolExecutor | 继承ThreadPoolExecutor的ScheduledExecutorService接口实现,周期性任务调度的类实现。 |
一个线程池包括以下四个基本组成部分:
1、线程池管理器(ThreadPool):用于创建并管理线程池,包括创建线程池,销毁线程池,添加新任务;
2、工作线程(PoolWorker):线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
3、任务接口(Task):每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等;
4、任务队列(taskQueue):用于存放没有处理的任务。提供一种缓冲机制。
要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,很有可能配置的线程池不是较优的,因此在Executors类里面提供了一些静态工厂,生成一些常用的线程池:
1.创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
ExecutorService executorService1 = Executors.newSingleThreadExecutor();
2.创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
ExecutorService executorService2 = Executors.newFixedThreadPool(10);
3.调度型线程池,调度型线程池会根据Scheduled(任务列表)进行延迟执行,或者是进行周期性的执行.适用于一些周期性的工作。
ExecutorService executorService3 = Executors.newScheduledThreadPool(10);
4.创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
ExecutorService executorService4 = Executors.newCacheThreadPool();
2.2 简单的代码示例
Executor.java
public class Executor {
public static void main(String[] args) {
//定义了线程池中最大存在的线程数目
ExecutorService executorService=Executors.newFixedThreadPool(10);
//添加一个新的任务
for (int i = 0; i < 10; i++){
executorService.execute(new Begincode());
}
executor.shutdown();
}
}
Begincode.java
//无返回值的任务就是一个实现了runnable接口的类.使用run方法
public class Begincode implements Runnable {
public void run() {
System.out.println(“Begincode--runable”);
}
}
//有返回值的任务是一个实现了callable接口的类.使用call方法
public class Begincode implements callable{
public void call() {
System.out.println(“Begincode--callable”);
}
}
代码说明:
1.ExecutorService接口对象来执行任务,该对象有两个方法可以执行任务execute和submit。
- execute这种方式提交没有返回值,也就不能判断是否执行成功。
- submit这种方式它会返回一个Future对象。通过future的get方法来获取返回值,get方法会阻塞住直到任务完成。
2.当我们不需要使用线程池的时候,我们需要对其进行关闭。有两种方法可以关闭掉线程池。
- shutdown():并不是直接关闭线程池,而是不再接受新的任务。如果线程池内有任务,那么把这些任务执行完毕后,关闭线程池。
- shutdownNow():这个方法表示不再接受新的任务,并把任务队列中的任务直接移出掉,如果有正在执行的,尝试进行停止。
2.3 阻塞队列
JDK使用了实现接口BlockingQueue的阻塞队列来存储待处理工作job,并把队列作为构造函数参数,从而实现业务可以灵活的扩展定制线程池的队列。业务也可使用JDK自身同步阻塞队列SynchronousQueue、有界队列ArrayBlockingQueue、无界队列LinkedBlockingQueue。
- SynchronousQueue是无界的,也就是说他存数任务的能力是没有限制的,但是由于该Queue本身的特性,在某次添加元素后必须等待其他线程取走后才能继续添加。如果运行的线程等于或多于 corePoolSize,则 Executor始终首选将请求加入队列,而不添加新的线程;如果无法将请求加入队列,则创建新的线程,除非创建此线程超出maximumPoolSize,在这种情况下,任务将被拒绝。
- LinkedBlockingQueue创建的线程就不会超过 corePoolSize。如果运行的线程少于 corePoolSize,则 Executor 始终首选添加新的线程,而不进行排队。如果运行的线程等于或多于 corePoolSize,则 Executor 始终首选将请求加入队列,而不添加新的线程。换句说,永远也不会触发产生新的线程!corePoolSize大小的线程数会一直运行,忙完当前的,就从队列中拿任务开始运行。所以要防止任务疯长,比如任务运行的实行比较长,而添加任务的速度远远超过处理任务的时间,而且还不断增加,不一会儿就爆了。
- ArrayBlockingQueue这个是最为复杂的使用,所以JDK不推荐使用也有些道理。与上面的相比,最大的特点便是可以防止资源耗尽的情况发生。
三、线程池带来的好处
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗;
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行;
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
- 线程池可以应对突然大爆发量的访问,通过有限个固定线程为大量的操作服务,减少创建和销毁线程所需的时间。
四、使用线程池的风险
虽然线程池是构建多线程应用程序的强大机制,但使用它并不是没有风险的。用线程池构建的应用程序容易遭受任何其它多线程应用程序容易遭受的所有并发风险,诸如同步错误和死锁,它还容易遭受特定于线程池的少数其它风险,诸如与池有关的死锁、资源不足和线程泄漏。
死锁
虽然任何多线程程序中都有死锁的风险,但线程池却引入了另一种死锁可能。这可能会导致死锁,在那种死锁中,所有线程都被一些任务所占用,而这些线程又在排队,同步等待其他任务结果,而这些任务又无法执行,因为所有的线程都很忙。
资源不足
线程消耗包括内存和其它系统资源在内的大量资源。虽然线程之间切换的调度开销很小,但如果有很多线程,环境切换也可能严重地影响程序的性能。
线程池大小决定了在指定时间内能够处理的并发请求数。如果线程池太大,那么被那些线程消耗的资源可能严重地影响系统性能。在线程之间进行切换将会浪费时间,而且使用超出比您实际需要的线程可能会引起资源匮乏问题,因为池中线程正在消耗一些资源,而这些资源可能会被其它任务更有效地利用。
除了线程自身所使用的资源以外,服务请求时所做的工作可能需要其它资源,例如 JDBC 连接、套接字或文件。这些也都是有限资源,有太多的并发请求也可能引起失效。如果一个 web 应用接收到的请求数高于线程池大小,多出来的请求将进入队列等待,或被拒绝。
并发错误
线程池和其它排队机制依靠使用 wait() 和 notify() 方法,这两个方法都难于使用。如果编码不正确,那么可能丢失通知,导致线程保持空闲状态,尽管队列中有工作要处理。使用这些方法时,必须格外小心;即便是专家也可能在它们上面出错。而最好使用现有的、已经知道能工作的实现,例如使用无须编写您自己的池中讨论的util.concurrent 包。
线程泄露
各种类型的线程池中一个严重的风险是线程泄漏,当从池中除去一个线程以执行一项任务,而在任务完成后该线程却没有返回池时,会发生这种情况。发生线程泄漏的一种情形出现在任务抛出一个 RuntimeException 或一个 Error 时。如果池类没有捕捉到它们,那么线程只会退出而线程池的大小将会永久减少一个。当这种情况发生的次数足够多时,线程池最终就为空,而且系统将停止,因为没有可用的线程来处理任务。
有些任务可能会永远等待某些资源或来自用户的输入,而这些资源又不能保证变得可用,用户可能也已经回家了,诸如此类的任务会永久停止,而这些停止的任务也会引起和线程泄漏同样的问题。如果某个线程被这样一个任务永久地消耗着,那么它实际上就被从池除去了。对于这样的任务,应该要么只给予它们自己的线程,要么只让它们等待有限的时间。
五、使用线程池的准则
①不要把那些同步等待其它任务结果的任务线程加入队列排队,因为可能引发死锁。
②在为时间可能很长的操作使用合用的线程时要小心。如果程序必须等待诸如 I/O 完成这样的某个资源,那么请指定最长的等待时间,以及随后是失效还是将任务重新排队以便稍后执行。
③根据任务相应地调整线程池大小
要有效地调整线程池大小,您需要理解正在排队的任务以及它们正在做什么。它们是 CPU 限制的(CPU-bound)吗?它们是 I/O 限制的(I/O-bound)吗?您的答案将影响您如何调整应用程序。
调整线程池的大小基本上就是避免两类错误:线程太少或线程太多。幸运的是,对于大多数应用程序来说,太多和太少之间的余地相当宽。
一个系统最快的部分是CPU,所以决定一个系统吞吐量上限的是CPU。增强CPU处理能力,可以提高系统吞吐量上限。但根据短板效应,真实的系统吞吐量并不能单纯根据CPU来计算。那要提高系统吞吐量,就需要从“系统短板”(比如网络延迟、IO)着手:
- 尽量提高短板操作的并行化比率,比如多线程下载技术
- 增强短板能力,比如用NIO替代IO
CPU密集型应用
CPU密集则是大量CPU时间都用于进行计算。需要进行矩阵运算视频解码这些操作的通常属于CPU密集。观察CPU占用的话多数时间都是出于I/O wait状态(图中绿色或黄色)。
I/O密集型应用
一个I/O密集的应用通常行为是反复去读写磁盘文件(图中蓝色)。
通常,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
一般说来,大家认为线程池的大小经验值应该这样设置:(其中N为CPU的个数)
- 如果是CPU密集型应用,则线程池大小设置为N+1
- 如果是IO密集型应用,则线程池大小设置为2N+1
如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。但是,IO优化中,确定线程池的大小相对比较复杂,涉及到下游系统的响应时间,因为一个线程常常因为等待其他系统的响应而被阻塞。所以我们必须增加线程的数量以更好地利用CPU,所以这样的估算公式可能更适合:
最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1) CPU数目*
很显然,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
六、使用线程池就一定比使用单线程高效?
答案是否定的,比如Redis就是单线程的,但它却非常高效,基本操作都能达到十万量级/s。从线程这个角度来看,部分原因在于:多线程带来线程上下文切换开销,单线程就没有这种开销。
当然“Redis很快”更本质的原因在于:Redis基本都是内存操作,这种情况下单线程可以很高效地利用CPU。而多线程适用场景一般是:存在相当比例的IO和网络操作。