引用线程池的背景:
在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在Java中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。
所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁。如何利用已有对象来服务就是一个需要解决的关键问题,其实这就是一些”池化资源”技术产生的原因。
线程池的优点:
1.重用线程池中的线程,减少因对象创建,销毁所带来的性能开销;
2.能有效的控制线程的最大并发数,提高系统资源利用率,同时避免过多的资源竞争,避免堵塞;
3.能够多线程进行简单的管理,使线程的使用简单、高效;
线程池的缺点:
并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、线程安全、死锁等问题。
并发编程三要素是什么?在 Java 程序中怎么保证多线程的运行安全?
并发编程三要素(线程的安全性问题体现在):
原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
出现线程安全问题的原因:
- 线程切换带来的原子性问题
- 缓存导致的可见性问题
- 编译优化带来的有序性问题
解决办法:
- JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
- synchronized、volatile、LOCK,可以解决可见性问题
- Happens-Before 规则可以解决有序性问题
Java中创建线程池有2种方式,使用ThreadPoolExecutor类或者使用Executors类,其实这两种方式在本质上是一样的,都是通过ThreadPoolExecutor类的构造函数创建的,我们来分析一个参数最多的构造函数。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.acc = System.getSecurityManager() == null ?
null :
AccessController.getContext();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
(1)corePoolSize:线程池中的核心线程数。当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize。如果当前线程数为corePoolSize,继续提交的任务被保存到队列中,等待被执行。如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。
(2)maximumPoolSize:线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize。
(3)keepAliveTime:线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间。默认情况下,该参数只在线程数大于corePoolSize时才有用。
(4)TimeUnit:keepAliveTime的时间单位。
(5)workQueue:用于保存等待执行任务的阻塞队列,当线程池中的线程数超过它的corePoolSize的时候,线程会进入阻塞队列进行等待。一般来说,我们应该尽量使用有界队列,因为使用无界队列作为工作队列会对线程池带来如下影响。
a)当线程池中的线程数达到corePoolSize后,新任务将在无界队列中等待,因此线程池中的线程数不会超过corePoolSize。
b)由于a,使用无界队列时maximumPoolSize将是一个无效参数。
c)由于a和b,使用无界队列时keepAliveTime将是一个无效参数。
d)更重要的,使用无界队列可能会耗尽系统资源,有界队列则有助于防止资源耗尽,同时即使使用有界队列,也要尽量控制队列的大小在一个合适的范围。
所以我们一般会使用:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue。
(6)threadFactory:创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名,还可以将所有的线程设置为守护线程。Executors静态工厂里默认的ThreadFactory,线程的命名规则是“pool-数字-thread-数字”。
(7)handler:线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
a)AbortPolicy:直接抛出异常,默认策略。
b)CallerRunsPolicy:用调用者所在的线程来执行任务。
c)DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务。
d)DiscardPolicy:直接丢弃任务。
当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。根据以上参数分析我们可以得出一个使用线程池的技巧:如果确定并发量有限,并且每个线程占用的内存大小有限,使用Executors建立线程池;如果并发量没有办法控制,并且每个线程占用的内存大小无法确定较小,那么需要使用ThreadPoolExecutor的方式来创建线程。
线程池的工作机制
线程池的工作机制,可以分成4个阶段来说:
1)如果当前运行的线程少于corePoolSize,则创建新线程来执行任务。
2)如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
3)如果无法将任务加入BlockingQueue(有界队列已满),则创建新的线程来处理任务。
4)如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。
用一张图进行说明:
线程复用的过程:
合理地配置线程池
要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析:
(1)任务的性质:CPU密集型任务(大部份时间用来做计算、逻辑判断等操作)、IO密集型任务(大部分的状况是CPU在等I/O 的读/写操作)和混合型任务。
(2)任务的优先级:高、中和低。
(3)任务的执行时间:长、中和短。
(4)任务的依赖性:是否依赖其他系统资源,如数据库连接。
性质不同的任务可以用不同规模的线程池分开处理。先来分析(1)任务的性质:
a)CPU密集型任务应配置尽可能小的线程,如配置服务器CPU个数+1个线程的线程池。服务器CPU个数可以通过Runtime.getRuntime().availableProcessors()方法获得。
b)由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如服务器CPU个数的2倍。对于IO型的任务的最佳线程数,有个公式可以计算:
Nthreads = NCPU * UCPU * (1 + W/C)
其中:
NCPU是处理器的核的数目
UCPU是期望的CPU利用率(该值应该介于0和1之间)
W/C是等待时间与计算时间的比率
等待时间与计算时间我们在Linux下使用相关的vmstat命令或者top命令查看。
c)对于混合型任务,如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。
分析(2)任务的优先级:
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理,它可以让优先级高的任务先执行。
分析(3)任务的执行时间:
执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行。
分析(4)任务的依赖性:
依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。
最后,不管从哪个角度来分析,都建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点儿,比如5000。如果当时我们设置成无界队列,那么线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。
JDK预定义5种线程池
在JDK1.8中,已经预定义了5种线程池,除了WorkStealingPool是JDK1.8新加入的,其余4个在JDK1.5就有了。5种预定义线程池的使用都非常简单,就不一一用代码演示了,下面分别简单的介绍:
FixedThreadPool
创建使用固定线程数的FixedThreadPool的API。适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,它适用于负载比较重的服务器。FixedThreadPool的corePoolSize和maximumPoolSize都被设置为创建FixedThreadPool时指定的参数nThreads。当线程池中的线程数大于corePoolSize时,keepAliveTime为多余的空闲线程等待新任务的最长时间,超过这个时间后多余的线程将被终止。这里把keepAliveTime设置为0L,意味着多余的空闲线程会被立即终止。FixedThreadPool使用有界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)。
SingleThreadExecutor
创建使用单个线程的SingleThreadExecutor的API,于需要保证顺序地执行各个任务;并且在任意时间点,不会有多个线程是活动的应用场景。corePoolSize和maximumPoolSize被设置为1,其他参数与FixedThreadPool相同。SingleThreadExecutor使用有界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)。
CachedThreadPool
创建一个会根据需要创建新线程的CachedThreadPool的API。大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器。corePoolSize被设置为0,即corePool为空;maximumPoolSize被设置为Integer.MAX_VALUE。这里把keepAliveTime设置为60L,意味着CachedThreadPool中的空闲线程等待新任务的最长时间为60秒,空闲线程超过60秒后将会被终止。FixedThreadPool和SingleThreadExecutor使用有界队列LinkedBlockingQueue作为线程池的工作队列。CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但CachedThreadPool的maximumPool是无界的。这意味着,如果主线程提交任务的速度高于maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下,CachedThreadPool会因为创建过多线程而耗尽CPU和内存资源。
WorkStealingPool
JDK1.8加入的,利用所有运行的处理器数目来创建一个工作窃取的线程池,使用forkjoin实现,如果对forkjoin不熟悉,可以去看一下之前的《java线程并发工具类》。看下WorkStealingPool源码:
public static ExecutorService newWorkStealingPool() {
return new ForkJoinPool
(Runtime.getRuntime().availableProcessors(),
ForkJoinPool.defaultForkJoinWorkerThreadFactory,
null, true);
}
ScheduledThreadPoolExecutor
Executors可以创建2种类型的ScheduledThreadPoolExecutor,如下:
(1)ScheduledThreadPoolExecutor,包含若干个线程的ScheduledThreadPoolExecutor,适用于需要多个后台线程执行周期任务,同时为了满足资源管理的需求而需要限制后台线程的数量的应用场景。
(2)SingleThreadScheduledExecutor,只包含一个线程的ScheduledThreadPoolExecutor,适用于需要单个后台线程执行周期任务,同时需要保证顺序地执行各个任务的应用场景。