背景图

线程池与调优

概述

对于高并发来说,不得不说异步,说到异步就不得不说线程了。涉及到线程就会讲到线程的状态和线程上下文已经CPU的线程调度问题,以及线程的并发错误控制问题。不过我们今天来说得是线程池的问题,因为线程池本身具有以下几个作用:

  • 稳流,队列可以做到初级的削峰填谷的功能。
  • 可以实现线程共用,因为创建线程和销毁线程的开销是比较巨大的。

线程池组成部分

一个比较简单的线程池至少应包含线程池管理器、工作线程、任务列队、任务接口等部分。其中线程池管理器的作用是创建、销毁并管理线程池,将工作线程放入线程池中;工作线程是一个可以循环执行任务的线程,在没有任务是进行等待;任务列队的作用是提供一种缓冲机制,将没有处理的任务放在任务列队中;任务接口是每个任务必须实现的接口,主要用来规定任务的入口、任务执行完后的收尾工作、任务的执行状态等,工作线程通过该接口调度任务的执行。

线程池管理器至少有下列功能:创建线程池,销毁线程池,添加新任务。

工作线程是一个可以循环执行任务的线程,在没有任务时将等待。

任务接口是为所有任务提供统一的接口,以便工作线程处理。任务接口主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等。

应用场景

当一个服务器接受到大量短小线程的请求时,使用线程池技术是非常合适的,它可以大大减少线程的创建和销毁次数,提高服务器的工作效率。

综上所述:java提供的线程池的好处分为以下三点:
a. 重用存在的线程,减少对象创建、消亡的开销,性能佳。
b. 可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。
c. 提供定时执行、定期执行、单线程、并发数控制等功能。

几个关键的类

Java里面线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是ExecutorService。

类名 说明
ExecutorService 真正的线程池接口。
ScheduledExecutorService 能和Timer/TimerTask类似,解决那些需要任务重复执行的问题。
ThreadPoolExecutor ExecutorService的默认实现。
ScheduledThreadPoolExecutor 继承ThreadPoolExecutor的ScheduledExecutorService接口实现,周期性任务调度的类实现。

Java线程池创建方式

newCachedThreadPool

作用:创建一个可根据需要创建新线程的线程池,但是在以前构造的线程可用时将重用它们,并在需要时使用提供的 ThreadFactory 创建新线程。

特征:

  • 线程池中数量没有固定,可达到最大值(Interger.MAX_VALUE)
  • 线程池中的线程可进行缓存重复利用和回收(回收默认时间为1分钟)
  • 当线程池中,没有可用线程,会重新创建一个线程

创建方式: Executors.newCachedThreadPool();

newFixedThreadPool

作用:创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。在任意点,在大多数 nThreads 线程会处于处理任务的活动状态。如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。

特征:

  • 线程池中的线程处于一定的量,可以很好的控制线程的并发量
  • 线程可以重复被使用,在显示关闭之前,都将一直存在
  • 超出一定量的线程被提交时候需在队列中等待

创建方式:

1
2
Executors.newFixedThreadPool(int nThreads);//nThreads为线程的数量 
Executors.newFixedThreadPool(int nThreads,ThreadFactory threadFactory);//nThreads为线程的数量,threadFactory创建线程的工厂方式

newSingleThreadExecutor

作用:创建一个使用单个 worker 线程的 Executor,以无界队列方式来运行该线程。(注意,如果因为在关闭前的执行期间出现失败而终止了此单个线程,那么如果需要,一个新线程将代替它执行后续的任务)。可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。与其他等效的 newFixedThreadPool(1) 不同,可保证无需重新配置此方法所返回的执行程序即可使用其他的线程。

特征:

  • 线程池中最多执行1个线程,之后提交的线程活动将会排在队列中以此执行

创建方式:

1
2
Executors.newSingleThreadExecutor();
Executors.newSingleThreadExecutor(ThreadFactory threadFactory); // threadFactory创建线程的工厂方式

newScheduledThreadPool

作用: 创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。

特征:

  • 线程池中具有指定数量的线程,即便是空线程也将保留
  • 可定时或者延迟执行线程活动

创建方式:

1
2
Executors.newScheduledThreadPool(int corePoolSize);// corePoolSize线程的个数 
newScheduledThreadPool(int corePoolSize, ThreadFactory threadFactory);// corePoolSize线程的个数,threadFactory创建线程的工厂

