线程池的合理使用
- 一、简介
- 二、为什么要使用线程池
- 三、核心参数
- 四、如何合理配置线程参数
- 1.1 corePoolSize && maximumPoolSize
- 1.2 Handler 拒绝策略
- 1.2.1AbortPolicy:
- 优势:
- 劣势:
- 1.2.2 DiscardPolicy:
- 优势:
- 劣势:
- 1.2.3 CallerRunsPolicy适合以下场景下使用:
- 保证任务的执行:
- 控制任务提交速率:
- 避免任务丢失:
- 优势:
- 小结
- 1.3 WorkQueue
- 五、底层原理
- 扩展
- 六、注意事项
- 1.1 项目中首页灵感盒子接口总结
- 1.2 父子任务共用同一线程池,系统饥饿死锁
- 1.3 future的使用注意事项
一、简介
线程池(Thread Pool)是一种基于池化思想管理线程的工具,在Java中的体现是ThreadPoolExecutor类。
二、为什么要使用线程池
Java 中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理地使用线程池能够带来以下好处。
- 降低资源消耗。
通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 - 提高线程的可管理性。
在系统启动时就将对应业务线程池创建好,当任务到达时,任务可以不需要等到线程创建就能立即执行。 - 提高响应速度。
线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。但是,要做到合理利用线程池,必须对其实现原理了如指掌。
三、核心参数
- corePoolSize:核心线程数目,线程池中的常驻核心线程数
- maximumPoolSize:最大线程数目,线程池中能够容纳同时执行的最大线程数
- keepAliveTime:空闲线程的存活时间,当前线程池数量超过corePoolSize时,当空闲时间达到keepAliveTime时,多余空闲线程会被销毁直到只剩下corePoolSize个线程为止
- unit时间单位
- workQueue:阻塞队列,被提交但是尚未被执行的任务
- threadFactory:线程工厂-可以为线程创建时起个好名字
- handler:拒绝策略,表示当队列满了并且工作线程-大于等于线程池的数量最大线程数(maxinumPoolSize)时如何来拒绝请求执行的策略
四、如何合理配置线程参数
1.1 corePoolSize && maximumPoolSize
针对核心线程数 首先要分析业务的平时流量情况。通过监控和统计数据,了解每个时间段的请求量和并发数,核心线程数的配置也要考虑服务器的硬件资源。比如,CPU的核心数、内存容量等。确保配置的核心线程数不超过服务器硬件能够支持的最大并发数。
除了平时流量外,还需要考虑业务的长期趋势,此时maximumPoolSize就可以派上用场了,可以预留一部分线程来应对系统的流量突然激增,等到流量高峰过了,达到 keepAliveTime ,非核心线程就会进行回收,避免浪费了系统资源。
网上大部分情况一般都是看任务是IO密集型还是CPU密集型,如果是任务密集型配 2N,如果是CPU密集型则配置成N+1 (N代表CPU核心数),实际上大部分业务是比较复杂的,既有CPU密集型也有IO密集型,最好的方式是通过压测来确定下来,压测请参考:接口压测指南
1.2 Handler 拒绝策略
RejectedExecutionHandler的实现JDK自带的默认有4种
- AbortPolicy:丢弃任务,抛出运行时异常
- CallerRunsPolicy:由提交任务的线程来执行任务(主线程)
- DiscardPolicy:丢弃这个任务,但是不抛异常
- DiscardOldestPolicy:从队列中剔除最先进入队列的任务,然后再次提交任务
线程池创建的时候,如果不指定拒绝策略默认就是 AbortPolicy 策略。当然,你也可以自己实现RejectedExecutionHandler 接口,比如将任务存在数据库或者缓存中,这样就能够从数据库或者缓存中获取到被拒绝掉的任务了。
AbortPolicy和DiscardPolicy是ThreadPoolExecutor类中的两种常见拒绝策略,它们各自有不同的优缺点:
1.2.1AbortPolicy:
优势:
当线程池无法接受新任务时,立即抛出RejectedExecutionException异常,能够迅速通知调用者任务无法执行,保证系统稳定性。
劣势:
可能会导致任务丢失,对于一些重要的任务无法执行可能会对系统造成影响。
1.2.2 DiscardPolicy:
优势:
默默地丢弃无法处理的任务,不会抛出异常,不影响线程池继续处理其他任务,避免了异常抛出的开销。
劣势:
丢弃任务可能会造成任务丢失,不提供任何反馈给调用者,无法知道任务是否被执行。
选择使用哪种策略取决于具体的业务需求和系统设计。如果希望及时发现线程池无法处理新任务并进行处理,可以选择AbortPolicy;如果对于一些任务丢失可以接受,并且不希望抛出异常影响线程池的运行,可以选择DiscardPolicy。
1.2.3 CallerRunsPolicy适合以下场景下使用:
保证任务的执行:
当线程池无法接受新任务时,希望任务能够得到执行而不是被丢弃。
控制任务提交速率:
当某些情况下需要限制任务提交的速率,而且对任务执行时长没有特别要求时,可以选择使用CallerRunsPolicy。
避免任务丢失:
希望尽可能保证所有任务都能够被执行,即使需要调用线程来执行任务。
优势:
能够保证任务的执行,即使线程池无法接受新任务也能够确保任务不被丢弃。
可以避免因为任务被拒绝导致的异常抛出,保证线程池的稳定性。
小结
总之,CallerRunsPolicy可以保证任务得到执行,适用于对任务执行顺序要求不苛刻、重要性较高的场景下使用。
1.3 WorkQueue
任务队列:看需求情况选择无界队列还是有界队列
- 线程池的任务队列本来起缓冲作用,但是如果设置的不合理会导致线程池无法扩容至max,这样无法发挥多线程的能力,导致一些服务响应变慢。
- 队列长度要看具体使用场景,取决服务端处理能力以及客户端能容忍的超时时间等
- 建议采用tomcat的处理方式,core与max一致,先扩容到max再放队列,不过队列长度要根据使用场景设置一个上限值,如果响应时间要求较高的系统可以设置为0。
五、底层原理
扩展
jdk自带的线程池与tomcat线程池区别
- 线程创建时机:
- 普通线程池:先使用核心线程,然后任务进入队列,再创建额外线程。
- Tomcat 线程池:在核心线程忙时,会直接创建新的线程直到 maxThreads。
- 队列的使用:
- 普通线程池:核心线程忙时,任务排队,队列满时才创建新的线程。
- Tomcat 线程池:在所有线程都忙时,任务才进入队列。
tomcat线程池更注重快速响应,会在核心线程忙时立即创建新线程,直到达到 maxThreads,只有在所有线程都忙时才使用队列来排队请求。
六、注意事项
1.1 项目中首页灵感盒子接口总结
由于线上核心线程数和最大线程数配置太小10-20,平时访问人数可能没有多少,但是一旦运营搞活动就会访问到首页的灵感盒子功能,导致请求进行堆积阻塞,处理不过来,导致对应服务大面积超时。
- 经过分析压测,最终调整核心线程数和最大线程数100-300,同时拒绝策略由调用者线程改为直接丢弃策略,同时利用本地缓存使得请求快速响应,最终达到一个单机的性能瓶颈,使得线程池的合理使用达到一个最佳的状态。
- 同时代码中存在不同业务,共用了一个线程池,也就是父子任务共用一个线程池,这也是不合理的,应该根据不同业务使用不同的线程池,进行资源隔离。
- 剩下就是针对超时时间的配置,不要太长,否则容易将线程资源耗尽,影响正常的请求。
1.2 父子任务共用同一线程池,系统饥饿死锁
比如A方法调用B方法,AB方法称为父子任务,当他们都被同一个线程池执行时,一定条件下会出现以下场景:
- 父任务获取到线程池线程执行,而子任务则被暂存到队列中
- 当父任务沾满了线程池里所有的线程,等待子任务返回结果后,结束父任务
- 此时子任务由于在队列中,一直不能等到线程来处理,导致不能从队列中释放
- 父子任务互相等待,从而早饥饿死锁
1.3 future的使用注意事项
- 项目中对于CompletableFuture的使用最好不要使用join,使用join会导致线程会一直阻塞直到超时时间才会返回,应该换成getNow,立即返回,即使没有拿到结果可以返回一个默认值。
- 值得注意的是虽然 getNow 不会阻塞线程,但是线程没有执行完毕,例如
执行allOf().get(超时时间)超时,依然可以拿到结果(默认值),在这种情况下 并不会回收线程 终止任务,网上说可以使用 cancel 或completed 等方法来进行终止关闭掉没有执行完毕的线程,遗憾的是本地经过测试并没有生效,也可能是自己使用的问题。