0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

线程池三大核心参数的含义 线程池核心线程数制定策略

jf_ro2CN3Fa 来源:稀土掘金技术社区 2023-12-01 10:20 次阅读

1 前言

说到线程池八股文背的很熟的肯定知道无非就这几个考点:

(1)线程池三大核心参数 corePoolSize、maximumPoolSize、workQueue 的含义

(2)线程池核心线程数制定策略

(3)建议通过 ThreadPoolExecutor 的构造函数来声明,避免使用 Executors 创建线程池

以上考点作为线程池面试几乎必问的内容,大部分人应该都是如数家珍,张口就来,但是懂了面试八股文真的就不一定在实际运用中真的就会把线程池用好 。且看下面这次真实生产事故还原

2 事故还原

某次一位研发同事写出了下面类似的代码:

Listitems=getFromDb();
List>completableFutures=items.stream().map(item->CompletableFuture.supplyAsync(()->{
AppMapStationDatadata=mapper.copy(item);
//发起价格信息查询的RPC调用
data.setPriceInfo(priceApi.getPriceInfoById(item.getId()))
returndata;
},apiExecutor)).collect(Collectors.toList());

result=completableFutures.stream().map(e->{
returne.get();
}).filter(Objects::nonNull).collect(Collectors.toList());

上面的代码中,代码首先从数据库里面查出来一堆对象,然后对每一个对象进行模型转换,由于要获取每个对象的价格信息发起了一次RPC调用,由于RPC服务没有提供批量接口,所以代码里面用了线程池并发请求,以求得接口尽可能快的返回数据。

使用的是CompletableFuture 而且自定义了线程池,线程池指定了10个核心线程,20个最大线程,这段代码在上线后的一段时间确实没有任何问题,但是在灰度放量用户量多起来之后发现接口经常超时告警。

请问为什么上面的代码在用户量稍微大一点的时候就运行缓慢了呢?

实际代码问题出现在了这个get方法中,这个get方法没有指定超时时间,当getPriceInfoById这个接口响应变慢的时候,这个主线程的代码get又没有指定超时时间,这时候问题就来了。

由于某次业务查询查到了非常多的数据,每条数据就是个模型转换任务,这个任务就会在队列排队,get方法没有指定超时时间的情况下,其最终耗时就取决于整个线程池中执行最慢的那一个任务,所以当从DB中查出来的数据量越来越大的时候这个转换任务的最大耗时就会逐渐增加,进而引发接口超时。

所以这里改进上述问题需要做到两个点:

1、数据库中查出来的数据集合必须分页

2、get方法必须设置超时时间

此外需要知道get方法设置超时时间的计算方式也需要留意,考虑下面这种场景

提交两个任务 A 和 B 到线程池,A 任务耗时 3 秒,B 任务耗时 4 秒,Future 以 2 秒为超时时间获取任务结果

代码如下:

ExecutorServiceexecutorService=Executors.newFixedThreadPool(2);

CallabletaskA=()->{
sleep(3);
return"A";
};
CallabletaskB=()->{
sleep(4);
return"B";
};

List>futures=Stream.of(taskA,taskB)
.map(executorService::submit)
.collect(Collectors.toList());

for(Futurefuture:futures){
try{
Strings=future.get(2,TimeUnit.SECONDS);
System.out.println(s);
}catch(Exceptione){
continue;
}
}

实际运行情况是第一个任务会超时但是第二个不会 ,看起来是不是还有点不可思议,耗时时间长的任务B反而没超时。原因就在于 Future.get(long timeout, TimeUnit unit) ,调用 get 时才开始计时,而非任务加入线程池的时间

dbba0046-8fe9-11ee-939d-92fbcf53809c.jpg

从图上就可以看出来,在获取B的任务执行结果的时候B任务已经执行了两秒,所以在等待两秒的情况下可以获取到结果

3 线程池不当使用举例

(1)不区分业务一把梭哈,全用一个线程池

曾经有一个项目,对接多个租户,每个租户都有各自的任务需要执行,代码中不区分租户的将所有租户的任务全部丢到一个线程池中执行,结果一个租户的任务提交过多导致线程池执行缓慢,但是由于线程池是同一个,影响了所有租户接口的响应时间。如果说上面说的这个场景用一个线程池产生了租户互相影响的问题还不够严重,那么下面的这种场景就问题更大了。

