dbaplus社群 2024年10月24日
8 个线程池的深渊巨坑,使用不当直接生产事故!!!
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

文章总结了使用线程池时应注意的坑及优秀实践,包括正确声明线程池、监测其运行状态、为不同业务配置不同线程池、命名线程池、正确配置参数、避免重复创建等方面,内容详细实用。

正确声明线程池:手动通过ThreadPoolExecutor构造函数声明,避免使用Executors类创建,因其可能导致OOM。需根据机器性能和业务场景手动配置线程池参数,如核心线程数、任务队列、饱和策略等,并为线程池命名以助于定位问题。

监测线程池运行状态:可通过SpringBoot中的Actuator组件或ThreadPoolExecutor的相关API进行简陋监控,获取线程池的线程数、活跃线程数、已完成任务数、排队任务数等信息,并给出了简单的Demo。

建议不同业务用不同线程池:根据业务情况配置线程池,优化系统性能瓶颈相关业务,并通过一个死锁案例说明为子任务新增线程池服务可解决死锁问题。

正确配置线程池参数:介绍了常规配置方式及适用场景,如CPU密集型任务(N+1)、I/O密集型任务(2N),还提到了更严谨的计算方法。此外,介绍了美团的线程池参数动态配置思路及相关开源项目。

避免线程池使用的其他坑:包括避免重复创建线程池、注意Spring内部线程池的合理配置、解决线程池和ThreadLocal共用可能导致的获取旧值/脏数据问题,可使用阿里巴巴开源的TransmittableThreadLocal解决。

漫走云雾 2024-10-24 07:15 广东

如何正确实践并完美避坑?


这篇文章,我会简单总结一下,我了解的使用线程池的时候应该注意的坑,以及一些优秀的实践。拿来即用,美滋滋!


内容概览:



一、正确声明线程池


线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类创建线程池,会有 OOM 风险。


Executors 返回线程池对象的弊端如下(后文会详细介绍到):



说白了就是:使用有界队列,控制线程创建数量


除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:



二、监测线程池运行状态


你可以通过一些手段来检测线程池的运行状态比如 SpringBoot 中的 Actuator 组件。


除此之外,我们还可以利用 ThreadPoolExecutor 的相关 API 做一个简陋的监控。从下图可以看出, ThreadPoolExecutor提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。



下面是一个简单的 Demo。printThreadPoolStatus()会每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数。


/** * 打印线程池的状态 * * @param threadPool 线程池对象 */public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) {    ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory("print-images/thread-pool-status", false));    scheduledExecutorService.scheduleAtFixedRate(() -> {        log.info("=========================");        log.info("ThreadPool Size: [{}]", threadPool.getPoolSize());        log.info("Active Threads: {}", threadPool.getActiveCount());        log.info("Number of Tasks : {}", threadPool.getCompletedTaskCount());        log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());        log.info("=========================");    }, 0, 1, TimeUnit.SECONDS);}

三、建议不同类别的业务用不同的线程池


很多人在实际项目中都会有类似这样的问题:我的项目中多个业务需要用到线程池,是为每个线程池都定义一个还是说定义一个公共的线程池呢?


一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。


我们再来看一个真实的事故案例! (本案例来源自:《线程池运用不当的一次线上事故》[1] ,很精彩的一个案例)


案例代码概览


上面的代码可能会存在死锁的情况,为什么呢?画个图给大家捋一捋。


试想这样一种极端情况:假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 "死锁"


线程池使用不当导致死锁


解决方法也很简单,就是新增加一个用于执行子任务的线程池专门为其服务。


四、别忘记给线程池命名


初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。


默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。


给线程池里的线程命名通常有下面两种方式:


1、利用 guava 的 ThreadFactoryBuilder


ThreadFactory threadFactory = new ThreadFactoryBuilder()                        .setNameFormat(threadNamePrefix + "-%d")                        .setDaemon(true).build();ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory)

2、自己实现 ThreadFactor


