温故而知新,可以为师矣。
线程池是什么
线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL。
池化思想,就是为了提高对资源的利用率,减少对资源的管理,不用思考细枝末节,直接拿来就用。它的应用有很多,比如数据库连接池,内存池等等。
线程池的好处以及使用
1.线程池的好处
使用线程池有着很多好处,比如我们无需关心线程池内部线程的调度,只要按照设定好的参数去配置即可快速使用,线程池可以按照既定的规则去分配资源,执行对应的任务。针对于线程池的好处,在美团的《Java线程池实现原理及其在美团业务中的实践》中是这样描述的:
- 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
- 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
- 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。
同时,《阿里巴巴 Java 开发手册泰山版》中有着这样的强制规定:
【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
说明:线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。 如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。
2.线程池的使用
线程池的创建可以分为两类,通过 Executors 或者 ThreadPoolExecutor 来创建。由于通过 Executors 来创建线程池使用的是默认参数,存在各样弊端,因此在《阿里巴巴 Java 开发手册泰山版》被强制规定不允许通过 Executors 来创建,他的目的是让使用者将设置的参数暴露出来,能够更加清晰地使用:
- newFixedThreadPool和 newSingleThreadExecutor: 主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
- newCachedThreadPool和 newScheduledThreadPool: 主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
因此这里只讨论 ThreadPoolExecutor 的使用,使用的是 JDK1.8。
3.通过 ThreadPoolExecutor 创建线程池
目前可通过 ThreadPoolExecutor 来创建 7 种线程池:
这里只给出创建 ArrayBlockingQueue 的例子:
public class ThreadPoolDemo { private static ExecutorService pool = new ThreadPoolExecutor(4, Runtime.getRuntime().availableProcessors() * 2, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(Integer.MAX_VALUE), r -> new Thread(r, "ThreadTest")); public static void main(String[] args) { for (int i = 0; i < 100; i++) { pool.execute(new Task(i)); } }
} class Task implements Runnable{ private int i; Task(int i){ this.i = i; } @Override public void run() { System.out.println(Thread.currentThread().getName() + i); }
}
结果:
····
ThreadTest13
ThreadTest18
ThreadTest17
ThreadTest16
ThreadTest21
ThreadTest20
ThreadTest19
ThreadTest24
ThreadTest23
····
4.线程池的核心参数
ThreadPoolExecutor 的核心参数总共有 7 个:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler){····}
参数名 | 解释 | 作用 |
---|---|---|
corePoolSize | 线程池核心线程大小 | 线程池维护的最小线程数量,即使线程处于空闲状态,也不会被销毁,除非设置了「allowCoreThreadTimeOut」。 |
maximumPoolSize | 线程池最大线程数量 | 当线程数已经达到了「corePoolSize」,此时如果有任务继续提交到线程池,会将任务添加到阻塞队列中,如果设置的阻塞队列也满了,则会重新申请新的线程来执行任务,一直到最大线程数量 |
keepAliveTime | 空闲线程存活时间 | 一个线程如果处于空闲状态,并且当前的线程数量大于corePoolSize,那么在指定时间后,这个空闲线程会被销毁 |
unit | 空闲线程存活时间单位 | NANOSECONDS :1微毫秒 = 1微秒 / 1000 MICROSECONDS :1微秒 = 1毫秒 / 1000 MILLISECONDS :1毫秒 = 1秒/1000 SECONDS :秒 MINUTES :分 HOURS :小时 DAYS :天 |
workQueue | 工作队列 | 当线程池的所有线程都在处理任务时,若来了新任务则会缓存到此任务队列中,然后等待执行 |
threadFactory | 线程工厂 | 创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等 |
RejectedExecutionHandler | 拒绝策略 | 当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,此时针对于任务的拒绝策略 |
线程池的设计和实现
上面简单介绍了线程池的一些使用和参数,下面主要看下线程池对于如何处理线程的执行:
通过图中,可以简单理解为,如果任务提交到线程池中,线程池会根据一些策略来决定是立刻执行任务还是放在任务缓存队列中。具体的执行策略规则是由线程池中「线程数」和「队列」来决定的:
关于线程池的源码解析等,不是本次分享的重点,网上有很多文章内容可以参考学习,这里给一篇比较好的分析文章: Java线程池源码解析
线程池参数设置参考方案
1.为什么要考虑线程池的参数设置?
先来看下因为线程池参数设置导致的事件:
1)美团-2018年XX页面展示接口大量调用降级:
事故描述:XX页面展示接口产生大量调用降级,数量级在几十到上百。
事故原因:该服务展示接口内部逻辑使用线程池做并行计算,由于没有预估好调用的流量,导致最大核心数设置偏小,大量抛出RejectedExecutionException,触发接口降级条件。
2)美团-2018年XX业务服务不可用S2级故障
事件描述:XX业务提供的服务执行时间过长,作为上游服务整体超时,大量下游服务调用失败。
事故原因:该服务处理请求内部逻辑使用线程池做资源隔离,由于队列设置过长,最大线程数设置失效,导致请求数量增加时,大量任务堆积在队列中,任务执行时间过长,最终导致下游服务的大量调用超时失败。
上面对线程池使用不当造成的事故,会让我们对线程池的参数设置有一个思考,怎样设置参数才是最好的,这其中包括了对系统性能的思考。比如:
- 线程池的线程数量设置过多,会使线程竞争激烈,影响系统性能
- 线程池的线程数量过少,会浪费系统资源。这里还涉及到任务的耗时情况,假设任务耗时较长,且使用了无界队列,会导致容器内存 OOM。
这里普及一个小知识:线程和 CPU 核数有什么关系?
CPU 核数和线程数并没有什么关系,单核 CPU 一样可以执行多个线程,只是单核 CPU 同一时间只能执行一个线程,多线程会增加线程上下文切换。
2.参数设置
网上关于参数设置的讨论分为四个方面。
2.1 根据业务进行推算
一个任务的执行时间是0.1s,那么1s内可以执行10个,500个任务就需要500/10 = 50个,即 n / (1/每个任务花费时间) = n * 每个任务花费时间
那么至少要设置50~100个线程,28原则设置为80
这种估算比较粗糙,由于业务发展,业务的执行时间不能够完全确定,而且任务的数量也可能随着时间的变化而变化。
2.2 根据 CPU 和 IO 进行推算
1)CPU 密集型:最佳线程数 = CPU 核数 +1
对于 CPU 密集型场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在实际项目中,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
2)IO 密集型:最佳线程数=2 * CPU 核数
因为IO设备的读写速度远低于CPU的执行速度,所以IO密集型的任务执行时间要比CPU密集型长很多。而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,可以多配置一些线程,根据经验具体的计算方法是:
最佳线程数=2*CPU核数
。
3)IO密集型和CPU密集型交叉运行:最佳线程数 =CPU 核数 * ( 1 +(I/O 耗时 / CPU 耗时))
对于 I/O 密集型和CPU密集型交叉运行的场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,可以总结出一个公式:
最佳线程数 =1 +(I/O 耗时 / CPU 耗时)
。令 N=I/O 耗时 / CPU 耗时,当一个线程 执行 IO 操作时,另外 N个线程正好执行完各自的 CPU 计算,这样 CPU 的利用率就达到了 100%。
2.3 根据 QPS 和 P99 估算
在Hystrix文档中,官方这样建议去设置核心线程数:
requests per second at peak when healthy × 99th percentile latency in seconds + some breathing room
每秒最大支撑的请求数 * 99%平均响应时间 + 缓冲值
链接如下:https://github.com/Netflix/Hystrix/wiki/Configuration#ThreadPool
假如每秒可以支撑 100 个请求,99%的响应时间为0.1s,缓冲值为4,那么计算公式为:100 * 0.1s + 4 = 14个最大线程。
2.4 动态设置线程池参数
参数设置的核心难点是,由于业务的变更和发展,并且随着容器化的推进,不容易估计业务所需要的参数,可能会造成一些问题。美团在Java线程池实现原理及其在美团业务中的实践中提出了动态化线程池的设计,在原生的线程池基础上,增加了动态化配置参数和监控,能够实时监控和优化线程池的使用情况。
动态化线程池设计:https://github.com/mabaiwan/hippo4j
以目前的情况来看,这种方式可能是最好的。