首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >通用性能优化手段(9)-之线程池优化

通用性能优化手段(9)-之线程池优化

作者头像
用户12591013
发布2026-07-10 15:28:20
发布2026-07-10 15:28:20
120
举报

线程池,是我们日常开发中经常使用的工具。对于一个熟练的开发者来说,了解线程池的原理,并能进行优化,是其必备的技能。本文将提供线程池设置的最佳实践数据,教大家设置线程池的大小,以及队列的长度。最后,再给出几个笔者实践中遇到过的性能优化案例。

图片
图片

线程池的基本原理

线程池本质上是生产者-消费者模型的高级实现,通过解耦任务提交(生产)和任务执行(消费),实现资源的高效调度。以下是结合生产者-消费者模型的详细原理分析:

一、核心角色对照

模型组件

线程池对应角色

作用

生产者

调用 execute() /submit() 的线程

提交任务(runnable/Callable)到线程池

缓冲区(队列)

阻塞队列(BlockingQueue)

存储等待执行的任务

消费者

工作线程(worker threads)

从队列获取任务并执行

二、工作流程

1. 生产者阶段(提交任务)

  • 外部线程(生产者)调用线程池的 execute(Runnable task)submit(Callable task)
  • 关键行为: 1. 若核心线程未满(workers.size() < corePoolSize),立即创建新线程(消费者)执行任务(绕过队列)。 2. 核心线程已满,任务进入阻塞队列(缓冲区)等待。

2. 缓冲区(任务队列)

  • 使用阻塞队列(如 LinkedBlockingQueue)作为任务缓冲区。
  • 核心机制: 1. 队列为空时:工作线程(消费者)通过 queue.take() 阻塞等待新任务。 2. 队列满时:生产者提交任务会触发 queue.offer() 失败,进而尝试创建非核心线程(若 workers.size() < maximumPoolSize)。

3. 消费者阶段(执行任务)

  • 工作线程(消费者)循环执行以下逻辑:
代码语言:javascript
复制
while (!isShutdown) {
    Runnable task = queue.take();      // 阻塞获取任务(或 poll(keepAliveTime))
    task.run();                        // 执行任务
}
  • 线程复用:每个工作线程执行完任务后不销毁,而是循环获取新任务(避免频繁创建/销毁开销)。
  • 动态伸缩: 非核心线程空闲超过keepAliveTime后,通过 queue.poll(keepAliveTime) 超时返回,线程自动终止。

三、使用线程池的原则

  • 线程池中放入的任务(Runnable task/ Callable task),需要是同类型的 如果不同类型的任务(有的运行时间长,有的时间短),很容易让线程池拥堵。
  • 线程池中放入的任务(Runnable task/ Callable task),需要是相互独立的 互相依赖的任务放到同一个线程中,很容易造成死锁。
图片
图片

线程池的大小如何设置

线程池的大小,对于应用的性能至关重要。配置线程池通常取决于提交的任务类型以及系统的配置。配置的过小,空闲的处理器不能有效利用,导致吞吐量过低;配置的过大,那么大量的线程竞争相对较少的cpu,导致线程调度效率降低并且浪费大量的内存资源。

一、理论基础

cpu(计算)密集型任务:线程数可以设置为 Ncpu + 1

当线程数为cpu个数+1时,通常能获得最优的利用率。即使计算密集型的线程偶尔由于缺页或者其它原因而暂停时,这个多出来的线程能够确保cpu的时钟周期不被浪费。

Ncpu:表示cpu的个数,可以用 Runtime.getRuntime().availablePrcessors()获得

IO密集型任务:线程数= Ncpu * U * (1+ w/c)

io密集型的任务,需要估算出任务等待的时间与计算时间的比值,这个比值不需要很精确,可以从系统中的监控获得。

U:表示cpu的目标使用率

w:表示任务等待的时间

c:表示任务计算的时间

二、生产中实际设置方法

生产实践中,我们可以根据历史经验快速设置线程池的大小:

  • 计算密集型,通常coresize = maxsize,设置为cpu个数或者cpu个数+1
  • io密集型(大部分的业务线程池都是IO密集型,访问redis、访问db都是io操作---这也是微服务逻辑大部分的场景),coreSize 是maxSize的一半。

下面是我们压测获得的线程池最佳配置数据(应用是web形式的java微服务),配置随cpu核数变化

cpu核心数

coresize

maxsize

拒绝策略

2

20

40

AbortPolicy

4

30

60

AbortPolicy

8

40

80

AbortPolicy

如果任务执行是有超时设置的(异步变同步,拿最终结果,实践中是不允许死等的,一定要设置一个超时时间),队列长度:QSIZE =(timeout/rt)* maxSize

Timeout 自己设置的超时时间.

rt:平均每个任务执行耗时

maxSize:最大线程数

例子:timeout =200, rt = 20, qsize = 200 /20 * 40 = 400

如果任务执行没有超时设置,也就是说任务丢到线程池中,并不需要同步将结果返回给调用者,那么这个队列长度,可以根据经验去设置:例如 1024,甚至1w,但是要注意不能太大,把应用内存撑爆

图片
图片

生产实践案例

案例1、线程池配置过小

问题:某个项目上线前,进行压力测试,测试过程中发现,压力增加到一定程度,服务的吞吐量上不去,而且cpu也涨不上去

分析:研发同学找到我这边,简单分析了一下调用链路,让其扫下代码,看看调用链上是否有使用线程池,coreSize和maxSize是多大。很快,在调用链路上发现有线程池的使用,coreSize = maxSize =2,线程池里的任务是rpc调用,属于IO密集型,这个设置明显太小了。造成了请求堆积,cpu利用率也上不去,表现就是吞吐量上不去。简单的说,就是请求都堵在了线程池上

