如何正确使用线程池

本文详细描述了一个项目中遇到的线程池不消费问题,通过排查发现是由于内存溢出导致线程异常结束。作者总结了正确使用线程池的方法,包括添加未捕获异常处理、自定义阻塞策略和线程池配置等。
摘要由CSDN通过智能技术生成

如何正确使用线程池

在我们日常的开发工作中,有个性能优化方向:池化对象,池化的意义在于缓存,通常会将一些对象保存起来,这主要考虑的是对象的创建成本。比如像线程资源、数据库连接资源或者 TCP 连接等,这类对象的初始化通常要花费比较长的时间,如果频繁地申请和销毁,就会耗费大量的系统资源,造成不必要的性能损失。我们就可以使用一个虚拟的池子,将这些资源保存起来,当使用的时候,我们就从池子里快速获取一个即可。
本文主要针对我们工作中常用的线程池,在生产环境出现的线程不消费的问题进行总结和复盘。
一、 项目背景
云会议管理系统可通过多种方式组织会议,支持立即会议、预定会议、周期会议根据客户需要的场景组织会议,会议邀请通知自动提醒,保证会议安全性,让会议便捷又安全。
一场会议中产生的入会、出会、静音、举手等事件消息,业务上要求保证当前会议消息的有序性。目前消息队列使用RabbitMQ(因为会议控制页面涉及多端webSocket同步,RabbitMQ stomp支持webSocket 消息代理)。
为了保证消息的有序性只能单消费者,为了保证消费者的吞吐量,在每个消费者上再开多线程。但是,要警惕顺序性被打破!
目前的解决方案是增加二级内存阻塞队列(虚拟队列)开启多个线程(线程池),消费者取到消息后不做处理,根据key二次分发到多个内存阻塞队列,每个内存队列分配一个线程处理(1对1的关系)提升吞吐量。
流程图如下:
在这里插入图片描述

在此基础之上使用了Spring cloud stream 消息分区能力,生产者将消息数据发送给多个消费者实例时,保证同一场会议的消息数据始终是由同一个消费者实例接收和处理,以增加消费者的数量,提升吞吐量。

二、 生产现象
运维团队告警消息队列消息积压,此时消息队列已经不消费了,查看RabbitMQ的监控页面如下(默认管理端口15672):
在这里插入图片描述

发现Ready和Total一直增加,Unacked为0,确认消息队列Queues消息积压,消息队列阻塞。

三、 紧急处理
通过jps命令查看,发现消费者服务没了,然后查看对应服务的log日志,发现消费者服务报错日志Java.lang.OutOfMemoryError: unable to create new native thread。
通过top命令查看服务器cpu状态,这时候排查发现其他项目组部署的Other服务cpu >100% 、线程数15469个。
在这里插入图片描述

此时紧急修复生产环境,手动重启对应的异常消费服务,以及重启其他项目组部署的Other服务。阻塞的消息队列正常消费,生产环境恢复(客户无感)。

四、 问题再现
一段时间之后运维团队再次告警,队列消息没有正常消费。通过告警信息发现还是消息队列没有消费,发现还是之前出现OutOfMemoryError那台服务器上有问题,集群中另一台服务器上消费者正常消费(消息分区)。
通过jps 查看当前服务器发现消费者服务正常,查看log日志发现MQ消费者正常消费消息,消费日志一直在输出。线程池中的消费线程没有日志输出,而且没有任何错误日志。通过top命令查看服务器cpu状态,这时候发现其他项目组部署的服务还是cpu >100% 、线程数15000+。
为了尽快修复生产环境,通过jstack pid 消费者进程手动保存堆栈信息,然手动kill掉 其他项目组部署的服务,重启消费者服务,生产环境恢复。

五、 问题排查
1、消费者内部的线程池中的消费线程没有日志输出,而且没有任何错误日志,线程池不消费了,根据日志排查发现这消费线程执行到某行代码之后,没有后续的日志,第一反应是消费线程池中线程阻塞了。
2、继续排查代码(这时主观认为还是消费线程阻塞),跟踪消费线程最后的结束日志是application.pulishEvent,认为这里是造成阻塞的原因,因为spingboot 事件是基于观察者模式的默认是同步的,这里可能会引起线程阻塞。
3、继续跟踪application.pulishEvent 阻塞的原因,发现事件消费者有@Async注解,这个是异步执行的,使用的是默认线程池(task-%),所以就否定application.pulishEvent 造成线程阻塞,
4、造成线程阻塞的原因可能是IO操作或者死锁,仔细跟踪消费线程中的逻辑里面的IO操作,包括网络IO、磁盘IO、数据库IO,以及是否死锁,但是没有发现异常的代码。
5、继续排查日志,发现消费线程(一共10个消费线程)结束的日志位置,即结束的代码位置不是在同一行,那么就不是消费线程阻塞,阻塞的话大概率是阻塞在同一行代码,线程不消费只有两种可能一种是线程阻塞,一种是线程异常结束。
6、这个时候才开始查看事故当时的jstack 输出的线程堆栈信息,查找对应的消费线程信息,发现没有对应的消费线程,正好佐证了自己的想法,具体原因就是线程异常结束了。
7、这个时候又重新梳理了一下整个事故的时间点和信息,发现最初的时候就是因为其他项目组的服务造成java.lang.OutOfMemoryError: unable to create new native thread,所以此时怀疑线程异常结束的原因还是OOM导致的。
8、此时在dump目录下发现多个hs_err_pid.log ,只有jvm异常结束才会产生这个问题,里面发现都是oom内存不足导致的jvm进程结束,然后又查看了watchdog(健康检查)的日志发现 hs_err_pid.log产生的时间点和watchdog的日志的时间点是吻合的。

