“线程池”业务代码最常用也最容易犯错的组件

接下来,我们就利用这个方法来观察一下线程池的基本特性吧。

首先,自定义一个线程池。这个线程池具有2个核心线程、5个最大线程、使用容量为10的ArrayBlockingQueue阻塞队列作为工作队列,使用默认的AbortPolicy拒绝策略,也就是任务添加到线程池失败会抛出

RejectedExecutionException。此外,我们借助了Jodd类库的ThreadFactoryBuilder方法来构造一个线程工厂,实现线程池线程的自定义命名。

然后,我们写一段测试代码来观察线程池管理线程的策略。测试代码的逻辑为,每次间隔1秒向线程池提交任务,循环20次,每个任务需要10秒才能执行完成,代码如下:

@GetMapping(“right”)

public int right() throws InterruptedException {

//使用一个计数器跟踪完成的任务数

AtomicInteger atomicInteger = new AtomicInteger();

//创建一个具有2个核心线程、5个最大线程,使用容量为10的ArrayBlockingQueue阻塞队列作为工作队列的线程池,使用默认的AbortPolicy拒绝策略

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(

2, 5,

5, TimeUnit.SECONDS,

new ArrayBlockingQueue<>(10),

new ThreadFactoryBuilder().setNameFormat(“demo-threadpool-%d”).get(),

new ThreadPoolExecutor.AbortPolicy());

printStats(threadPool);

//每隔1秒提交一次,一共提交20次任务

IntStream.rangeClosed(1, 20).forEach(i -> {

try {

TimeUnit.SECONDS.sleep(1);

} catch (InterruptedException e) {

e.printStackTrace();

}

int id = atomicInteger.incrementAndGet();

try {

threadPool.submit(() -> {

log.info(“{} started”, id);

//每个任务耗时10秒

try {

TimeUnit.SECONDS.sleep(10);

} catch (InterruptedException e) {

}

log.info(“{} finished”, id);

});

} catch (Exception ex) {

//提交出现异常的话,打印出错信息并为计数器减一

log.error(“error submitting task {}”, id, ex);

atomicInteger.decrementAndGet();

}

});

TimeUnit.SECONDS.sleep(60);

return atomicInteger.intValue();

}

60秒后页面输出了17,有3次提交失败了:

线程池:业务代码最常用也最容易犯错的组件

并且日志中也出现了3次类似的错误信息:

[14:24:52.879] [http-nio-45678-exec-1] [ERROR] [.t.c.t.demo1.ThreadPoolOOMController:103 ] - error submitting task 18

java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@163a2dec rejected from java.util.concurrent.ThreadPoolExecutor@18061ad2[Running, pool size = 5, active threads = 5, queued tasks = 10, completed tasks = 2]

我们把printStats方法打印出的日志绘制成图表,得出如下曲线:

线程池:业务代码最常用也最容易犯错的组件

至此,我们可以总结出线程池默认的工作行为

  • 不会初始化corePoolSize个线程,有任务来了才创建工作线程;

  • 当核心线程满了之后不会立即扩容线程池,而是把任务堆积到工作队列中;

  • 当工作队列满了后扩容线程池,一直到线程个数达到maximumPoolSize为止;

  • 如果队列已满且达到了最大线程后还有任务进来,按照拒绝策略处理;

  • 当线程数大于核心线程数时,线程等待keepAliveTime后还是没有任务需要处理的话,收缩线程到核心线程数。

了解这个策略,有助于我们根据实际的容量规划需求,为线程池设置合适的初始化参数。当然,我们也可以通过一些手段来改变这些默认工作行为,比如:

  • 声明线程池后立即调用prestartAllCoreThreads方法,来启动所有核心线程;

  • 传入true给allowCoreThreadTimeOut方法,来让线程池在空闲的时候同样回收核心线程。

不知道你有没有想过:Java线程池是先用工作队列来存放来不及处理的任务,满了之后再扩容线程池。当我们的工作队列设置得很大时,最大线程数这个参数显得没有意义,因为队列很难满,或者到满的时候再去扩容线程池已经于事无补了。

那么,**我们有没有办法让线程池更激进一点,优先开启更多的线程,而把队列当成一个后备方案呢?**比如我们这个例子,任务执行得很慢,需要10秒,如果线程池可以优先扩容到5个最大线程,那么这些任务最终都可以完成,而不会因为线程池扩容过晚导致慢任务来不及处理。

限于篇幅,这里我只给你一个大致思路:

  1. 由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们是否可以重写队列的offer方法,造成这个队列已满的假象呢?

  2. 由于我们Hack了队列,在达到了最大线程后势必会触发拒绝策略,那么能否实现一个自定义的拒绝策略处理程序,这个时候再把任务真正插入队列呢?

接下来,就请你动手试试看如何实现这样一个“弹性”线程池吧。Tomcat线程池也实现了类似的效果,可供你借鉴。

