工作中怎么使用线程池?

关于怎么使用工具类的线程池看:https://www.jianshu.com/p/ae67972d1156https://www.cnblogs.com/zincredible/p/10984459.html

但是我们工作中不用这四个线程池,因为这四种线程池不一定适合我们生产环境中的线程池配置,如果使用了这四种线程池,可能会出现阻塞、内存溢出等情况。

所以我们这篇来讲讲怎么合理的根据工作常见来自定义我们的线程池(即自定义设置7个参数的值)

一、基本参数

1、线程池的创建

我们可以通过ThreadPoolExecutor来创建一个线程池。

	new ThreadPoolExecutor(corePoolSize, maximumPoolSize,
keepAliveTime, milliseconds,runnableTaskQueue, threadFactory,handler);


创建一个线程池需要输入几个参数:

  • corePoolSize(线程池的基本大小):当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads方法,线程池会提前创建并启动所有基本线程。
  • runnableTaskQueue(任务队列):用于保存等待执行的任务的阻塞队列。可以选择以下几个阻塞队列。(阻塞队列能够暂时缓存新到任务,作为新建线程的缓冲池。)
  1. LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
  2. SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
  3. PriorityBlockingQueue:一个具有优先级得无限阻塞队列。
  • maximumPoolSize(线程池最大大小):线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是如果使用了无界的任务队列这个参数就没什么效果。
  • ThreadFactory:用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字,Debug和定位问题时非常又帮助。
  • RejectedExecutionHandler(饱和策略):当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法处理新任务时抛出异常。以下是JDK1.5提供的四种策略。n  AbortPolicy:直接抛出异常。
  1. CallerRunsPolicy:只用调用者所在线程来运行任务。
  2. DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
  3. DiscardPolicy:不处理,丢弃掉。
  4. 当然也可以根据应用场景需要来实现RejectedExecutionHandler接口自定义策略。如记录日志或持久化不能处理的任务。
  • keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以如果任务很多,并且每个任务执行的时间比较短,可以调大这个时间,提高线程的利用率。
  • TimeUnit(线程活动保持时间的单位):可选的单位有天(DAYS),小时(HOURS),分钟(MINUTES),毫秒(MILLISECONDS),微秒(MICROSECONDS, 千分之一毫秒)和毫微秒(NANOSECONDS, 千分之一微秒)。

向线程池提交任务

我们可以使用execute提交的任务,但是execute方法没有返回值,所以无法判断任务知否被线程池执行成功。通过以下代码可知execute方法输入的任务是一个Runnable类的实例。


threadsPool.execute(new Runnable() { 
       @Override
      public void run() {
          // TODO Auto-generated method stub
        }
});
 

我们也可以使用submit 方法来提交任务,它会返回一个future,那么我们可以通过这个future来判断任务是否执行成功,通过future的get方法来获取返回值,get方法会阻塞住直到任务完成,而使用get(long timeout, TimeUnit unit)方法则会阻塞一段时间后立即返回,这时有可能任务没有执行完。

try {
   Object s = future.get();
} catch (InterruptedException e) {
        // 处理中断异常
} catch (ExecutionException e) {
       // 处理无法执行任务异常
} finally {
     // 关闭线程池
     executor.shutdown();
}
 

线程池的关闭

我们可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池,但是它们的实现原理不同,shutdown的原理是只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。shutdownNow会首先将线程池的状态设置成STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表。

只要调用了这两个关闭方法的其中一个,isShutdown方法就会返回true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。至于我们应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow。

2.    合理的配置线程池

要想合理的配置线程池,就必须首先分析任务特性,可以从以下几个角度来进行分析:

  1. 任务的性质:CPU密集型任务,IO密集型任务和混合型任务。
  2. 任务的优先级:高,中和低。
  3. 任务的执行时间:长,中和短。
  4. 任务的依赖性:是否依赖其他系统资源,如数据库连接。

任务性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务配置尽可能少的线程数量,如配置Ncpu+1个线程的线程池。IO密集型任务则由于需要等待IO操作,线程并不是一直在执行任务,则配置尽可能多的线程,如2*Ncpu。混合型的任务,如果可以拆分,则将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率,如果这两个任务执行时间相差太大,则没必要进行分解。我们可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。