在这里插入图片描述

六、 问题验证
1、因为日志中没有任何报错信息,增加线程UncaughtExceptionHandler,并启动当时出现问题的其他项目组部署的服务,在测试环境经过大量时间压测,希望复现当时的现象,但是每次模拟都是RabbitMQ消费者线程结束不消费的现象,不是想要的线程池线程异常结束现象(证据不足,不足于佐证猜想)
2、换思路继续压测,先启动消费者服务,然后其他项目组部署的服务在启动逐步去吃掉服务器的cpu和内存资源(其他项目组部署的服务在这个过程中被优化过导致一直没有复现,此时已经让对应的代码还原),大量的时间压测之后,最终复现想要的线程池线程oom导致的异常结束。
在这里插入图片描述

最终确认线程池不消费根本原因就是因为java.lang.OutOfMemoryError: unable to create new native thread导致线程异常结束。

七、 总结和复盘

生产出现问题之后,比如本文的OOM,应该第一时间jps查看当前进程是否存在,使用top、free命令查看当前服务器的状态,然后jstack pid 保存当前的线程堆栈,jmap -heap 保存当前的内存信息,然后重启服务,尽快恢复生产环境。
排查问题也应该第一时间去看日志,然后查看jstack和jmap保存的现场信息,最后结合代码去分析,而不是盲目的就看代码,当前除非你对代码非常熟悉。
线程不消费只有两种可能一种是线程阻塞,一种是线程异常结束,本文分析的就是
因为java.lang.OutOfMemoryError: unable to create new native thread原因导致线程异常结束,而其他的error或者exception也会导致线程异常结束,这就需要增加线程UncaughtExceptionHandler异常输出,方便后续问题排查。
另一种线程不消费的原因是线程阻塞,这要充分排查run方法中的网络IO、磁盘IO、数据IO,内存IO等会导致阻塞的操作,比如发生死锁或者CountDownLatch不带超时时间的await()。

线程池正确的使用总结如下:

(一) 增加线程的未捕获异常

首先应该注意,try/catch只能捕获对应线程内的异常Exception,不同的线程组合在一个用try/catch进行包起来不能进行捕获,子线程的异常不会影响到主线程的执行,即使子线程有异常抛出,并且打印了异常信息,主线程依然能够正常运行。
通过业务代码里面手动在每个run方法里面使用try/catch进行捕获,当然不推荐每个线程都这样捕获,因为try/catch 只能对异常Exception进行捕获,对Error是没办法处理的
推荐使用UncaughtExceptionHandler,来检测出由于未捕获异常而终止的情况,并且对此进行处理。

(二) 创建线程池通过 ThreadPoolExecutor 的方式

阿里发布的 Java 开发手册中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
Executors 利用工厂模式提供原生的四种线程池实现方式,但是并不推荐使用,原因是使用 Executors 创建线程池不会传入这个参数而使用默认值所以开发者常常忽略这一参数,而且默认使用的参数会导致资源浪费,严重情况在会造成OOM。
• newFixedThreadPool和newSingleThreadExecutor:主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM
• newFixedThreadPool和newSingleThreadExecutor:主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM

(三) 自定义阻塞策略

ThreadPollExecutor 默认提供的四种拒绝策略:
• CallerRunsPolicy -可能会造成OOM
• AbortPolicy -直接报错,消息丢失
• DiscardPolicy-不报错,消息丢失
• DiscardOldestPolicy-Oldest消息丢失

默认提供的四种拒绝策略各有各的问题,如果业务场景是不允许消息丢失,这个时候就应该自定义阻塞策略,executor.getQueue().put®;当消息达到线程池上线之后阻塞整个线程池,解决oom和线程池原生拒绝策略消息丢失的问题。

(四) 配置线程池需要考虑因素

CPU密集型:尽量使用较小的线程池,Cpu核心数+1;
IO密集型:
可以使用较大的线程池,CPU核心数 * 2 或者(线程等待时间与线程CPU执行时间之比 + 1)* CPU数目;
混合型:可以将任务分为CPU密集型和IO密集型,然后分别使用不同的线程池去处理,根据业务情况而定(执行时间差别较大,拆分为两个线程池;否则没有必要拆分);
当然线程池究竟设置多大要看你的线程池执行的什么任务了,CPU密集型、IO密集型、混合型,任务类型不同,设置的方式也不一样。从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系等角度来分析。并且近可能地使用有界的工作队列。最终的线程参数还是要根据实际业务的压测结果来定

(五) 线程池扩展

• 可以自定义阻塞队列,实现二级线程池,即优先一级线程池,一级线程池满了之后打到二级线程池,然后在放回一级线程池阻塞队列中;
• 可以自定义实现相同key的消息串行,不同key的消息并行的亲缘线程池;
• 可以参考Hystrix,采用线程池实现限流;
• 可以参考tomcat线程池,以调整线程池执行顺序,比如优先核心线程数>最大线程数>线程阻塞队列>拒绝策略;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值