务必确认清楚线程池本身是不是复用的

=================

不久之前我遇到了这样一个事故:某项目生产环境时不时有报警提示线程数过多,超过2000个,收到报警后查看监控发现,瞬时线程数比较多但过一会儿又会降下来,线程数抖动很厉害,而应用的访问量变化不大。

为了定位问题,我们在线程数比较高的时候进行线程栈抓取,抓取后发现内存中有1000多个自定义线程池。一般而言,线程池肯定是复用的,有5个以内的线程池都可以认为正常,而1000多个线程池肯定不正常。

在项目代码里,我们没有搜到声明线程池的地方,搜索execute关键字后定位到,原来是业务代码调用了一个类库来获得线程池,类似如下的业务代码:调用ThreadPoolHelper的getThreadPool方法来获得线程池,然后提交数个任务到线程池处理,看不出什么异常。

@GetMapping(“wrong”)

public String wrong() throws InterruptedException {

ThreadPoolExecutor threadPool = ThreadPoolHelper.getThreadPool();

IntStream.rangeClosed(1, 10).forEach(i -> {

threadPool.execute(() -> {

try {

TimeUnit.SECONDS.sleep(1);

} catch (InterruptedException e) {

}

});

});

return “OK”;

}

但是,来到ThreadPoolHelper的实现让人大跌眼镜,**getThreadPool方法居然是每次都使用

Executors.newCachedThreadPool来创建一个线程池**。

class ThreadPoolHelper {

public static ThreadPoolExecutor getThreadPool() {

//线程池没有复用

return (ThreadPoolExecutor) Executors.newCachedThreadPool();

}

}

通过上一小节的学习,我们可以想到newCachedThreadPool会在需要时创建必要多的线程,业务代码的一次业务操作会向线程池提交多个慢任务,这样执行一次业务操作就会开启多个线程。如果业务操作并发量较大的话,的确有可能一下子开启几千个线程。

那,为什么我们能在监控中看到线程数量会下降,而不会撑爆内存呢?

回到newCachedThreadPool的定义就会发现,它的核心线程数是0,而keepAliveTime是60秒,也就是在60秒之后所有的线程都是可以回收的。好吧,就因为这个特性,我们的业务程序死得没太难看。

要修复这个Bug也很简单,使用一个静态字段来存放线程池的引用,返回线程池的代码直接返回这个静态字段即可。这里一定要记得我们的最佳实践,手动创建线程池。修复后的ThreadPoolHelper类如下:

class ThreadPoolHelper {

private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(

10, 50,

2, TimeUnit.SECONDS,

new ArrayBlockingQueue<>(1000),

new ThreadFactoryBuilder().setNameFormat(“demo-threadpool-%d”).get());

public static ThreadPoolExecutor getRightThreadPool() {

return threadPoolExecutor;

}

}

需要仔细斟酌线程池的混用策略

==============

线程池的意义在于复用,那这是不是意味着程序应该始终使用一个线程池呢?

当然不是。通过第一小节的学习我们知道,要根据任务的“轻重缓急”来指定线程池的核心参数,包括线程数、回收策略和任务队列

  • 对于执行比较慢、数量不大的IO任务,或许要考虑更多的线程数,而不需要太大的队列。

  • 而对于吞吐量较大的计算型任务,线程数量不宜过多,可以是CPU核数或核数*2(理由是,线程一定调度到某个CPU进行执行,如果任务本身是CPU绑定的任务,那么过多的线程只会增加线程切换的开销,并不能提升吞吐量),但可能需要较长的队列来做缓冲。

之前我也遇到过这么一个问题,业务代码使用了线程池异步处理一些内存中的数据,但通过监控发现处理得非常慢,整个处理过程都是内存中的计算不涉及IO操作,也需要数秒的处理时间,应用程序CPU占用也不是特别高,有点不可思议。

经排查发现,业务代码使用的线程池,还被一个后台的文件批处理任务用到了。

或许是够用就好的原则,这个线程池只有2个核心线程,最大线程也是2,使用了容量为100的ArrayBlockingQueue作为工作队列,使用了CallerRunsPolicy拒绝策略:

private static ThreadPoolExecutor threadPool = new ThreadPoolExecutor(

2, 2,

1, TimeUnit.HOURS,

new ArrayBlockingQueue<>(100),

new ThreadFactoryBuilder().setNameFormat(“batchfileprocess-threadpool-%d”).get(),

new ThreadPoolExecutor.CallerRunsPolicy());

这里,我们模拟一下文件批处理的代码,在程序启动后通过一个线程开启死循环逻辑,不断向线程池提交任务,任务的逻辑是向一个文件中写入大量的数据:

@PostConstruct

