1. 线程饥饿的定义与本质
定义:
线程饥饿指的是在多线程并发执行时,由于某些线程长时间无法获得执行所需的 CPU 资源或系统资源,从而导致这些线程的任务一直处于等待状态,严重时可能使这些线程永远得不到执行。
本质:
- 资源竞争:多个线程争夺有限的 CPU 时间片、锁或 I/O 资源,部分线程因为优先级低或调度策略原因,无法获得执行机会。
- 任务提交过快:在异步任务提交中,如果使用了单一线程池且任务提交速度远大于线程执行速度,可能会迅速将线程池队列填满,进而触发线程池的拒绝策略。
- 拒绝策略问题:例如,当采用
CallerRunsPolicy
时,被拒绝的任务会由调用线程执行,从而形成单线程串行执行,导致整个系统吞吐量大幅下降。
二、案例分析
//定义一个线程池
@Bean(name = "taskWorkExecutor")
public Executor taskWorkExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setKeepAliveSeconds(300);
executor.initialize();
return executor;
}
public void doSomeThing(){
CompletableFuture.runAsync(() -> {
//查询需要处理的任务
ArrayList<Object> taskList= Lists.newArrayList();
taskList.stream().map(item -> CompletableFuture.runAsync(() -> {
//业务流程
}, taskWorkExecutor));
}, taskWorkExecutor);
}
存在的问题
-
任务提交数量膨胀
在doSomeThing()
方法中,主线程提交的任务直接由同一个线程池来处理。如果taskList
中的元素非常多,会迅速占满线程池的队列。当队列满时,CallerRunsPolicy
将使拒绝的任务由提交任务的线程执行,这将导致原本的并发任务被串行化执行,严重降低吞吐量。 -
线程池资源竞争
如果所有任务都使用同一个线程池,不同类型的任务(例如 CPU 密集型和 I/O 密集型任务)会互相竞争资源,可能导致部分高优先级任务无法及时执行,从而出现线程饥饿问题
三、解决思路
3.1 优化线程池管理
方案一:拆分线程池
- 高 CPU 占用任务池:将 CPU 密集型任务单独分配到一个线程池中,设置较低的核心线程数和较小的队列容量,以防止大量任务同时占用 CPU 资源。
- 低 CPU 占用任务池:将 I/O 密集型或短任务统一交由另一个线程池处理,该线程池可以配置较高的核心线程数和较大队列,保证响应速度。
- 任务分发池:建立一个中间层线程池用于任务的初步调度,将接收到的大量任务按类别或优先级分发到不同的线程池,避免单一线程池被任务淹没。
方案二:使用 CompletableFuture 任务编排
使用 CompletableFuture
可以将各个任务的执行链解耦,根据不同的任务类型,选择对应的线程池执行,达到线程池分层设计,并以流式方式组织任务顺序。例如:
private void taskProcess(){
// 定义IO密集型线程池和计算密集型线程池
ExecutorService ioExecutor = Executors.newCachedThreadPool();
ExecutorService computeExecutor = Executors.newFixedThreadPool(4);
CompletableFuture.supplyAsync(() -> fetchFromDatabase(), ioExecutor) // 使用IO线程池
.thenApplyAsync(data -> processData(data), computeExecutor) // 切换至计算线程池
.thenAccept(result -> saveResult(result)); // 使用默认线程池(可选)
}
方案三:任务超时控制
为了防止线程长时间等待而导致资源无法释放,可以在提交任务时设置超时时间:
public vodi taskProcess(){
//指定异步线程的超时时间,防止线程假死
CompletableFuture.runAsync(()->{}).orTimeout(5, TimeUnit.MINUTES);
}
4. 提交任务的注意事项
-
拒绝策略问题:
在使用CallerRunsPolicy
时,主线程可能会被迫执行任务,导致任务串行化。为此,可以考虑:- 采用更合理的线程池配置,调整核心线程数和队列容量;
- 对不同任务设置不同的线程池,避免混用同一线程池导致的互相干扰。
-
监控和日志:
建议在提交任务和执行任务时增加详细日志记录,监控线程池的运行状态和队列长度,及时发现并调整性能瓶颈。 -
任务拆分和批处理:
如果任务数量非常大,可以考虑对任务进行分批提交,或采用分页加载的方式,避免一次性提交过多任务导致线程池队列快速饱和。
5. 总结
问题归纳:
- 线程饥饿:当任务提交数量过多,单一线程池容易被快速填满队列,导致部分任务得不到及时执行。
- 拒绝策略导致串行化:使用
CallerRunsPolicy
时,被拒绝的任务在主线程中执行,可能破坏原本的并发设计,降低吞吐量。 - 线程池资源竞争:不同类型的任务竞争同一个线程池资源,会导致部分高优先级任务无法及时获得 CPU 时间片。
解决方案:
- 拆分线程池:根据任务类型分别建立不同的线程池,避免资源互相争用。
- 利用 CompletableFuture 解耦任务链:使任务编排更优雅,明确任务先后顺序。
- 设置任务超时:避免任务长时间阻塞,保证系统响应及时。
- 优化拒绝策略与任务提交策略:根据业务场景调整线程池参数,并通过分批提交任务减少瞬时压力。
- 增加监控和日志:在关键位置添加监控代码,及时捕获并处理线程饥饿问题,通过配置文件的方式配置线程池核心参数,对于线程池核心参数扩容的情况,可以进行动态增加(动态减少的话支持不太够,不符合预期)。