曾经有一段这样的场景,因为共用线程池直接导致线程池任务永远完成不了,请看下面的这种情况:

首先向线程池中提交了一个任务,然后在这个任务的内部实现中又往同一个线程池中再次提交了一个任务,相当于父子任务在同一个线程池中执行,这时候极有可出现线程死锁也就是循环等待的情况

dbcc942c-8fe9-11ee-939d-92fbcf53809c.jpg

如上图所示,父任务全部处于执行状态,这时候子任务想要执行需要等父任务执行完成,但是父任务都执行不完,因为还有个子任务没完成,即父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 "死锁"

所以综上所述,在代码中应该避免各种任务都往一个线程池中投放,对每个线程池指定好线程名称,做好分类比较合适,这里在日常开发中比较推荐使用Guava的工具类,来指定线程名称前缀,这样使用jstack分析线程问题也方便排查。

ThreadFactorythreadFactory=newThreadFactoryBuilder()
.setNameFormat(threadNamePrefix+"-%d")
.setDaemon(true).build();
ExecutorServicethreadPool=newThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.MINUTES,
workQueue,
threadFactory);

(2)@Async注解不自己定义线程池

@Async用在方法上标识这是一个异步方法,如果不自己指定线程池这个方法将直接新建一个线程执行,可以翻看spring实现源码知道这个点

@Async的实现其实非常简单就是利用AOP,容器启动的时候会扫描所有被打上@Async注解的方法,并代理这些方法的执行,在执行这个方法的时候,生成Callable任务丢到线程池中执行(核心代码位于org.springframework.aop.interceptor.AsyncExecutionInterceptor)

@Override
@Nullable
publicObjectinvoke(finalMethodInvocationinvocation)throwsThrowable{
ClasstargetClass=(invocation.getThis()!=null?AopUtils.getTargetClass(invocation.getThis()):null);
MethodspecificMethod=ClassUtils.getMostSpecificMethod(invocation.getMethod(),targetClass);
finalMethoduserDeclaredMethod=BridgeMethodResolver.findBridgedMethod(specificMethod);

AsyncTaskExecutorexecutor=determineAsyncExecutor(userDeclaredMethod);
if(executor==null){
thrownewIllegalStateException(
"NoexecutorspecifiedandnodefaultexecutorsetonAsyncExecutionInterceptoreither");
}
//将方法调用封装成Callable实例丢入线程池中执行
Callabletask=()->{
try{
Objectresult=invocation.proceed();
if(resultinstanceofFuture){
return((Future)result).get();
}
}
catch(ExecutionExceptionex){
handleError(ex.getCause(),userDeclaredMethod,invocation.getArguments());
}
catch(Throwableex){
handleError(ex,userDeclaredMethod,invocation.getArguments());
}
returnnull;
};

returndoSubmit(task,executor,invocation.getMethod().getReturnType());
}

如果不指定线程池这里就会启用默认的线程池 SimpleAsyncTaskExecutor 然后我们看下这个类的注释

dbddedee-8fe9-11ee-939d-92fbcf53809c.jpg

那这个问题就很严重了,假定你的方法执行速度慢,而且qps大,这时候线程数就会直接爆炸,所以建议写一个类继承 AsyncConfigurer接口并复写getAsyncExecutor方法,然后在使用注解的时候指定线程池的名称

//使用注解时指定线程池的Bean名称
@Async("apiExecutor")

(3)线程池遇上ThreadLocal

线程池和 ThreadLocal 共用,可能会导致线程从 ThreadLocal 获取到的是旧值/脏数据。这是因为线程池会复用线程对象,与线程对象绑定的类的静态属性 ThreadLocal 变量也会被重用,这就导致一个线程可能获取到其他线程的 ThreadLocal 值。

比较常规的做法是在任务执行完毕之后的finally代码块里面做清理工作

Runnablerunnable=()->{
try{
BizThreadLocal.set("xxxx");
//dosth
}finally{
BizThreadLocal.remove();
}
};

但是其实finally的代码块其实也不是百分百一定执行,事实上Thread#stop() 方法打断线程执行的时候 finally代码块中的内容就不会执行,比较推荐的还是# TransmittableThreadLocal

4 再谈线程池,几个关键要点

(1)为什么默认线程池的队列长度不能动态调整?

