记录一次由 Java 线程池状态转换与拒绝策略引发的 JVM 频繁 Full GC 故障排查实录
记录一次由 Java 线程池状态转换与拒绝策略引发的 JVM 频繁 Full GC 故障排查实录前言兄弟们说实话搞技术这条路真是各种坑。咱们做开发的说白了就是要不断踩坑、不断成长这才是技术人的常态。那天凌晨三点手机闹钟一样响个不停。运维群里炸了锅生产环境 CPU 使用率居然只有 30%。但内存占用曲线像坐过山车Full GC 频率高得吓人。每次 GC 后内存回收效果极差堆内存很快就又爆满。这症状典型的“内存泄漏”既视感。可我们最近明明没上线新功能代码也没动过。我顶着两个黑眼圈登录服务器第一反应是查 GC 日志。日志里全是System.gc()的调用还有频繁的Allocation Failure。盯着日志看了半小时我发现了个不起眼的异常堆栈。java.util.concurrent.RejectedExecutionException。这玩意儿平时很少见怎么今天跟下雨一样顺着堆栈往上查源头竟是我们核心业务的一个异步线程池。问题找到了但逻辑有点反直觉。线程池拒绝任务为什么会引发 JVM 频繁 Full GC这背后其实藏着一个关于对象生命周期和状态转换的陷阱。一、 底层原理1.1 核心机制要搞懂这事儿得先明白线程池是怎么“变脸”的。线程池内部维护了一个ctl变量高 3 位存状态低 29 位存线程数。状态一共五种RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED。正常业务都在 RUNNING 状态下跑。一旦调用shutdown()状态转为 SHUTDOWN不再接受新任务但处理完队列里的。如果调用shutdownNow()直接变 STOP试图中断所有线程。拒绝策略就发生在这几种状态转换的缝隙里。当线程数满了队列也满了再来新任务就会触发拒绝策略。默认是AbortPolicy直接抛异常。这异常对象本身不大但架不住频率高啊。更致命的是CallerRunsPolicy。它会让提交任务的线程比如 Tomcat 线程亲自执行任务。这会导致提交线程阻塞任务对象在队列里停留时间变长。短命对象本该在 Young Gen 被清理。但因为被阻塞的线程持有引用或者任务对象本身被标记为长期存活。它们就被“误伤”晋升到了老年代。老年代空间有限满了就得 Full GC。Full GC 又慢导致系统响应更慢任务堆积更严重。这就形成了恶性循环。下面这张图把状态流转和拒绝策略的触发点画清楚了。stateDiagram-v2 [*] -- RUNNING RUNNING -- SHUTDOWN : 调用 shutdown() RUNNING -- STOP : 调用 shutdownNow() RUNNING -- RUNNING : 接受任务 SHUTDOWN -- SHUTDOWN : 处理队列任务 SHUTDOWN -- TIDYING : 队列和线程清空 STOP -- TIDYING : 线程中断完成 TIDYING -- TERMINATED : 执行 terminated() state 任务提交检查 { RUNNING -- 拒绝策略 : 线程满且队列满 SHUTDOWN -- 拒绝策略 : 不接受新任务 } 拒绝策略 -- AbortPolicy : 抛异常 拒绝策略 -- CallerRunsPolicy : 主线程执行 拒绝策略 -- DiscardPolicy : 直接丢弃 拒绝策略 -- DiscardOldestPolicy : 丢弃最老任务1.2 与同类方案的对比不同的拒绝策略对内存和系统的影响天差地别。策略名称行为表现内存影响适用场景AbortPolicy抛异常高频创建异常对象占用堆空间默认策略需严格监控CallerRunsPolicy提交线程执行任务对象滞留易晋升老年代需保证任务最终执行容忍延迟DiscardPolicy静默丢弃无额外对象创建最省内存允许数据丢失如日志上报DiscardOldestPolicy丢弃队列头可能触发队列调整开销中等需保证最新数据如实时行情从表里能看出来CallerRunsPolicy最危险。它虽然保证了任务不丢但把压力转嫁给了提交线程。提交线程一慢整个请求链路都慢。任务对象在队列里等久了GC 器就以为它是“长寿对象”。直接把它搬到老年代去“养老”。老年代一满Full GC 就来了。二、 快速上手光说不练假把式。我写了个最小复现 Demo三分钟让你看到 Full GC 是怎么来的。这段代码模拟了高并发下线程池满员后的拒绝场景。特意用了CallerRunsPolicy看看内存怎么爆炸。import java.util.concurrent.*; public class ThreadPoolGcDemo { public static void main(String[] args) throws InterruptedException { // 创建一个小巧的线程池核心线程 2 个最大 2 个队列容量 5 个 // 故意设小为了快速触发拒绝策略 ThreadPoolExecutor executor new ThreadPoolExecutor( 2, 2, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(5), // 关键使用 CallerRunsPolicy模拟生产环境配置 new ThreadPoolExecutor.CallerRunsPolicy() ); System.out.println(开始模拟高并发任务提交...); // 启动 50 个线程同时提交任务 // 每个任务休眠 1 秒模拟业务处理耗时 for (int i 0; i 50; i) { int taskId i; executor.submit(() - { try { Thread.sleep(1000); System.out.println(任务 taskId 执行完毕); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } // 等待任务提交完成 Thread.sleep(5000); // 强制触发 GC观察内存变化 System.out.println(提交完成等待 GC 回收...); System.gc(); Thread.sleep(2000); executor.shutdown(); } }运行这段代码配合 VisualVM 观察堆内存。你会发现随着任务提交Eden 区迅速填满。因为CallerRunsPolicy导致主线程阻塞任务对象无法及时释放。它们被扫描到直接晋升到老年代。老年代空间一旦不够JVM 就会发起 Full GC。三、 核心 API / 深水区3.1 核心方法速查排查这类问题这几个方法你得烂熟于心。方法名作用生产级建议getPoolSize()获取当前线程数监控是否达到最大值getQueue().size()获取队列积压量超过阈值立即报警getLargestPoolSize()历史最大线程数评估峰值负载能力getTaskCount()已完成任务总数计算吞吐量3.2 生产级配置生产环境配线程池千万别用默认构造函数。一定要显式指定拒绝策略和队列类型。队列推荐用LinkedBlockingQueue但一定要指定容量。不指定容量默认是Integer.MAX_VALUE那等于无界队列。内存直接爆掉JVM 都救不回来。拒绝策略建议自定义一个不要直接抛异常。记录日志或者存入本地磁盘备用。// 自定义拒绝策略示例 public class CustomRejectHandler implements RejectedExecutionHandler { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 记录拒绝日志带上时间戳和任务信息 System.err.println(任务被拒绝当前线程数 executor.getPoolSize()); // 这里可以存入数据库或文件防止数据丢失 } }3.3 高级定制有些场景我们需要动态调整线程池参数。比如大促期间临时增加线程数。可以用setCorePoolSize和setMaximumPoolSize。但要注意减少核心线程数不会立即生效。得等空闲线程超时回收。// 动态调整示例 executor.setCorePoolSize(10); executor.setMaximumPoolSize(20); // 注意调整完要观察监控防止线程数波动过大四、 实战演练回到我们那天的故障现场。业务是订单系统异步处理订单状态变更。当时大促流量突增线程池瞬间打满。我们当时配的正是CallerRunsPolicy。主线程被阻塞HTTP 请求响应时间从 200ms 飙升到 5s。Tomcat 线程池也满了新请求进不来。这时候拒绝策略触发主线程继续执行任务。任务对象在内存里堆积老年代迅速膨胀。Full GC 频繁触发STWStop-The-World时间长达 2 秒。用户页面直接转圈圈投诉电话被打爆。我们紧急回滚了配置把拒绝策略改成了DiscardPolicy。虽然丢了一些非核心日志但系统活过来了。内存曲线平稳GC 频率恢复正常。这就是典型的“保命优先”。五、 避坑指南与最佳实践踩了这么多坑总结几条血泪经验。技巧监控线程池队列大小比监控线程数更敏感。队列一满马上报警别等线程池爆。⚠️警告千万别用Executors.newFixedThreadPool()。它底层队列是无界的内存泄漏的温床。✅推荐拒绝策略要分级处理。核心业务用AbortPolicy快速失败非核心业务用DiscardPolicy。技巧定期打印线程池状态日志。pool-1-thread-1这种名字太抽象给线程池起个业务名。// 给线程池命名方便排查 ThreadFactory factory new ThreadFactoryBuilder().setNameFormat(order-process-%d).build(); ThreadPoolExecutor executor new ThreadPoolExecutor(..., factory, ...);六、 综合实战演示最后给出一套生产级线程池配置模板。这套配置兼顾了性能和安全性直接拿去用。import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class ProductionThreadPool { // 定义线程池参数根据 CPU 核心数调整 private static final int CORE_POOL_SIZE Runtime.getRuntime().availableProcessors(); private static final int MAX_POOL_SIZE CORE_POOL_SIZE * 2; private static final int QUEUE_CAPACITY 1000; public static ThreadPoolExecutor createExecutor(String poolName) { // 自定义线程工厂设置线程名 ThreadFactory factory new ThreadFactory() { private final AtomicInteger counter new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(poolName -thread- counter.getAndIncrement()); t.setDaemon(true); // 设置为守护线程防止阻止 JVM 退出 return t; } }; // 构建线程池 return new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(QUEUE_CAPACITY), factory, // 使用自定义拒绝策略记录日志但不抛异常阻塞 new RejectedExecutionHandler() { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { System.err.println([ poolName ] 任务拒绝执行队列大小 executor.getQueue().size()); // 可选将任务存入持久化队列稍后重试 } } ); } public static void main(String[] args) { ThreadPoolExecutor executor createExecutor(OrderAsync); // 模拟提交任务 for (int i 0; i 100; i) { executor.submit(() - { System.out.println(Thread.currentThread().getName() 处理任务); }); } executor.shutdown(); } }总结线程池不是配完就完事的它得盯着。状态转换和拒绝策略是内存泄漏的隐形杀手。尤其是CallerRunsPolicy慎用。监控队列积压比监控 CPU 更重要。把复杂的问题简单化才是架构师的修养。