示例:表示延迟1秒后每3秒执行一次。

1
2
3
4
5
6
scheduledThreadPool.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
System.out.println("delay 1 seconds, and excute every 3 seconds");
}
}, 1, 3, TimeUnit.SECONDS);

newSingleThreadScheduledExecutor

作用:创建一个单线程执行程序,它可安排在给定延迟后运行命令或者定期地执行。

特征:

  • 线程池中最多执行1个线程,之后提交的线程活动将会排在队列中以此执行
  • 可定时或者延迟执行线程活动

创建方式:

1
2
Executors.newSingleThreadScheduledExecutor() ; 
Executors.newSingleThreadScheduledExecutor(ThreadFactory threadFactory) ;//threadFactory创建线程的工厂

阿姆达尔定律

要说道计算机的调优我们就不得不说一下Amdahl’s Law;阿姆达尔定律(英语:Amdahl’s law,Amdahl’s argument),一个计算机科学界的经验法则,因吉恩·阿姆达尔(Gene Amdahl)而得名。它代表了处理器平行运算之后效率提升的能力。

1967年计算机体系结构专家吉恩.阿姆达尔提出过一个定律阿姆达尔定律,说:在并行计算中用多处理器的应用加速受限于程序所需的串行时间百分比。譬如说,你的程序50%是串行的,其他一半可以并行,那么,最大的加速比就是2。不管你用多少处理器并行,这个加速比不可能提高。在这种情况下,改进串行算法可能比多核处理器并行更有效。

公式

并行计算中的加速比是用并行前的执行速度并行后的执行速度之比来表示的,它表示了在并行化之后的效率提升情况。

阿姆达尔定律是固定负载(计算总量不变时)时的量化标准。可用下面的公式来表示:

这个公式中Ws,Wp分别表示问题规模的串行分量(问题中不能并行化的那一部分)和并行分量,p表示处理器数量。

只要注意到当 p→∞(p趋近无穷大) 时,上式的极限是W/Ws,其中,W = Ws + Wp。这意味着无论我们如何增大处理器数目,加速比是无法高于这个数的。

或者也可以用下面的公式表示:

根据该公式可以推导出:

如果我们针对并行计算,上面的公式代表:

  • Slatency代表理论上的加速比
  • s 为并行处理结点个数
  • p 为并行计算部分所占比例
  • 1-p 为串行计算部分所占比例

这样,当p=1时,最大加速比p=s;当p=0时,最小加速比S=1;当s→∞时,极限加速比S→ 1/(1-p),这也就是加速比的上限。例如,若加速前并行代码执行时间占整个代码的执行时间的75%(p=0.75),则加速后并行处理的总体性能的提升不可能超过原先的4倍。

Amdahl’s law表明在问题的可并行部分占比不大时,增加处理机的数量并不能显著地加快解决问题的时间。

阿姆达尔定律的结论让人沮丧,但到了20世纪80年代晚期,Sandia国家实验室的科学家们在对具有1024个处理器的超立方体结构上观察到了3个实际应用程序随着处理器的增加发生线性加速的现象,科学家John L. Gustafson基于此实验数据在1988年提出了一个新的计算加速系数的公式:

这其中:

  • Slatency代表理论上的加速比
  • s 为并行处理结点个数
  • p 为并行计算部分所占比例

Gustafson定律说明在许多实际的应用程序中得到接近线性的加速效果是可能的。

阿姆达尔定律的问题出在它的前提过于理想化。因为并行算法通常能处理比串行算法更大规模的问题,即使算法仍然存在着串行部分,但由于问题规模的不断扩大,往往会导致算法中串行部分所占比例的持续减少。

这里面需要注意的是如果有公共资源的访问,那么就是会产生串行执行,因为你要加锁进行处理。如果你能完全将任务拆分互不影响,那么你的程序执行就会很快。

线程调优

本文最要的部分就是这块内容了。

确定线程数

对于最合理的线程数一般没有特别定义,主要有几个方法来确定。

一个比较简单的但是不知可行性的方法如下:

  • 如果是CPU密集型应用,则线程池大小设置为N+1
  • 如果是IO密集型应用,则线程池大小设置为2N+1

如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。

当然也有文档中给出这样的一个计算公式:

最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

这个公式进一步转化为:

最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)* CPU数目