import java.util.concurrent.Executors;import java.util.concurrent.ThreadFactory;import java.util.concurrent.atomic.AtomicInteger;/** * 线程工厂,它设置线程名称,有利于我们定位问题。 */public final class NamingThreadFactory implements ThreadFactory {
private final AtomicInteger threadNum = new AtomicInteger(); private final ThreadFactory delegate; private final String name; /** * 创建一个带名字的线程池生产工厂 */ public NamingThreadFactory(ThreadFactory delegate, String name) { this.delegate = delegate; this.name = name; // TODO consider uniquifying this }
@Override public Thread newThread(Runnable r) { Thread t = delegate.newThread(r); t.setName(name + " [#" + threadNum.incrementAndGet() + "]"); return t; }
}

五、正确配置线程池参数


说到如何给线程池配置参数,美团的骚操作至今让我难忘(后面会提到)!


我们先来看一下各种书籍和博客上一般推荐的配置线程池参数的方式,可以作为参考!


常规操作


很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换成本。不清楚什么是上下文切换的话,可以看我下面的介绍。


上下文切换:

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。


上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。


Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。


类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。



有一个简单并且适用面比较广的公式:



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


CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 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(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。


公示也只是参考,具体还是要根据项目实际线上运行情况来动态调整。我在后面介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!


美团的骚操作


美团技术团队在《Java 线程池实现原理及其在美团业务中的实践》[3]这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。


美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:



为什么是这三个参数?


如何支持参数动态配置?且看 ThreadPoolExecutor 提供的下面这些方法。



格外需要注意的是corePoolSize, 程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize,如果大于的话就会回收工作线程。


另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。


最终实现的可动态修改线程池参数效果如下。


动态配置线程池参数最终效果


如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:



线程池使用的一些小坑


六、重复创建线程池的坑


线程池是可以复用的,一定不要频繁创建线程池比如一个用户请求到了就单独创建一个线程池。


@GetMapping("wrong")public String wrong() throws InterruptedException {    // 自定义线程池    ThreadPoolExecutor executor = new ThreadPoolExecutor(5,10,1L,TimeUnit.SECONDS,new ArrayBlockingQueue<>(100),new ThreadPoolExecutor.CallerRunsPolicy());

// 处理任务 executor.execute(() -> { // ...... } return "OK";}

出现这种问题的原因还是对于线程池认识不够,需要加强线程池的基础知识。


七、Spring 内部线程池的坑


使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)。


@Configuration@EnableAsyncpublic class ThreadPoolExecutorConfig {
@Bean(name="threadPoolExecutor") public Executor threadPoolExecutor(){ ThreadPoolTaskExecutor threadPoolExecutor = new ThreadPoolTaskExecutor(); int processNum = Runtime.getRuntime().availableProcessors(); // 返回可用处理器的Java虚拟机的数量 int corePoolSize = (int) (processNum / (1 - 0.2)); int maxPoolSize = (int) (processNum / (1 - 0.5)); threadPoolExecutor.setCorePoolSize(corePoolSize); // 核心池大小 threadPoolExecutor.setMaxPoolSize(maxPoolSize); // 最大线程数 threadPoolExecutor.setQueueCapacity(maxPoolSize * 1000); // 队列程度 threadPoolExecutor.setThreadPriority(Thread.MAX_PRIORITY); threadPoolExecutor.setDaemon(false); threadPoolExecutor.setKeepAliveSeconds(300);// 线程空闲时间 threadPoolExecutor.setThreadNamePrefix("test-Executor-"); // 线程名字前缀 return threadPoolExecutor; }}

八、线程池和 ThreadLocal 共用的坑


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


不要以为代码中没有显示使用线程池就不存在线程池了,像常用的 Web 服务器 Tomcat 处理任务为了提高并发量,就使用到了线程池,并且使用的是基于原生 Java 线程池改进完善得到的自定义线程池。


当然了,你可以将 Tomcat 设置为单线程处理任务。不过,这并不合适,会严重影响其处理任务的速度。


server.tomcat.max-threads=1


解决上述问题比较建议的办法是使用阿里巴巴开源的 TransmittableThreadLocal(TTL)。TransmittableThreadLocal类继承并加强了 JDK 内置的InheritableThreadLocal类,在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。


InheritableThreadLocal 项目地址:https://github.com/alibaba/transmittable-thread-local



>>>>

参考资料




作者丨漫走云雾

来源丨网址:https://blog.csdn.net/m0_67847535/article/details/136009069

dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn


跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

线程池 OOM风险 运行状态监测 参数配置 避免踩坑
相关文章