Java线程池没用好,系统直接崩溃了...
2022-03-23 08:34:22来源:石杉的架构笔记
大家好,今天给大家讲一个比较偏硬核技术类的知识,就是 Java 线程池在生产项目中的高并发优化。
可能很多兄弟都听说过 Java 线程池的理论原理,知道他是怎么运作的,但是从来没在项目里玩儿过 Java 线程池,更没在高并发环境下玩儿过 Java 线程池的优化,所以今天我们来一起探讨一下这个 Java 线程池在生产项目中的高并发优化!
线程池的基本工作原理既然要聊线程池,那最起码大家得大概知道一点儿 Java 线程池的基本工作原理,如果要把线程池原理讲清楚,甚至剖析到 JDK 线程池的源码层面,那可能得单独开一篇文章来写,这不是我们这次的主题,所以我们就把线程池最简单的原理给大家讲一下先。
线程池,简单来说,就是他有一个池子,里面放了一堆的线程,这些线程一般是不会销毁的,他们会一直存在,然后你可以不停的给线程池提交任务。
线程池会拿线程出来执行你的任务,任务执行完了以后,线程不会终止,他就继续在线程池里待命就可以了。
我们看下图 1 所示:
图 1
但是这个时候会有一个关键的问题,那就是线程池里的线程数量通常是有限制的。
注意,这里说的是通常,因为 Java 线程池的真正原理来说,其实通过定制化手段,可以让 Java 线程池有各种各样不同的表现,我们这里就是说最基础的一种情况,那就是线程池里的线程数量是固定的,而且是有限的。
所有如果说你要是一下子提交了太多的任务给线程池,然后此时所有的线程都在忙着运行自己的任务呢,这个时候你要是再想提交新的任务,你觉得会如何?任务能提交进去吗?
看下图 2 所示:
图 2
那当然没法提交进去了,但是此时难道线程池只能拒绝你吗?那倒也不是,线程池为了应对这种情况,通常会设置一个队列让你提交任务,让你的任务在队列里等待一段时间,等有线程运行完了自己的任务,空闲出来了,再来运行这个队列里的任务。
注意,这也是通常情况,因为 Java 线程池通过定制其实可以有别的表现,只不过通常线程池我们会这么设置而已。
如下图 3 所示:
图 3
线程池高并发场景下问题剖析好那么接着问题来了,上面这个就是最最基础的 Java 线程池的原理和用法,但是真正投入到一个生产项目里以后,他会遇到什么样的问题呢?
首先最大的一个问题,就是提交到线程池里的任务,可能都是要执行各种网络 IO 的任务。
比如说,RPC 调用其他的服务,或者说是后台处理 DB 里大量的数据,所以很可能会导致线程运行完一个任务要耗费很长时间,从几百毫秒到几秒,甚至几十秒,都有这种可能。
如下图 4 所示:
图 4
第二个问题,大家注意到上图没有,就是有的任务是 RPC 调用,可能仅仅是耗费几百 ms,有的任务是大量数据操作,可能会耗费几十秒。
所以说,其实一个公共的线程池里,运行了各种不同的任务,这就导致了线程池里的一个线程什么时候能执行完一个任务,那是不确定的,因为任务有可能是 RPC 调用,也可能是大数据量处理。
第三个问题,可能有一些任务是在一个 Http 请求里的,原本可能是在一个 Http 请求处理过程中,会依次处理多个耗时的任务。
现在为了优化性能,需要提交多个任务到线程池里,利用多个线程并发执行多个任务,提升本次请求的性能,这个 Http 请求需要等待这多个并发运行的任务都执行结束了,才会给用户返回响应。
如下图 5 所示:
图 5
所以说,终极大问题来了,这种在生产项目里跑的线程池,因为提供给了各种不同的任务来共用,比如说定时 RPC 调用,定时大数据量处理,前台 Http 请求多任务并发。
所以在生产环境繁忙期的时候,可能有如下场景:线程池此时正在运行多个定时 RPC 调用、定时大数据量处理的任务,这些任务又特别的耗时,导致很多线程都是忙碌状态,少数线程是空闲状态。
然后这个时候,系统刚好面向 C 端用户提供的接口有高并发访问的场景,大量 Http 请求过来,每个请求都要提交多个任务给线程池并发运行,导致线程池的少数空闲线程快速的跑满,然后接着大量的任务进入了线程池的队列开始排队等待。
如下图 6 所示:
图 6
这个时候必然会导致大量的 Http 请求出现 hang 死的问题,因为很多 Http 请求的任务都在线程池里排队等待,他们没法运行,Http 请求也就没法返回响应,给用户的感觉就是点击 APP/网页一类的前端,点来点去没反应,系统出现卡顿问题!
如下图 7 所示:
图 7
线程池高并发场景下性能优化针对这种生产环境的问题,我们需要做的第一个最大的改善,就是把各种不同的任务从一个线程池里分离出来,让他们互相之间不要影响。
也就是说,定时 RPC 任务就放一个线程池里去,定时 DB 大量数据处理任务放另外一个线程池里去,然后 Http 请求多任务并发处理放一个独立的线程池,大家各自用自己的线程池和资源,互相之间不影响。
如下图 8 所示:
图 8
如上图所做的话,我们有一个专门处理 Http 请求的线程池,这压力一下子就下来了,因为 Http 请求的任务通常耗时都在几十 ms 到一百 ms 级,整体速度很快,线程池里没有定时 RPC 和定时 DB 访问这种耗时任务进来捣乱了。
所以 Http 请求的专有线程池可以轻松+愉快的快速的处理所有 Http 请求的任务,即使是在高并发场景下,可以通过线程池增加线程资源来合理抗下高并发压力。
另外就是对线上系统生产环境的线程池任务运行,我们通常会在公司里或者项目内研发统一的线程池监控框架。
所有的线程池任务都需要封装到一个线程池监控框架提供的 Class 里,然后通过这个 Class 来实现任务的排队等待与运行耗时的两个维度的监控数据统计。
如下面的代码所示:
// 线程任务包装类,用了装饰设计模式public class RunnableWrapper implements Runnable { // 实际要执行的线程任务 private Runnable task; // 线程任务被创建出来的时间 private long createTime; // 线程任务被线程池运行的开始时间 private long startTime; // 线程任务被线程池运行的结束时间 private long endTime; // 当这个任务被创建出来的时候,就会设置他的创建时间 // 但是接下来有可能这个任务提交到线程池后,会进入线程池的队列排队 public RunnableWrapper(Runnable task) { this.task = task; this.createTime = new Date().getTime(); } // 当任务在线程池排队的时候,这个run方法是不会被运行的 // 但是当任务结束了排队,得到线程池运行机会的时候,这个方法会被调用 // 此时就可以设置线程任务的开始运行时间 public void run() { this.startTime = new Date().getTime(); // 此处可以通过调用监控系统的API,实现监控指标上报 // 用线程任务的startTime-createTime,其实就是任务排队时间 // monitor.report("threadName", "queueWaitTime", startTime-createTime); // 接着可以调用包装的实际任务的run方法 task.run(); // 任务运行完毕以后,会设置任务运行结束的时间 this.endTIme = new Date().getTime(); // 此处可以通过调用监控系统的API,实现监控指标上报 // 用线程任务的endTime - startTime,其实就是任务运行时间 // monitor.report("threadName", "taskRunTime", endTime - startTime); }}
大家通过上面的代码可以清晰的看到,只要我们所有提交到线程池的任务,都用一个框架统一封装的 RunnableWrapper 类,基于装饰模式来进行包装。
此时就可以得到线程任务的创建时间、开始时间、结束时间,接着就可以计算出这个任务的排队耗时、运行耗时,通过监控系统进行上报。
此时我们通过在监控系统里配置告警条件,就可以实现不同线程池的每个任务的耗时指标上报,同时如果有某个线程池的某个线程排队耗时或者运行耗时超过了我们配置的阈值,就会自动告警。
如下图 9 所示:
图 9
总结好了,今天这篇文章到此为止,把我们的线程池在生产项目里的生产问题和高并发如何优化,以及生产环境下的监控方案,都告诉大家了。
希望大家学以致用,以后在项目里用线程池的时候,能够灵活运用咱们文章里学到的知识点。