线程池
自定义线程池
自定义线程池
ThreadPoolExcutor构造函数
ThreadPoolExcutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize就是核心线程数,核心线程数就是可以在线程池长久存活的线程数量
- maximumPoolSize就是最大线程数,最大线程数 >= 核心线程数,代表核心线程数满之后可以创建多少临时线程数
- keepAliveTime是临时线程可以空闲的时间,超过这个时间没任务临时线程就会被销毁
- unit则是这里keepAliveTime的时间单位
- workQueue是任务队列,当核心线程数已满时,来不及处理的任务就会被放到workQueue中,等核心线程处理完任务后再从队列中取任务,不能为null(自定义的需要实现了BlockingQueue
) - threadFactory就是线程的工厂,指定如何创建线程,比如可以根据不同的业务,自定义线程的命名,不能为null(自定义的话需要实现ThreadFactory接口里的newThread方法)
- RejectExecutionHandler是拒绝的策略,但核心线程数满了,队列也满了,临时线程也在忙的时候,就会出发这个拒绝策略,决定此时新来的任务该如何处理,不能为null(自定义的话需要实现RejectedExecutionHandler接口里的rejectedExecution方法)
提交一个新任务到线程池时,具体的执行流程 / 线程池的工作流程
- 提交任务时会根据corePoolSize的大小创建若干个线程来执行任务
- 如果任务数量超过了corePoolSize,后续的任务就会进入workQueue
- 当核心线程空闲后就会去workQueue中取任务执行
- 当workQueue也满了之后,就会检查maximumPoolSize - corePoolSize是否大于0,如果大于0就会创建临时线程来处理任务,不大于则直接触发拒绝策略.
- 临时线程处理完任务后,如果空闲时间超过keepAliveTime就会被销毁
- 但核心线程数和临时线程以及workQueue都满了之后,就会出发对应的拒绝策略。
线程池的线程数如何设置?
绝大多数情况下需要根据压测的效果来不断更正设置,但主要可以有两种方向:
- cpu密集型:即任务需要大量的计算,很少阻塞,cpu一直处于繁忙状态,那么这时候为了能充分利用cpu的计算能力,线程数就应该设置为 cpu核心线程数 + 1,这里+1是避免意外的情况导致线程被阻塞(对于cpu密集形corePoolSize和maximumPoolSize推荐都设置为cpu + 1,因为此场景下,cpu是瓶颈,如果线程数大于cpu核心数就会出现大量线程争夺同一个cpu资源,导致大量线程上下文的切换,消耗资源和时间,越多反而越慢)
- IO密集型:即任务需要频繁的IO操作,如磁盘/网络传输等,CPU经常需要等待IO完成,即容易被阻塞,所以为了避免阻塞消耗掉过多的线程,线程数可以设置为 cpu核心数 * 2(这里具体乘多少得看实际压测下来线程有多少时间被阻塞住,假如一个线程80%的时间都在等待IO,那么阻塞系数就是0.8, 那么推荐的线程数就是:CPU / (1 - 0.8)。(在此场景下由于线程巨大部分时间都被IO阻塞住了,等的时间>算的时间,所以当线程被阻塞住时,就可以让其他线程去执行任务,需要用更多的线程才能把cpu填满。通常corePoolSize设置为cpu核心数,maximumPoolSize设置为cpu/(1 - 阻塞系数))
但实际业务中其实不可能出现纯cpu计算和纯IO场景的,更多时候是两者都需要,所以线程参数的设置不能这么死板,通常建议把cpu计算和IO场景的线程池拆开,不耦合在一个线程池里这样就不会因为cpu计算而设置线程数过少,导致IO使用时阻塞很严重。也不会因为IO设置线程数过多,在cpu计算时导致大量线程同时争夺cpu时间片,频繁上下文切换,浪费时间
可以通过Runtime.getRuntime().availableProcessor()来获取CPU的最大核心数
如何预先创建核心线程
默认情况下,线程池是按需创建线程,也就是说来一个任务创建一个核心线程,如果第二个任务来的时候第一个核心线程还没执行完任务,那么才会根据corePoolSize的大小来创建第二个线程
当然也可以通过这两个方法来预先创建核心线程
- prestartAllCoreThreads(),会创建所有的核心线程,并返回创建的线程数,适合那些服务一起动就要抗流量的场景,比如说什么网关之类的
- prestartCoreThread(),这个方法会创建一个核心线程,并返回一个boolean来表示是否创建成功。这个方法就比较适合那些需要渐进式预热的场景,希望控制创建节奏
几个关于预先创建核心线程的误区:
- 预创建不等于立刻执行任务,线程创建后会阻塞在getTask(),等待任务,不会空转,不会烧cpu
- 预创建的线程是阻塞态,不会浪费cpu资源
- 预创建只影响corePoolSize,不影响maximumPoolSize
- 预创建的线程不会超过定义的corePoolSize
创建的核心线程能销毁吗?
是的可以,有一个processWorkerExit方法中,会判断allowCoreThreadTimeOut参数的值,如果是false,那么核心线程数就会保留在线程池中,如果是true,代表最小的线程数可以是0,也就代表核心线程数能够被销毁
为什么线程池要先使用阻塞队列,而不是直接增加线程?
因为每创建一个线程都会占用一定的系统资源(如栈空间、线程调度开销等),直接增加线程会迅速消耗系统资源,导致性能下降。
使用阻塞队列可以将任务暂存,避免线程数量无限增长,确保资源利用率更高。
如果阻塞队列都满了,说明此时系统负载很大,再去增加线程到最大线程数去消化任务即可。
举一个很简单的例子:饭店老板手下有10个员工,一开始吃饭的人路续进来点餐,十个员工都能招呼,后面员工越来越多,老板就会说:不好意思,麻烦现在这等一下。等到来的人越来越多,已经处理不了了,老板就回去招几个人来帮忙(招人要付钱啊,所以一开始能等就等,实在等不了了,万一顾客等久了投诉怎么办?所以就必须得招人了)。如果招了人也处理不了了,那只好告诉客人小店目前忙不过来了,你可以选择排队(做任务持久化,得等很久才处理),或者是去找下一家(直接丢弃任务)。等人少后,原来的10个员工已经能处理了,那招来的临时工肯定就得让他们走了,因为不走就得一直给钱啊(线程资源很昂贵,创建了又不用就是浪费资源,所以得销毁)
为什么Tomcat在 < maximumPoolSize 的时候都是优先创建线程?而Java确是等阻塞队列满了才会创建临时线程?
其核心原因在于面对的场景不同,Tomcat他有明确的使用场景,因为Tomcat作为Web服务器,他的诉求是尽可能快速得响应请求。
因为当用户点击页面,也就是发送一个Http请求时,如果你的策略明明可以创建线程,但选择先存入队列的话,那用户视角响应就会变慢,不仅影响用户体验,更是将你系统的QPS整体拖慢了。而Web场景下,其实大多数时候没有复杂的运算,因此真正占用CPU的时间很少,大部分线程都是被IO阻塞住的,如果在能扩线程的情况下不扩,就会导致队列越来越长,响应时间越来越长。
因此对于Tomcat这类Web服务器而言,他们的理念就是“用更多的线程换取响应速度”。
而反过头来看Java的线程池设计,为什么要这么设计呢?因为Java线程池本质上是一个通用线程池,你的使用场景其实是不明确的。他的应用场景更多是:异步,定时任务,后台计算,消息消费等等。而这些任务通常情况下不需要立刻执行并响应,是允许排队的,更强调资源稳定。
线程池有哪些拒绝策略
- AbortPolicy,当任务队列满且没有线程空闲,此时添加任务会直接抛出 RejectedExecutionException 错误,这也是默认的拒绝策略。适用于必须通知调用者任务未能被执行的场景。
- CallerRunsPolicy,当任务队列满且没有线程空闲,此时添加任务由即调用者线程执行。适用于希望通过减缓任务提交速度来稳定系统的场景。
- DiscardOldestPolicy,当任务队列满且没有线程空闲,会删除最早的任务,然后重新提交当前任务。适用于希望丢弃最旧的任务以保证新的重要任务能够被处理的场景。
- DiscardPolicy,直接丢弃当前提交的任务,不会执行任何操作,也不会抛出异常。适用于对部分任务丢弃没有影响的场景,或系统负载较高时不需要处理所有任务。
自定义拒绝策略需要实现RejectedExecutionHandler接口里的rejectedExecution方法
Java中提供了哪些现成的线程池
需要注意,根据阿里巴巴开发手册,在实际业务场景中严禁使用JUC提供的线程的线程池,必须自己手动创建线程池并配置参数。不仅能避免很多不必要的问题,也能让开发人员清楚,自己目前的线程池是用来干嘛的,如何运作的,并且这些Executors也是通过return new ThreadPoolExecutor()创建的。因为很多JUC提供的线程池队列都是无界的,这种场景下maximumPoolSize根本没用,且任务不断累积后很容易触发OOM
Executors 返回的线程池对象的弊端如下:
1)FixedThreadPool,SingleThreadPool,ScheduledThreadPool: 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
2)CachedThreadPool: 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
Java 并发库中提供了 5 种常见的线程池实现,主要通过 Executors 工具类来创建。
1)FixedThreadPool:创建一个固定数量的线程池。
线程池中的线程数是固定的,空闲的线程会被复用。如果所有线程都在忙,则新任务会放入队列中等待。
适合负载稳定的场景,任务数量确定且不需要动态调整线程数。
2)CachedThreadPool:一个可以根据需要创建新线程的线程池。
线程池的线程数量没有上限,空闲线程会在 60 秒后被回收,如果有新任务且没有可用线程,会创建新线程。
适合短期大量并发任务的场景,任务执行时间短且线程数需求变化较大。
3)SingleThreadExecutor:创建一个只有单个线程的线程池。
只有一个线程处理任务,任务会按照提交顺序依次执行。
适用于需要保证任务按顺序执行的场景,或者不需要并发处理任务的情况。
4)ScheduledThreadPool:支持定时任务和周期性任务的线程池。
可以定时或以固定频率执行任务,线程池大小可以由用户指定。
适用于需要周期性任务执行的场景,如定时任务调度器。
5)WorkStealingPool:基于任务窃取算法的线程池。
线程池中的每个线程维护一个双端队列(deque),线程可以从自己的队列中取任务执行。如果线程的任务队列为空,它可以从其他线程的队列中“窃取”任务来执行,达到负载均衡的效果。
适合大量小任务并行执行,特别是递归算法或大任务分解成小任务的场景。
总结:
- FixedThreadPool 适合任务数量相对固定,且需要限制线程数的场景,避免线程过多占用系统资源。
- CachedThreadPool 更适合大量短期任务或任务数量不确定的场景,能够根据任务量动态调整线程数。
- SingleThreadExecutor 保证任务按顺序执行,适合要求严格顺序执行的场景。
- ScheduledThreadPool 是定时任务的最佳选择,能够轻松实现周期性任务调度。
- WorkStealingPool 适合处理大量的小任务,能更好地利用 CPU 资源。
如何动态调整线程池的参数
首先线程池中可以动态修改的参数本质上只有四种
- corePoolSize,可以通过setCorePoolSize调整
- maximumPoolSize,可以通过setMaximumPoolSize调整
- keepAliveTime,可以通过setKeepAliveTime调整
- workQueue,如果使用线程池组件提供的队列,由于是final修饰的,所以不允许扩容。但可以自己实现一个阻塞队列,不用final修饰
在设置corePoolSize和maximumPoolSize的时候,需要注意调用顺序
- 增大线程数,先修改maximumPoolSize再修改corePoolSize
- 减小线程数,先修改corePoolSize再修改maximumPoolSize
常见的做法是,将线程池的部分参数写入配置文件中,配合@ConfigurationProperties读取配置里的信息
然后配合如Apollo,Nacos等配置中心,动态修改配置,然后配置监听事件@EventListener(EnvironmentChangeEvent.class)
还有的办法就是使用现成的动态线程池的中间件来修改。
也可以使用 JMX(Java Management Extensions)来监控 ThreadPoolExecutor,结合指标来自动调整线程池大小以优化性能。
JMX 是 Java 提供的一套运行时管理和监控机制,核心就是:
- 管理 Bean(MBean):把应用内部的状态和操作暴露给外部管理工具
- 监控和操作应用:比如线程池、连接池、缓存、GC、应用自定义指标等
简单理解:它是 Java 内部“可被外部管理”的接口”
注册好后就可以使用比如Prometheus,Grafana等连接JVM,查看对应的参数数据