背景简述:某一天 产品经理反馈某个接口功能执行数据缺失,起初以为只是该接口的bug; 接着很快又反馈有另外的接口出现问题 另外这个接口是博主写的… 通过日志分析后,发现线程池里面的异步任务没有执行,两个接口都用到了异步线程 所以初步推测是线程池任务丢失。
项目定义的线程池:
executor = ThreadPoolProxyFactory.createThreadPoolExecutor(8, 32, 20L, TimeUnit.SECONDS, new LinkedBlockingQueue(2048), (new ThreadFactoryBuilder()).setNamePrefix("common-pool-").build(), new CallerRunsPolicy());
其中拒绝策略用的是jdk自带的 (各个拒绝策略记不住具体是干嘛的不用慌,看一眼源码就知道了)
public static class CallerRunsPolicy implements RejectedExecutionHandler {
/**
* Creates a {@code CallerRunsPolicy}.
*/
public CallerRunsPolicy() { }
/**
* Executes task r in the caller's thread, unless the executor
* has been shut down, in which case the task is discarded.
*
* @param r the runnable task requested to be executed
* @param e the executor attempting to execute this task
*/
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
r.run();
}
}
}
显然这个拒绝策略的作用是:当线程池满了时,用外层发起的线程去执行(比如主线程),所以任务不应该会丢失,但是大半个小时后也没见任务继续执行,我们项目数据量不太可能达到这个级别;
陷入了一个僵局:队列满了不应该会丢失任务,但任务确实没执行;如果队列没满 那么队列的任务在排队 核心线程会从队列取出来执行; 出现了这种看似悖论的问题,很大概率就是有哪个点被我们忽略了。
此外,线程池没有执行任务,说明线程出了问题没有释放,比如数据库死锁了 事务一直没提交,比如连接池耗尽;
- 查询数据库状态 发现最后一次死锁状态在很久之前,数据库死锁问题排除
- 连接池如果耗尽 那基本会有大面积报错 日志并没有看到 且数据量不太可能导致mysql连接池耗尽 也排除
现在思路继续聚焦回到线程池,原业务代码较为复杂 究极简化后发现有嵌套
tips : 线程池嵌套:
executor.execute(()->{executor.execute(()-> {
// do something
}) } )
我们知道事务嵌套会有隐患,需要熟练事务隔离问题,那线程池嵌套是不是也会有隐患呢?
我们来复习一下线程池的知识:
- 核心线程没满时(没达到最大核心线程),且提交线程(请求数) > 当前核心线程 , 会优先创建核心线程 直到达到最大核心线程数
- 提交线程大于最大核心线程数时,会往队列里面存放任务,直到队列满为止
- 队列满了之后,会开始创建最大线程(即非核心线程、maximumPool)
- 当最大线程都执行不过来时(请求数很大,核心线程、非核心线程执行不过来 队列也满了) 开始进入拒绝策略。
在我们的项目中,任务量不太可能将队列都占满,且拒绝策略为CallerRunsPolicy 主线程也没有执行,所以问题聚焦回核心线程, 说明核心线程一直没被释放。
接着就是简化所有业务代码,回归到线程池问题
tips: 生产事故难排查的原因 主要原因就是代码链路长,我们很多时候无法第一时间知道具体点在哪 每行代码都可能产生bug 需要有精准的排除不相关问题, 比如我们最开始怀疑就是事务问题,因为事务造成死锁 尤其在多线程环境中 是非常常见的,但是查询数据库状态并没有发现死锁,且mysql死锁一般日志也会有deadlock提示, 所以排除了是数据库的问题,才将思路转回线程池本身。
在将业务代码剥离后,N个方法调用链路平铺开来, 得到以下代码:
(这里解释一下,博主是已经先有了思路 带着答案写简化demo来验证的)
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(8, 32, 20L, TimeUnit.SECONDS, new LinkedBlockingQueue(2048));
// 模拟接收到一堆通知事件
for (int i = 0; i < 15; i++) {
AtomicInteger atomicInteger = new AtomicInteger(i);
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
System.out.println("进入外层:" + atomicInteger.get());
// 模拟上传三方接口
List<CompletableFuture<Pair>> uploadContractFeatures = new ArrayList<>(3);
for (int j = 0; j < 3; j++) {
int i1 = atomicInteger.get();
CompletableFuture<Pair> suppliedAsync = CompletableFuture.supplyAsync(
() -> {
System.out.println("进入内层:" + i1);
return new Pair((long) i1, Boolean.TRUE);
},
threadPoolExecutor
)
.exceptionally(ex -> {
System.out.println("======异常");
new Pair<Long, Boolean>(Long.valueOf(i1), Boolean.FALSE);
return new Pair(Long.valueOf(i1), Boolean.FALSE);
});
uploadContractFeatures.add(suppliedAsync);
}
// 等待上传任务完成
CompletableFuture.allOf(uploadContractFeatures.toArray(new CompletableFuture[0])).join();
System.out.println("---------------------执行完毕");
}
});
}
可以得出结论:
- 外层会先占用核心线程,此时内层已经拿不到核心线程了
- 任务数远没达到最大队列,所以非核心线程也没有创建,仍依赖核心线程执行
- 内层拿不到核心线程 且CompletableFuture.allOf 在阻塞线程(等待内层线程执行完毕)
- 结果就是 外层在等待内层完成,内层拿不到线程 一直在阻塞,内层没完成,外层也无法释放,导致了整个线程池不可用
题外话:因为核心线程阻塞 任务会一直往队列中添加,如果用的是无界队列 在时间过久后 将发生OOM (虽然在还没到这个时间点 也早就应该发现线程池出问题了 但确实是一个OOM可能产生的点)
我们也可以通过jconsole来辅助分析:
针对项目更合理的解决方案:
内层任务更换其它线程池,例如:
ThreadPoolExecutor executorService = new ThreadPoolExecutor(
4,
32,
10L,
TimeUnit.SECONDS,
new SynchronousQueue<>(),
new ThreadFactoryBuilder().setNamePrefix("inner-pool-").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
核心线程不会过大 避免资源浪费,最大线程32 (项目够用),
队列使用SynchronousQueue 表示零容量队列强制线程池优先创建新线程(而非缓冲任务)
CallerRunsPolicy 拒绝策略表示线程满了后 用当前线程执行 保证任务不丢失
写在最后:适合自己业务的技术才是好技术,不浪费资源,且资源够用才是恰到好处;例如数据量更大的情况下,就不能使用SynchronousQueue了