优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先得到执行,需要注意的是如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。

执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。

依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,如果等待的时间越长CPU空闲时间就越长,那么线程数应该设置越大,这样才能更好的利用CPU。

建议使用有界队列,有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点,比如几千。有一次我们组使用的后台任务线程池的队列和线程池全满了,不断的抛出抛弃任务的异常,通过排查发现是数据库出现了问题,导致执行SQL变得非常缓慢,因为后台任务线程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻塞住,任务积压在线程池里。如果当时我们设置成无界队列,线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。当然我们的系统所有的任务是用的单独的服务器部署的,而我们使用不同规模的线程池跑不同类型的任务,但是出现这样问题时也会影响到其他任务。

3.    线程池的监控

通过线程池提供的参数进行监控。线程池里有一些属性在监控线程池的时候可以使用

  • taskCount:线程池需要执行的任务数量。
  • completedTaskCount:线程池在运行过程中已完成的任务数量。小于或等于taskCount。
  • largestPoolSize:线程池曾经创建过的最大线程数量。通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
  • getPoolSize:线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以这个大小只增不减。
  • getActiveCount:获取活动的线程数。

通过扩展线程池进行监控。通过继承线程池并重写线程池的beforeExecute,afterExecute和terminated方法,我们可以在任务执行前,执行后和线程池关闭前干一些事情。如监控任务的平均执行时间,最大执行时间和最小执行时间等。这几个方法在线程池里是空方法。如

protected void beforeExecute(Thread t, Runnable r) { }

aa

二、设置参数时要注意什么?

首先在设置参数的时候,有以下的几点是我们需要考虑到的!

1、下游系统抗并发的能力

多线程给下游系统造成的并发等于你设置的线程数

例:

假如,是多线程访问数据库,那么就得考虑数据库的连接池大小设置,数据库并发太多影响其qps,会将数据库打挂等问题。

假如,是访问下游系统的接口,那么就得考虑下游系统是否可以抗得住这么多的并发量,不可以将下游系统打挂了。

2、CPU使用率

在线程数设置的比较大的时候,那么就会出现以下的几个问题:

(1)线程的初始化,切换,销毁等操作会消耗比较多的cpu资源,从而使得cpu利用率一直维持在比较高的水平。

(2)线程数比较大的时候,任务会短时间迅速执行,任务的集中执行会给cpu造成比较大的压力。

(3)任务的集中支持,会使得cpu的使用率呈现锯齿状,也就是说在短时间内cpu升高,之后,迅速下降到闲置状态。

cpu使用的不合理,应该减小线程数,让任务在队列等待,使得cpu的使用率应该持续稳定在一个合理,平均的数值范围。

所以,在cpu在够用的时候,不应该过大,注意,并不是越大越好。

这个时候,可以通过上线之后,观察机器的cpu使用率和cpu负载,观察这两个参数来判断线程数是否合理。

能够通过命令查看cpu使用率是不是主要花在线程切换上。

cpu负载是正在执行的线程和等待执行的线程之和。

注意了,这里的等待指的是线程处在running状态,可是,还没有被cpu调度的等待,负载较高,也就意味着cpu竞争激烈,进而的说明,线程设置的比较大,在抢cpu资源。

负载的值通常约等于cpu核数是比较合理的数值。

3、线程池中执行的任务性质

计算密集型的任务比较占cpu,所以说,通常线程数设置的大小等于或者是略微大于cpu的核数。

可是,IO型任务主要时间消耗在IO等待上,cpu压力不是很大,所以,线程数一般设置的比较的大。

例:

多线程访问数据库,数据库有128个表,这样的话,就直接考虑使用128个线程。

4、内存使用率

线程数过多以及队列的大小对于这个数据都会造成影响。

队列的大小应该通过前期计算线程池任务的条数,来合理的设置队列的大小,不适合太小,让它不会溢出,因为,溢出会走拒绝策略,多多少少对于它的性能会造成一定的影响,与此同时,复杂度也会被增加,所以,这里需要我们好好的考量拒绝策略的选择。