曾经面对生产环境线程池的参数设定问题,我曾经想到一个方案,既然线程池的参数不好定,那咱们直接动态修改就行不行呢,线程池本身提供了很多的set方法可以做到参数修改,比如我们在springBoot项目往往去使用ThreadPoolTaskExecutor 作为线程池,从下图的set方法列表中可以看出存在很多修改线程池参数的方法

dbfd781c-8fe9-11ee-939d-92fbcf53809c.jpg

然后实际使用的时候发现核心线程数和最大线程数都能动态修改 但是队列长度却不能 ,为什么队列长度不能调用setQueueCapacity方法进行动态修改呢?

首先我们可以简单理解为spring的ThreadPoolTaskExecutor是Java原生ThreadPoolExecutor的封装,观察这个类的setMaxPoolSize和setQueueCapacity代码实现我们就能发现setQueueCapacity 实际就是一个赋值仅在第一次实例化线程池的使用到了这个参数。

publicvoidsetMaxPoolSize(intmaxPoolSize){
synchronized(this.poolSizeMonitor){
if(this.threadPoolExecutor!=null){
this.threadPoolExecutor.setMaximumPoolSize(maxPoolSize);
}

this.maxPoolSize=maxPoolSize;
}
}

publicvoidsetQueueCapacity(intqueueCapacity){
this.queueCapacity=queueCapacity;
}


protectedBlockingQueuecreateQueue(intqueueCapacity){
return(BlockingQueue)(queueCapacity>0?newLinkedBlockingQueue(queueCapacity):newSynchronousQueue());
}

从上面的源码我们还可以看出spring的ThreadPoolTaskExecutor使用的队列是LinkedBlockingQueue ,那么为啥线程池ThreadPoolExecutor不支持修改队列长度呢?这个原因就很简单了因为这个队列的capacity是final类型的,自然不能修改。

dc0e7284-8fe9-11ee-939d-92fbcf53809c.jpg

那如果我一定要修改这个队列长度应该怎么处理?那完全就可以仿照美团的方式,自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(把LinkedBlockingQueue的capacity 字段的final关键字修饰给去掉了,让它变为可变的)是不是也是很简单。

(2)再谈核心线程数的参数设置

核心线程池的参数设置一般各种网络资料中比较推崇的是N+1和2N法,即:

CPU 密集型任务(N+1) :这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务(2N) :这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型 :简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。

IO 密集型 :涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

但是实际上比较科学的线程数计算方式是:

最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间))

WT(线程等待时间)= 线程运行总时间 - ST(线程计算时间)

线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。(我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例)

CPU 密集型任务的 WT/ST 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N。

这里额外说一句早先我虽然知道线程池核心线程数应该和CPU核心线程数有关,但是悲剧的是我并不知道怎么查Linux系统的核心数,这里把查询命令贴出来供参考:

#总核数=物理CPU个数X每颗物理CPU的核数
#查看物理CPU个数
cat/proc/cpuinfo|grep"physicalid"|sort|uniq|wc-l

#查看每个物理CPU中core的个数(即核数)
cat/proc/cpuinfo|grep"cpucores"|uniq

(3)为什么不太推荐你用ParallelStream?

曾经某次代码评审中,有位同学写出了下面类似的代码

//从数据库中查找学生对象
Liststudents=searchDataFromDb();

//使用并行流进行模型转换
Listres=newArrayList();
students.parallelStream().forEach(student->{
StudentVovo=newStudentVo(student)
res.add(student);
});

结果测试过程中返回给前端的数据总是莫名其妙的少很多和数据库中的真实数据条数对不上,相信大家都看出来了原因是List并不是线程安全的容器,所以导致了最后结果不对,其实这不能算是parallelStream的问题,但是很多人写代码时,以为并行流就快为了追求效率,不假思索就写了这样的代码,但是往往在线程池的环境下大家又仿佛绷紧了并发神经,又能考虑到了并发问题。

此外parallelStream的默认线程池遇上ThreadLocal同样也存在一些问题,其实如果不做额外线程池指定,代码中的 parallelStream 都是共用同一个线程池的,ParallelStream 底层使用了 ForkJoinPool,当 Stream 流中元素较多时,整个运行效率也会大大降低。

5 总结

本文通过一次生产事故,进一步总结了线程池在日常开发中需要注意的一些要点,希望对大家有所帮助。

