以 Java 举例,线程的使用过程中遇到了什么痛点?池化的思想的引入,Java 中是如何用线程池来解决这样问题的?Java 线程池在使用中的问题。公司又是如何实践的?
本文章亮点在于,将线程池在 Java 中如何引入、实践、演变的过程,体系化阐述出来。无论是在工作或者面试 都能够辅助同学系统的讲清楚线程池的问题。
随着电脑多核 CPU 的演变,为了充分发挥线程并发,开发人员开始使用多线程来提升系统性能,充分发挥多核 CPU 带来的便利。
每当需要执行任务的时候,我们需要 new 一个线程来运行。而我们知道的是,线程的创建和销毁需要开销,无论是对内存使用或者 CPU 使用,无限制的创建线程,最终的结果就是耗尽内存和 CPU,导致系统无法能接收新的请求,从而系统不可用。这是我们不希望看到的灾难。那么我们可以思考一个问题?我们是否需要每次都创建一个能够提供请求服务的线程?这里的资源是否存在浪费,以及我们如何能够合理控制系统的负载保证正常的水位?
池化定义,参考百度百科
对于存储而言,池化的概念并不陌生。可以说,存储池化概念的提出不始于存储虚拟化技术,在存储从服务器直联存储到以 SAN 或者 NAS 为代表的网络存储的发展过程中,就提出了池化的概念。
我们可以简单的理解为,为了提高某种资源的利用率,可以用于物理或者虚拟中的一种抽象表达思想。
比如我们现在说的线程,我们应该如何运用池化思想呢?我们从线程的痛点得知,我们主要希望解决的问题有 2 点:
我们将线程提前创建好,并放到一个池子中(一个队列容器中)当你需要的时候,我就将该容器池子中的线程提供一个给你,当整个请求链路完成以后,该线程再放回到容器。这样我只需要维护池子中的线程,不再需要每次创建和回收线程。是不是增加了线程的资源复用性,提高了资源的利用率。
上面说的确实解决了资源浪费问题,那么资源使用不可控呢?比如我如何设定合理的池子中的线程数量?如果外部流量请求线程远远超过了我们系统的负载能力,又要如何处理呢?这里我们之前提到了我们多线程其实前提是基于服务器的多核能力。所以我们第一思想基于服务器的多核能力,是否能够计算出合理的线程数量呢?
公式来自于《Java 并发编程实践》
但是实际上这里也是存在一些问题,如果你在实践中用这个公式会发现依然不符合?那么实践中我们会遇到什么问题呢?可以接着往下看公司的实践(基于美团实践场景),这里我们在讲之前先了解下线程池的原理?
我们先来看 ThreadPoolExecutor 的运行流程原理,如下图:
基于我们之前提到的 2 个痛点
举例:假设现在是要下单买个商品。
上面简化了流程主要描述是想说红色【线程分配 A】重复使用 2 次(实际中可能会使用多次/1 次,这里简单举例为了更好说明),这样就避免了我们正常情况下需要创建 3 次线程和回收 3 次线程的开销。如果我们现在有多个用户在完成上面的步骤,那么更能充分发挥线程池的作用,提高了复用性。
从上面图中,我们看到有个缓冲执行和任务拒绝。实际上这两个就代表的是我们刚才说的资源使用不可控的痛点解决。如果现在的用户请求负载超过了线程池实时处理的能力,那么我们可以安排它进入缓存阻塞队列,就是告诉它,我们这边每次只能最多容纳 1000 人,符合 1000 人的情况,都可以进入等待。那么超过最大容纳 1000 人,要怎么办呢?为了保护我们系统的可用性,不好意思,我们拒绝提供服务了。其实这个也比较好理解,比如你现在秒杀商品的时候,你有时候会发现哦,"亲,服务器繁忙,请您稍后再操作!".其实就是对于当前系统服务的处理能力的自我保护,拒绝提供服务了。
首先线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。
线程池的运行主要分成两部分:任务管理、线程管理。
任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:
(1)直接申请线程执行该任务;
(2)缓冲到队列中等待线程执行;
(3)拒绝该任务。
线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。
我们可以看下 Java 中提供的线程池创建类,以及它的核心参数。这里没有说明 Executors 是因为对于实践中更多的自定义线程池会比较好,尤其是涉及到核心参数的自定义。
上面的英文都有很好的解释,鉴于友好的讲解,这里进行进一步说明:
• corePoolSize = 1
• queueCapacity = Integer.MAX_VALUE
• maxPoolSize = Integer.MAX_VALUE
• keepAliveTime = 60 秒
• allowCoreThreadTimeout = false
• rejectedExecutionHandler = AbortPolicy()
我们可以根据上述提供的线程池参数进行自定义线程池的初始化和设置。
参考美团的 2 个事故案例:
我们从事故案例中能看到什么问题?
我们从方案上来说的话,我们目前可以有这三种方案,但是上面的问题也很明显。其实没有一个银弹能够解决这个问题,但是我们却能够想下是否有折中的方案去动态根据不同场景来制定线程池呢?
这里依然参考美团的解决方案,美团基于线程池的痛点问题,提出了可以动态修改线程池参数+可观测性人工处理+告警机制预防。
- tasks :每秒的任务数,假设为 500~1000
- taskcost:每个任务花费时间,假设为 0.1s
- responsetime:系统允许容忍的最大响应时间,假设为 1s
- corePoolSize = 每秒需要多少个线程处理?
* threadcount = tasks/(1/taskcost) =tasks*taskcout = (500~1000)*0.1 = 50~100 个线程。corePoolSize 设置应该大于 50
* 根据 8020 原则,如果 80%的每秒任务数小于 800,那么 corePoolSize 设置为 80 即可
- queueCapacity = (coreSizePool/taskcost)*responsetime
* 计算可得 queueCapacity = 80/0.1*1 = 80。意思是队列里的线程可以等待 1s,超过了的需要新开线程来执行
* 切记不能设置为 Integer.MAX_VALUE,这样队列会很大,线程数只会保持在 corePoolSize 大小,当任务陡增时,不能新开线程来执行,响应时间会随之陡增。
- maxPoolSize = (max(tasks)- queueCapacity)/(1/taskcost)
* 计算可得 maxPoolSize = (1000-80)/10 = 92
* (最大任务数-队列容量)/每个线程每秒处理能力 = 最大线程数
- rejectedExecutionHandler:根据具体情况来决定,任务不重要可丢弃,任务重要则要利用一些缓冲机制来处理
- keepAliveTime 和 allowCoreThreadTimeout 采用默认通常能满足
参考:Spring 的 ThreadPoolTaskExecutor 类 (对 JDK ThreadPoolExecutor 的一层包装,可以理解为装饰者模式)的 setCorePoolSize 方法。
也就是说原生的 JDK 本身就是提供了动态修改线程池参数的方法。是不是这个时候发现自己学艺不精了,还是源码是最好的书籍啊。
希望本文能够带你了解了线程池是如何引入的?解决了多线程的什么问题?线程池的原理是如何解决痛点的?那么公司实践中又会遇到什么问题?以及公司美团是如何解决的?希望本篇文章对你有帮助,体系化的梳理了知识。
参考资料:https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。