拒绝策略包括了AbortPolicy(抛异常), CallerRunsPolicy(主线程执行) 和 DiscardPolicy(丢弃),也不适合过大,过大的话也用不上,还会消耗比较大的内存。

以上的四点是我们一定要去考虑的,之后给大家介绍一个很多人都会容易犯的错误。

如下:

线程池的配置

<bean id="threadPool" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
    <property name="corePoolSize" value="10" />
    <property name="maxPoolSize" value="30" />
    <property name="queueCapacity" value="50000" />
    <!-- 为了不会队列溢出,走到拒绝策略,这个值设置的较大,队列基本不会满 -->
</bean>

这里的话,发现任务执行的比较慢,机器的cpu,内存等也比较的低,所以,做出了加大线程的决定。

大约还需要100个线程,所以修改配置:

<bean id="threadPool" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
    <property name="corePoolSize" value="50" />
    <property name="maxPoolSize" value="100" />
    <property name="queueCapacity" value="50000" />
    <!-- 为了不会队列溢出,走到拒绝策略,这个值设置的较大,队列基本不会满 -->
</bean>

问题:

线程池是不是要创建新的线程,要做以下考虑:

1、假如,线程数小于corePoolSize,那么就直接添加新的线程。

2、假如,线程数大于等于corePoolSize,那么就放进队列进行等待,假如,放进队列成功了,那么就不添加新的线程。

3、假如对列满了,线程数小于maxPoolSize,那么就会新建线程,假如,大于maxpoolSize,那么就会走到拒绝策略

所以,队列设置较大,一般来说是不会满,所以线程数其实是一直达不到maxPoolSize的,所以,其实一致用的是50个线程。

解决:

将核心线程和最大线程设置成一个值,都为100就可以了。

线程池参数设置原则

以上就是对于线程池参数设置的一个简单介绍了

三、如何合理的配置核心线程数

第一步:先看下机器的CPU核数,然后再设定具体参数

CPU核数=Runtime.getRuntime().availableProcessors()

System.out.println(Runtime.getRuntime().availableProcessors());

第二步:分析下线程池处理的程序是CPU密集型,还是IO密集型

CPU 密集型:核心线程数 = CPU核数 + 1

IO 密集型:核心线程数 = CPU核数 * 2 

注意:IO密集型 (某大厂实战经验)
核心线程数 = CPU核数 / (1 - 阻塞系数)
例如阻塞系数为0.8 ,CPU核数为 4 ,则核心线程数为 20

(最大线程数cpu核数乘于25)

-----------------------------------------------------------------------------------------------------------

结论说了那什么是IO密集型(频繁IO)?什么是CPU密集型(计算逻辑复杂)?

CPU密集型(CPU-bound)

CPU密集型也叫计算密集型,指的是系统的硬盘,内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU loading 100%,CPU要读/写 I/O (硬盘/内存),I/O在很短的时间就可以完成,而CPU还有很多运算要处理,CPU loading很高。

在多重程序系统中,大部分时间用来做计算,逻辑判断等CPU动作的程序称之为 CPU 密集型。例如一个计算圆周率至小数点一千位以下的程序,在执行过程中绝大部分时间用在三角函数和开根号的计算,便是属于CPU 密集型的程序。

CPU密集型的程序一般而且CPU 占用率相当高。这可能是因为任务本身不太需要访问 I/O 设备,也可能是因为程序是多线程实现因此屏蔽了等待 I/O 的时间。

IO密集型(I/O-bound)

IO密集型指的是系统的CPU性能相对硬盘,内存要好很多,此时,系统运作,大部分的状况是CPU在等IO(硬盘/内存)的读/写 操作,此时CPU loading并不高。

IO密集型的程序一般在达到性能极限时,CPU占用率仍然较低。这可能是因为任务本身需要大量 IO 操作,而pipeline 做得不是很好,没有充分利用处理器能力。

CPU密集型和IO密集型对比

一般把任务划分为 计算密集型和 IO 密集型。

计算密集型任务:特点是要进行大量的计算,消耗CPU资源。比如计算圆周率,对视频进行高清解码等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效的利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。

计算密集型任务由于主要消耗CPU资源,因此,代码运行效率非常重要。例如脚本语言运行效率很低,完全不适合计算密集型任务。