审核编辑:汤梓红

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • 函数
    +关注

    关注

    3

    文章

    3868

    浏览量

    61309
  • 代码
    +关注

    关注

    30

    文章

    4555

    浏览量

    66772
  • 线程池
    +关注

    关注

    0

    文章

    53

    浏览量

    6768

原文标题:【避坑】线程池没用好,直接出现了生产事故....

文章出处:【微信号:芋道源码,微信公众号:芋道源码】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

    Posix多线程编程—线程属性

    如果你关心线程的调度策略参数,必须先设置该属性。 继承性的可能值是PTHREAD_INHERIT_SCHED(表示新现成将继承创建线程的调度策略
    发表于 09-29 11:00

    Posix多线程编程—线程属性

    如果你关心线程的调度策略参数,必须先设置该属性。 继承性的可能值是PTHREAD_INHERIT_SCHED(表示新现成将继承创建线程的调度策略
    发表于 09-29 16:00

    线程是如何实现的

    线程的概念是什么?线程是如何实现的?
    发表于 02-28 06:20

    线程创建的两种方法

    使用上述第方模块的方法之外,我们还可以自己结合前面所学的消息队列来自定义线程。这里我们就使用queue来实现一个上面同样效果的例子,大家感受一下。import timeimport
    发表于 03-16 16:15

    关于RT-Thread内存管理的内存简析

    :支持线程挂起。内存无空闲内存块时,申请线程会被挂起,直到有可用内存块。简单理解,就是将相同大小的内存块通过某种方式放在一起,就好比将各个内存块放在类似于水池的容器里,需要用的时候,就从这个池子里取
    发表于 04-06 17:02

    线程与6个arm核心中的一个绑定请问如何解绑?

    CPU_CLR接口将该核心从mask中移除;将第步mask作为参数,调用setj接口重新绑定线程;测试结果,发现线程一旦绑定了该
    发表于 09-07 14:25

    CPU与核心及进程和线程认识

    所谓的4核8线程,4核指的是物理核心。通过超线程技术,用一个物理核模拟两个虚拟核,每个核两个线程,总数为8线程
    的头像 发表于 03-30 14:48 7664次阅读
    CPU与<b class='flag-5'>核心</b>及进程和<b class='flag-5'>线程</b>认识

    第三代线程撕裂者发布 直接24核心48线程起步

    如果16核心32线程的锐龙9 3950X还不能满足你的发烧,第三代线程撕裂者(Ryzen ThreadRipper)终于来了,直接24核心48线程
    发表于 11-08 14:30 1641次阅读

    第三代线程撕裂者将于11月25日上市,24核心48线程起步突破万元

    如果16核心32线程的锐龙9 3950X还不能满足你的发烧,第三代线程撕裂者(Ryzen ThreadRipper)终于来了,直接24核心48线程
    的头像 发表于 11-08 14:51 3209次阅读

    Java线程核心原理

    看过Java线程池源码的小伙伴都知道,在Java线程池中最核心的类就是ThreadPoolExecutor,
    的头像 发表于 04-21 10:24 617次阅读

    核心线程数和最大线程数区别

    达到最大线程数。当任务执行完毕后,线程池会根据线程参数来决定是否回收线程。 简单来说,核心
    的头像 发表于 06-01 09:33 6065次阅读

    cpu核心数和线程数的关系

    核心线程数是线程池中一直存在的线程数,不会被回收。最大线程数是线程池中最多能够存在的
    的头像 发表于 06-01 17:41 7588次阅读

    4核8线程够用吗 核心线程哪个重要

    这取决于您使用计算机的用途。如果您的用途包括日常办公、网页浏览、轻度视频处理等,则4核8线程足以满足需求。如果您需要进行高强度的图形处理、虚拟化、编程开发等,则可能需要更多的核心线程
    的头像 发表于 06-01 17:50 1w次阅读

    核心线程数和最大线程数怎么设置

    核心线程数和最大线程数是Java线程池中重要的参数,用来控制线程池中
    的头像 发表于 12-01 13:50 4087次阅读

    线程池七大核心参数执行顺序

    线程池是一种用于管理和调度线程执行的技术,通过将任务分配到线程池中的线程进行处理,可以有效地控制并发线程的数量,提高系统的资源利用率和任务处
    的头像 发表于 12-04 16:45 375次阅读