解决方案:我们将coreSize按照cpu个数的8倍修改,设为32;maxSize改为64,再次压测,吞吐量大幅提升(同时cpu使用率也随之上涨),顺利通过了压力测试。

教训:IO密集型任务线程池不能太小(<=cpu个数),否则影响吞吐量

案例2、undertow线程池设置不合理

问题:扫描线上某个应用,发线其使用的内存较多,线程数也比较高。

分析排查:使用jstack,进行thread dump,发现web server使用的是undertow容器,其IO线程数比较高,有96个之多(undertow的io线程数,默认等于cpu的个数--参考Reactor模式中undertow部分)。另外,undertow的worker线程数也很高---足足有800个,这些线程在dump文件中可以查看到状态,几乎都是waiting的---这说明worker线程几乎都是空闲状态。

对于第一个问题--io线程数过多,查看了应用配置,并未设置io线程数,这个值就是默认值。这样,就查了一下jdk的版本,应用使用的大版本是jdk8,小版本比较旧,还不能识别容器的cpu个数,它用的就是物理宿主机的cpu个数

第二个问题--worker线程数过多,确实是研发同学自己配置的。又查了监控,找到历史上压力最大时间节点,观察worker线程的活跃数量也不超过100个。

解决方案:

1.升级jdk版本,升级到8u191版本以后(参考 8u19 release https://www.oracle.com/java/technologies/javase/8u191-relnotes.html)

2.修改worker线程数配置,将800改为200(tomcat线程池worker数最大值为200,dubbo也是200,结合前面的数据分析200个线程已经完全足够)

教训:webserver的线程池一般不要超过200个,太多了浪费内存,影响线程调度

案例3、CompletableFuture使用默认线程池性能低

问题:线上某个应用进行压测,随着压力增加,吞吐量却上不去,cpu使用率也上不去,明显这又是什么地方堵住了

分析:扫描代码,分析调用链路,发现在一个关键节点,有个异步调用使用了CompletableFuture.supplyAsync()方法,该调用未指定线程池,使用的是默认线程池--也就是ForkJoinPool的commonPool。异步执行的任务,是IO型任务,远程访问其它资源。

解决方案:换成自定义线程池(coresize 和maxsize都比较大,分别是cpu个数的8倍和16倍)后,应用的吞吐量和cpu都升上去了。

:ForkJoinPool的commonPool性能为何差?

1.整个jvm中共用这同一个线程池,资源竞争有影响

2.关键的一点,这个线程池size是固定的,数量为cpu个数-1,适用于cpu密集型计算。对于大多数IO密集型业务场景不适用,线程数不足,不能提供强大的吞吐量,task大多都被堵住了,等待少数几个线程来执行。

教训: 不要使用默认线程池,要设置自定义线程池

案例4、gRPC使用默认线程池

问题:线上压测某个服务,当压力增大时,服务线程数徒增,引发报警。研发找到我这里,帮助分析。

分析:通过监控,发现增加的线程主要是grpc的线程池,因为gRPC默认使用的是cachedThreadPool,所以线程池会随着请求的增加而增加,等流量过去以后线程数再次降低。grpc源码如下:

代码语言:javascript
复制
public static final Resource<ExecutorService> SHARED_CHANNEL_EXECUTOR =    new Resource<ExecutorService>() {      
  private static final String NAME = "grpc-default-executor";      
  
  @Override      
  public ExecutorService create() {        
    return Executors.newCachedThreadPool(getThreadFactory(NAME + "-%d", true));      
  }      
  
  @Override      
  public void close(ExecutorService instance) {        
    instance.shutdown();      
  }      
  
  @Override      
  public String toString() {        
    return NAME;      
  }    
};

解决方案:使用自定义的threadPoolExecutor,设置coresize=32,maxSize=200,通过nettyServerBuilder设置,如下:

教训:不要使用gRPC默认线程池,要设置自定义线程池

案例5、任务依赖引起死锁

问题:线上某个应用的某个逻辑不执行,从某个时间点以后,这个逻辑的日志就消失了。

分析:表现行为特别像是死锁产生,于是执行jstack操作获得thread dump文件,但是文件中并无"dead lock"字样。于是,仔细分析相关业务代码(去掉业务抽象为纯技术描述):该部分逻辑使用线程池异步执行,任务A执行的过程中又执行了任务B,且A和B使用同一个线程池。结合thead dump文件,发现线程池的线程都block在A 任务中的同一位置。于是怀疑,这种使用方式造成了线程池的"饥饿死锁"。我们将问题简化,写了一个demo,用于验证上述判断。代码如下:

代码执行后,果然block住,只打印了 "A start ---", 就不再动了。随后我们执行jstack,thread dump内容如下图,可见线程恰好block在line26

解决方案:以上demo,验证了我们的猜测--线程池发生了 饥饿死锁。 既然确定了原因,改动就很简单了:把任务B合并到A中,B操作作为A的一部分,不再单独放到线程池中去执行。这样改动之后,问题迎刃而解。

饥饿死锁--指的是线程池中的任务互相依赖,且在一个线程池中执行,前面的任务执行依赖于后一个任务,然儿后一个任务的执行却没有可用的线程来执行,这就造成了死锁。而且这种死锁,jstack并不能发现,给问题的排查带来了比较大的困难。

教训:线程池内的任务不能互相依赖,一定要独立

图片
图片
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-07-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 鹏哥调优笔记 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档