ThreadPoolExecutor未终止但shutdownNow返回空列表是否为正常现象?
场景说明
在JUnit测试中通过自定义ThreadPoolExecutor验证并发逻辑,测试结束时的线程池回收校验逻辑如下:
// wait on a conditional that indicates that results are available executor.shutdown(); executor.awaitTermination(100l); // do other result verifications here if ( ! executor.isTerminated()) { final var stuckTasks = executor.shutdownNow(); for (var stuckTask: stuckTasks) log.severe("stuck " + stuckTask); fail("executor not terminated, " + stuckTasks.size() + " tasks remaining"); }
长时间循环测试时会偶发报错:executor not terminated, 0 tasks remaining,且从未出现剩余任务数非0的情况,前置的业务结果校验全部通过。
原因分析
该现象属于JDK线程池实现的固有特性,不代表业务代码存在并发问题,可以安全忽略。触发该问题的常见原因如下:
shutdownNow()的返回值仅包含阻塞队列中尚未被工作线程拉取的待执行任务,已经被线程调度、正在执行中的任务不会被计入该返回值。- 即使所有任务已经执行完成,线程池的
isTerminated()状态更新也存在极短的竞态窗口:最后一个工作线程完成任务后,需要先完成Worker资源释放、线程退出等操作,才会将线程池的全局状态更新为TERMINATED。如果这个过程中出现CPU调度切换、系统负载波动,就可能出现「所有任务已执行完毕、队列已空,但isTerminated()仍返回false」的情况,设置的100ms等待时间刚好卡到这个窗口时就会触发报错。 - OpenJDK 11在Ubuntu环境下,若存在Cgroup资源限制、测试进程CPU被抢占的情况,会进一步放大这种毫秒级延迟的出现概率,长时间循环测试时必然会偶发触发。
- 仅userExecutor出现该问题、grpcExecutor不触发的差异,大概率和两个线程池的配置有关:比如userExecutor配置了允许核心线程超时、核心线程数为0,核心线程销毁的流程会进一步拉长
TERMINATED状态的更新窗口。
优化建议
- 已调整的「仅当
shutdownNow()返回的剩余任务数非0时才判定测试失败」逻辑是安全的,不会漏过业务并发问题:如果真的存在任务卡住的情况,要么会在队列中被统计到,要么正在运行的任务会导致前置的业务结果校验失败,和实际观察到的现象一致。 - 如果想要完全消除偶发报错,可以优化校验逻辑:首次
awaitTermination未通过时,额外判断getActiveCount() == 0 && getQueue().isEmpty(),满足该条件时也视为正常,不需要判定测试失败;也可以追加一轮短时间的二次等待,覆盖线程池状态更新的窗口。 - 后续开发的TaskTrackingExecutor装饰器是更可靠的解决方案,直接追踪所有提交任务的完成状态,不依赖线程池本身的状态判定,可以彻底避免底层实现的竞态对测试结果的干扰。
内容的提问来源于stack exchange,提问作者morgwai
相关产品推荐
相关产品推荐

