线程池的概念在第6、7章已经反复出现多次,因为Executor框架的实现需要线程池来执行任务;这一章详细介绍如何配置线程池。
任务和执行策略
前面提到,Excecutor框架将任务的提交和执行分离,实现了二者之间的解耦。但在实际项目中,任务之间可能存在关联或其他约束,并不能在任何执行策略下保证正确性。
- 非独立任务:
独立任务可适应任何执行策略,是程序具有最好的可扩展性。在执行过程中,你可以随意地调整线程池的大小以适应环境获得更好的性能。如果任务之间存在依赖关系,必须对执行策略有所限制来避免活性失败。
- 线程封闭的任务
假如任务使用的资源采用了线程封闭的安全策略,那么这些任务就要求单线程的执行策略。
- 影响时间敏感的任务
有些任务对时间非常敏感,比如GUI程序的事件处理任务,向线程池提一个交耗时任务,可能阻碍所有任务的响应性。
- 使用ThreadLocal任务
ThreadLocal使得每个线程可以拥有同一数据状态的私有副本,但是对线程池内的线程来说,它不断地执行不同的任务,它的生命周期变化不定。如果要在线程池内使用ThreadLocal,ThreadLocal的的生命周期必须与任务一致,它不能作为任务之间的通讯手段。
Excecutor框架提供很灵活的执行策略配置,以支持上述需求。
线程饥饿死锁
如果任务依赖另一个线程池任务,有发生死锁的风险。比如在一个单线程的Excecutor中,一个任务向同一个线程池提交一个任务,然后等待它的完成,就必然导致死锁。
在一个大型线程池内,这种死锁也有可能发生,比如所有的执行中的任务,都在处于block状态等待某个任务,而被等待的任务正在处于任务队列中。
长时任务
一个会长时间阻塞的任务,可能阻塞整个线程池。执行中的任务必然占用一个线程,线程是线程池的资源,一旦没有可用线程,那些短任务也没有机会执行,损害了整个系统的响应性。
线程池大小
理想的线程池大小取决于任务类型和计算资源,线程池的大小不应该被硬编码,应该参考CPU核数、内存容量等进行动态配置,要考虑的因素如下:
- 可用的CPU核数;
- 内存容量
- 任务的性质,CPU计算和IO等待的时间比例;
- 任务是否受限于其它有限资源,比如JDBC连接;
CPU使用率
对于计算密集型的任务系统来说,Ncpu处理系统下达到最大的吞吐率,线程数为Ncpu+1(因为再忙碌的线程偶尔也会被缺页等原因中断,一个额外的线程是必要的)。
如果线程有大量的IO或其他阻塞操作,那么需要一个更大的线程池。为了更准确一点,你需要测量阻塞时间和CPU计算时间的比率,具体的推理过程如下:
Ncpu = CPU核数
Ucpu = 目标CPU使用率,0<=Ucpu<=1
W/C = 任务阻塞时间和计算时间的比例
在有足够任务的情况下,使CPU保持在目标使用率的线程数如下:
Nthreads = Ncpu ∗ Ucpu ∗ ( 1 + W/C)
在java里, Ncpu = Runtime.getRuntime().availableProcessors()。
资源限制
CPU不是任务使用的唯一资源,其他限制包括内存,打开文件数,socket连接数,数据库连接数等。不过对这些资源的计算相对比较简单,将总数除以每个任务平均占用数即可。
为不同的任务单设线程池
由于不同的任务的资源使用情况完全不同,最好为他们设置不同的线程池,先在不同的线程池之间做一个资源分配规划,然后再分别计算理想的线程池大小。
不过线程池的调整并不是一个要求精确度的工作,绝大多数情况下,只要别太大或太小就可以了。
ThreadPoolExecutor
ThreadPoolExecutor是java并发库提供Executor实现,它同时也是一个非常灵活、久经考验的线程池。Executors的工厂方法newCachedThreadPool, newFixedThreadPool和newScheduledThreadExecutor创建几种预定义策略的ThreadPoolExecutor对象。如果不满需求,可以直接创建ThreadPoolExecutor,并进行定制。
ThreadPoolExecutor的最完整的构造函数如下:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) { ... }
线程的创建和销毁
corePoolSize,maximumPoolSize和keepAliveTime这几个参数空了线程的创建和销毁策略。
-
corePoolSize:线程池想要维持的目标线程数;
- 不会一启动就创建这么多线程,而是随着任务的进入逐步创建
- 一旦数量足够,尽量不再创建新的线程
- 该数量范围内的线程也不会主动销毁
-
maximumPoolSize:线程池的最大线程数
- 这个是线程池最大的线程数,当任务很多,把任务队列塞满了,此时会创建额外的线程来工作,总数不会超过maximumPoolSize
-
keepAliveTime:线程保活时间
- corePoolSize之外的线程空闲超过该时间,会被销毁。
任务队列
上面的参数控制了线程池可并发执行的任务数,当任务提交的速度超过执行的速度,那么会放入一个队列中,workQueue参数控制了任务队列的策略:
- 队列是有限的还是无限的?
- 队列中的任务优先级如何?
队列容量
Executors.newFixedThreadPool和newSingleThreadExecutor创建的线程池使用无限的LinkedBlockingQueue。更合理的方式是使用一个容量受限的任务队列,如ArrayBlockingQueue。
SynchronousQueue是一个特殊队列,它没有内部存储空间,如果使用它来做任务队列,效果就是提交一个任务要求立即有线程来执行它,否则提交请求被拒绝。所以使用SynchronousQueue,要么线程池是无限大的,要么任务被拒绝是可接受的。Executors.newCachedThreadPool创建的线程池使用了SynchronousQueue,它的线程数被配置为无限的。
任务优先级
FIFO队列,LinkedBlockingQueue和ArrayBlockingQueue,按任务提交的顺序来执行它们。PriorityBlockingQueue支持按特定的优先级来调度任务。
饱和策略
如果一个有限的任务度列被填满,此时饱和策略派上用场。饱和策略用RejectedExecutionHandler接口来表达,即上面
ThreadPoolExecutor构造方法的最后一个参数。JAVA并发库提供了集中默认的饱和策略实现:
- AbortPolicy:默认策略,任务提交抛出RejectedExecutionException异常;
- DiscardPolicy:默默地丢弃新提交的任务;
- DiscardOldestPolicy:丢弃任务队列中下一个被执行的任务;
- CallerRunsPolicy:在任务提交线程执行任务;
CallerRunsPolicy实现了一种限流机制,在线程池饱和时,让提交任务的线程来执行任务,它就无暇继续提交新任务了。
注意,ThreadPoolExecutor没有任何一种策略能够让提交任务的方法阻塞,直至任务队列不满。
ThreadFactory
ThreadPoolExecutor创建线程使用ThreadFactory,我们提供自己的ThreadFactory实现有以下几个可能的动机:
- 为线程设置UncaughtExceptionHandler;
- 使用自定义的Thread类;
- 修改Thread优先级;
- 给Thread一个更友好的命名;
ThreadPoolExecutor动态修改配置
ThreadPoolExecutor的很多配置可以在运行过程中动态修改,比如corePoolSize,maximumPoolSize等,自己去看看有哪些setter方法即可。
Executors有一个工厂方法unconfigurableExecutorService能够将现有的ThreadPoolExecutor包裹为不可修改的。Executors.newSingleThreadExecutor方法使用了这种方式,因为单线程的线程池不会并发执行任务,将其动态修改为多线程版本破坏了这个策略,因此不被允许。
继承ThreadPoolExecutor
ThreadPoolExecutor被设计为可以继承的,提供了若干hook方法供覆盖:
- beforeExecute:在任务线程执行,如果该方法抛出RuntimeException异常,任务被取消,afterExecute也不会被调用;
- afterExecute:在任务线程执行,任务正常执行完毕或抛出异常,都会调用;
- terminated:线程池关闭调用,意味着所有任务执行完毕,所有工作线程被销毁。
通过这些hook方法,可实现诸如统计、日志的功能,下面的TimingThreadPool实现任务的计时统计:
public class TimingThreadPool extends ThreadPoolExecutor {
private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
private final Logger log = Logger.getLogger("TimingThreadPool");
private final AtomicLong numTasks = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
startTime.set(System.nanoTime());
}
protected void afterExecute(Runnable r, Throwable t) {
try {
long endTime = System.nanoTime();
long taskTime = endTime - startTime.get();
numTasks.incrementAndGet();
totalTime.addAndGet(taskTime);
log.fine(String.format("Thread %s: end %s, time=%dns",t, r, taskTime));
} finally {
super.afterExecute(r, t);
}
}
protected void terminated() {
try {
log.info(String.format("Terminated: avg time=%dns",totalTime.get() / numTasks.get()));
} finally {
super.terminated();
}
}
}
总结
Executor框架是一个强大且灵活的并发任务执行框架,它提供了非常细粒度的配置选项,不同的配置项组合能够形成不同的任务执行策略和线程管理策略。但是请注意,并非任意配置选项组合都能很好地工作,如果配置不当,会产生非常怪异的行为。