IO密集型任务: 涉及到网络,磁盘IO的任务都是IO密集型任务。这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为 IO 的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。

IO密集型任务执行期间,99% 的时间都花在IO上,花在CPU上的时间很少,因此,如果用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。

小结:
计算密集型程序适合C语言或Java语言多线程执行,IO密集型适合脚本语言开发的多线程。

四、其他计算方式

先列出计算公式,然后举例解释说明

2.1 核心线程数

corePoolSize=20% * taskNum/(单线程/AR)=20% * 任务数 * 平响

taskNum是指任务数量/请求数量等,可以简单理解成是qps,

AR是指每个任务的平均处理时间,

20%是根据8020定律得来的,简单理解是80%的情况下,核心的任务数大约占到20%

2.2 工作队列长度

queueCapacity=(corePoolSize/AR)* MR

corePoolSize为上面计算出来的核心线程数,

AR是指每个任务的平均处理时间,

MR表示任务能够接受的最大响应时间

2.3 最大核心线程数

maximumPoolSize=(taskNum-queueCapacity)/(单线程/AR)

taskNum是指任务数量/请求数量等,可以简单理解成是qps,

queueCapacity为等待队列的长度,

AR是指每个任务的平均处理时间,

2.4 举例

假定任务数是100个/s,平均响应时间是0.1s,能够接受的最大响应时间是0.5s则

corePoolSize=20% * taskNum/(单线程/AR)=20% * 任务数 * 平响=20% * 100 个/s * 0.1s=2个

queueCapacity=(corePoolSize/AR)* MR=2个/0.1s *0.5s=10个

maximumPoolSize=(taskNum-queueCapacity)/(单线程/AR)=(100-10)/(1/0.1)=9个

根据上面推到就能大致算出核心参数的取值,但是是有前提假定的,即资源是无限的。根据公式算出来的只是一个理论参考值。实际线上参数配置还是要以此为基础进行微调

五、各种计算方式

 当我们自定义线程池的时候 corePoolSize、maximumPoolSize、workQueue(队列长度)该如何设置?

  你以为我要给你讲分 IO 密集型任务或者分 CPU 密集型任务?

  当然这个教科书式的流程我们决不能少!

  1:IO密集型任务时:

《Java并发编程实战》一书中给出的计算方式是这样的:

 

2:CPU密集型任务:

可以把核心线程数设置为核心数+1。

为什么要加一呢?

《Java并发编程实战》一书中给出的原因是:即使当计算(CPU)密集型的线程偶尔由于页缺失故障或者其他原因而暂停时,这个“额外”的线程也能确保 CPU 的时钟周期不会被浪费。

看不懂是不是?没关系我也看不懂。反正把它理解为一个备份的线程就行了。

 

教科书的痛点:

 

我之前有个系统就是按照这个公式算出来的参数去配置的。

结果效果并不好,甚至让下游系统直呼受不了。

这个东西怎么说呢,还是得记住,面试的时候有用。真实场景中只能得到一个参考值,基于这个参考值,再去进行调整。

我们再看一下美团的那篇文章调研的现有解决方案列表:

第一个就是我们上面说的,和实际业务场景有所偏离。

第二个设置为 2*CPU 核心数,有点像是把任务都当做 IO 密集型去处理了。而且一个项目里面一般来说不止一个自定义线程池吧?比如有专门处理数据上送的线程池,有专门处理查询请求的线程池,这样去做一个简单的线程隔离。但是如果都用这样的参数配置的话,显然是不合理的。

第三个不说了,理想状态。流量是不可能这么均衡的,就拿美团来说,下午3,4点的流量,能和 12 点左右午饭时的流量比吗?

基于上面的这些解决方案的痛点,美团给出了动态化配置的解决方案。

 

动态更新的工作原理是什么?

 

在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略。

对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idel的时候也会被回收;

对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务,setCorePoolSize具体流程

 

因此动态更新线程参数的核心在于:

setCorePoolSize   setMaxNumPoolSize  以及重新设置队列长度三个方法。

由于美团的文章发表只是理论上的概念并未发布源码。可以看看别人根据美团的思路自己写的代码:https://blog.csdn.net/honger_hua/article/details/105733000

 

  • 2
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值