public void init() {

printStats(threadPool);

new Thread(() -> {

//模拟需要写入的大量数据

String payload = IntStream.rangeClosed(1, 1_000_000)

.mapToObj(__ -> “a”)

.collect(Collectors.joining(“”));

while (true) {

threadPool.execute(() -> {

try {

//每次都是创建并写入相同的数据到相同的文件

Files.write(Paths.get(“demo.txt”), Collections.singletonList(LocalTime.now().toString() + “:” + payload), UTF_8, CREATE, TRUNCATE_EXISTING);

} catch (IOException e) {

e.printStackTrace();

}

log.info(“batch file processing done”);

});

}

}).start();

}

可以想象到,这个线程池中的2个线程任务是相当重的。通过printStats方法打印出的日志,我们观察下线程池的负担:

线程池:业务代码最常用也最容易犯错的组件

可以看到,**线程池的2个线程始终处于活跃状态,队列也基本处于打满状态。**因为开启了CallerRunsPolicy拒绝处理策略,所以当线程满载队列也满的情况下,任务会在提交任务的线程,或者说调用execute方法的线程执行,也就是说不能认为提交到线程池的任务就一定是异步处理的。如果使用了CallerRunsPolicy策略,那么有可能异步任务变为同步执行。从日志的第四行也可以看到这点。这也是这个拒绝策略比较特别的原因。

不知道写代码的同学为什么设置这个策略,或许是测试时发现线程池因为任务处理不过来出现了异常,而又不希望线程池丢弃任务,所以最终选择了这样的拒绝策略。不管怎样,这些日志足以说明线程池是饱和状态。

可以想象到,业务代码复用这样的线程池来做内存计算,命运一定是悲惨的。我们写一段代码测试下,向线程池提交一个简单的任务,这个任务只是休眠10毫秒没有其他逻辑:

private Callable calcTask() {

return () -> {

TimeUnit.MILLISECONDS.sleep(10);

return 1;

};

}

@GetMapping(“wrong”)

public int wrong() throws ExecutionException, InterruptedException {

return threadPool.submit(calcTask()).get();

}

我们使用wrk工具对这个接口进行一个简单的压测,可以看到TPS为75,性能的确非常差。

线程池:业务代码最常用也最容易犯错的组件

细想一下,问题其实没有这么简单。因为原来执行IO任务的线程池使用的是CallerRunsPolicy策略,所以直接使用这个线程池进行异步计算的话,当线程池饱和的时候,计算任务会在执行Web请求的Tomcat线程执行,这时就会进一步影响到其他同步处理的线程,甚至造成整个应用程序崩溃

解决方案很简单,使用独立的线程池来做这样的“计算任务”即可。计算任务打了双引号,是因为我们的模拟代码执行的是休眠操作,并不属于CPU绑定的操作,更类似IO绑定的操作,如果线程池线程数设置太小会限制吞吐能力:

private static ThreadPoolExecutor asyncCalcThreadPool = new ThreadPoolExecutor(

200, 200,

1, TimeUnit.HOURS,

new ArrayBlockingQueue<>(1000),

new ThreadFactoryBuilder().setNameFormat(“asynccalc-threadpool-%d”).get());

@GetMapping(“right”)

public int right() throws ExecutionException, InterruptedException {

return asyncCalcThreadPool.submit(calcTask()).get();

}

使用单独的线程池改造代码后再来测试一下性能,TPS提高到了1727:

线程池:业务代码最常用也最容易犯错的组件

可以看到,盲目复用线程池混用线程的问题在于,别人定义的线程池属性不一定适合你的任务,而且混用会相互干扰。这就好比,我们往往会用虚拟化技术来实现资源的隔离,而不是让所有应用程序都直接使用物理机。

就线程池混用问题,我想再和你补充一个坑:Java 8的parallel stream功能,可以让我们很方便地并行处理集合中的元素,其背后是共享同一个ForkJoinPool,默认并行度是CPU核数-1。对于CPU绑定的任务来说,使用这样的配置比较合适,但如果集合操作涉及同步IO操作的话(比如数据库操作、外部服务调用等),建议自定义一个ForkJoinPool(或普通线程池)。你可以参考第一讲的相关Demo。

重点回顾

====

线程池管理着线程,线程又属于宝贵的资源,有许多应用程序的性能问题都来自线程池的配置和使用不当。在今天的学习中,我通过三个和线程池相关的生产事故,和你分享了使用线程池的几个最佳实践。

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

各位读者,由于本篇幅度过长,为了避免影响阅读体验,下面我就大概概括了整理了

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!**

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

各位读者,由于本篇幅度过长,为了避免影响阅读体验,下面我就大概概括了整理了

[外链图片转存中…(img-6nZl9aJb-1713513379233)]

[外链图片转存中…(img-FMfiB1ql-1713513379234)]

[外链图片转存中…(img-CPg87koe-1713513379235)]

[外链图片转存中…(img-3EXPcDNp-1713513379236)]

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值