一个系统最快的部分是CPU,所以决定一个系统吞吐量上限的是CPU。增强CPU处理能力,可以提高系统吞吐量上限。但根据短板效应,真实的系统吞吐量并不能单纯根据CPU来计算。那要提高系统吞吐量,就需要从“系统短板”(比如网络延迟、IO)着手:

  • 尽量提高短板操作的并行化比率,比如多线程下载技术(这一条可以联系到Amdahl定律,提高并发度)
  • 增强短板能力,比如用NIO替代IO

线程池的设置

参数介绍

线程池的设置中最重要的就是三个部分,下面简单列一下说明:

配置项 说明
corePoolSize 核心线程会一直存活,及时没有任务需要执行;当线程数小于核心线程数时,即使有线程空闲,线程池也会优先创建新线程处理;设置allowCoreThreadTimeout=true(默认false)时,核心线程会超时关闭
queueCapacity 任务队列容量(阻塞队列);当核心线程数达到最大时,新任务会放在队列中排队等待执行.注意队列的模式FIFO(队列),LIFO(堆栈)
maxPoolSize 最大线程数,当线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务;当线程数<=maxPoolSize,且任务队列已满时,线程池会拒绝处理任务而抛出异常
keepAliveTime 线程空闲时间; 当线程空闲时间达到keepAliveTime时,线程会退出,直到线程数量=corePoolSize;如果allowCoreThreadTimeout=true,则会直到线程数量=0
allowCoreThreadTimeout 允许核心线程超时
rejectedExecutionHandler 任务拒绝处理器

这里要简单介绍下,线程拒绝的情况,一般有两种:

  • 当线程数已经达到maxPoolSize,并且队列已满,会拒绝新任务.
  • 当线程池被调用shutdown()后,会等待线程池里的任务执行完毕,再shutdown。如果在调用shutdown()和线程池真正shutdown之间提交任务,会拒绝新任务.

当线程被拒绝时,线程池会调用rejectedExecutionHandler来处理这个任务。如果没有设置默认是AbortPolicy,会抛出异常。ThreadPoolExecutor类有几个内部实现拒绝策略实现类来处理这类情况:

  • AbortPolicy 丢弃任务,抛运行时异常
  • CallerRunsPolicy 执行任务
  • DiscardPolicy 忽视,什么都不会发生
  • DiscardOldestPolicy 从队列中踢出最先进入队列(最后一个执行)的任务

实现RejectedExecutionHandler接口,可自定义处理器。

线程池按以下行为执行任务:

  • 当线程数小于核心线程数时,创建线程。
  • 当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列。
  • 当线程数大于等于核心线程数,且任务队列已满
    • 若线程数小于最大线程数,创建线程
    • 若线程数等于最大线程数,抛出异常,拒绝任务

如何设置参数

我们先来假设几个值:

  • tasks :每秒的任务数,假设为500~1000
  • taskcost:每个任务花费时间,假设为0.1s
  • responsetime:系统允许容忍的最大响应时间,假设为1s

做几个计算:

  • corePoolSize = 每秒需要多少个线程处理? (这个算法不是很靠谱)
    threadcount = tasks/(1/taskcost) =taskstaskcout = (500~1000)0.1 = 50~100 个线程。corePoolSize设置应该大于50。
    根据8020原则,如果80%的每秒任务数小于800,那么corePoolSize设置为80即可。
  • queueCapacity = (coreSizePool/taskcost)responsetime
    计算可得 queueCapacity = 80/0.1
    1 = 80。意思是队列里的线程可以等待1s,超过了的需要新开线程来执行
    切记不能设置为Integer.MAX_VALUE,这样队列会很大,线程数只会保持在corePoolSize大小,当任务陡增时,不能新开线程来执行,响应时间会随之陡增。
  • maxPoolSize = (max(tasks)- queueCapacity)/(1/taskcost)
    计算可得 maxPoolSize = (1000-80)/10 = 92
    (最大任务数-队列容量)/每个线程每秒处理能力 = 最大线程数
  • rejectedExecutionHandler:根据具体情况来决定,任务不重要可丢弃,任务重要则要利用一些缓冲机制来处理
  • keepAliveTime和allowCoreThreadTimeout采用默认通常能满足

以上都是理想值,实际情况下要根据机器性能来决定。如果在未达到最大线程数的情况机器cpu load已经满了,则需要通过升级硬件(呵呵)和优化代码,降低taskcost来处理。

参考:如何合理地估算线程池大小?

0%