拒绝策略里的对象晋升探秘 Java 线程池不当配置引发的 Full GC 根源前言兄弟们说实话搞技术这条路真是各种坑。咱们做开发的说白了就是要不断踩坑、不断成长这才是技术人的常态。在 Java 高并发编程中线程池的参数配置直接影响到系统的稳定性和性能。当任务队列满且线程数达到 Limits 时不当的拒绝策略如 CallerRunsPolicy可能导致任务执行线程被阻塞进而引发老年代对象晋升甚至频繁 Full GC。本文将深度剖析拒绝策略导致对象异常晋升的底层机理并提供最佳实践与规避策略。一、底层原理1.1 核心机制Java 线程池的状态流转其实像个严格的“国企晋升体系”。它一共有 5 种状态运行、关闭、停止、整理、终止。正常业务都在“运行”状态里打转。一旦线程池被 shutdown它就开始拒绝新任务。这时候拒绝策略就登场了。常见的拒绝策略有 4 种抛异常、调用者运行、丢弃、丢弃最老。问题往往出在“自定义拒绝策略”上。很多兄弟为了记录日志或者为了重试在拒绝策略里写了大量逻辑。这些逻辑会创建新的对象比如日志记录器、重试任务对象。如果这些对象存活时间稍长就会从 Eden 区晋升到 Survivor 区。如果 Survivor 区也塞满了它们就会直接“移民”老年代。老年代空间有限一旦填满JVM 只能启动 Full GC 进行大扫除。这个过程就像餐厅后厨忙不过来服务员还在旁边搞装修。不仅没帮上忙还占用了过道导致传菜员GC 线程寸步难行。graph TD A[线程池状态(运行中)] --|任务队列满 | B[触发拒绝策略] B --|执行拒绝逻辑 | C[创建临时对象] C --|对象存活 | D[Eden 区] D --|Minor GC | E[Survivor 区] E --|长期存活 | F[老年代] F --|空间不足 | G[Full GC 触发] G --|内存回收失败 | H[系统卡顿/崩溃] style A fill:#f9f,stroke:#333,stroke-width:2px style H fill:#ff6b6b,stroke:#333,stroke-width:2px1.2 与同类方案的对比很多同事觉得线程池满了直接抛异常最省事。其实不同拒绝策略对内存的影响天差地别。我们拿生产环境常见的几种方案做个对比。拒绝策略类型内存消耗系统影响适用场景AbortPolicy低抛出异常中断流程必须保证任务执行不允许丢失CallerRunsPolicy中调用者线程执行降低提交速度需要削峰填谷允许延迟自定义日志策略高创建日志对象加剧 GC不推荐除非日志极轻量DiscardPolicy低静默丢弃无反馈允许数据丢失如实时视频流你看自定义策略如果不小心就是内存杀手。二、快速上手为了复现这个问题我写了一个最小可运行的 Demo。这个 Demo 模拟了线程池满后拒绝策略疯狂创建大对象的情况。你只需要 3 分钟就能亲眼看到内存是怎么被吃掉的。import java.util.concurrent.*; import java.util.ArrayList; import java.util.List; public class ThreadPoolGCdemo { public static void main(String[] args) throws InterruptedException { // 创建一个极小的线程池核心线程 2 个最大 2 个队列容量 5 // 这样很容易触发拒绝策略 ThreadPoolExecutor executor new ThreadPoolExecutor( 2, 2, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable(5) ); // 设置自定义拒绝策略这里模拟创建大对象 executor.setRejectedExecutionHandler(new RejectedExecutionHandler() { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // ⚠️ 警告这里创建大对象是 Full GC 的元凶 // 实际业务中可能是打印日志、记录数据库、发起重试 byte[] largeData new byte[1024 * 1024]; // 1MB 数组 // 模拟处理逻辑让对象存活时间变长 System.gc(); System.out.println(任务被拒绝已创建 1MB 临时对象); } }); // 疯狂提交任务填满线程池 for (int i 0; i 100; i) { final int taskId i; executor.submit(() - { try { Thread.sleep(500); // 模拟任务耗时 System.out.println(任务 taskId 执行中); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } // 等待任务执行完毕 executor.shutdown(); executor.awaitTermination(1, TimeUnit.MINUTES); System.out.println(所有任务处理完成请观察 GC 日志); } }运行这段代码配合-XX:PrintGCDetails参数。你会看到老年代的使用率像坐火箭一样往上窜。三、核心 API / 深水区3.1 核心方法速查排查线程池问题这几个 API 是你必须熟记的。API 方法作用生产建议getPoolSize()当前线程数监控是否达到最大值getQueue().size()队列当前大小预警队列积压getCompletedTaskCount()已完成任务数计算吞吐量allowCoreThreadTimeOut()核心线程超时回收节省空闲资源3.2 生产级配置在生产环境线程池配置绝对不能“拍脑袋”。拒绝策略必须做到“轻量级”。如果一定要记录日志请用异步日志框架别在拒绝策略里同步写文件。超时控制也要做好防止任务无限期阻塞线程。// 生产级线程池配置示例 ThreadPoolExecutor safeExecutor new ThreadPoolExecutor( 10, // 核心线程数 20, // 最大线程数 60L, // 空闲线程存活时间 TimeUnit.SECONDS, new LinkedBlockingQueueRunnable(1000), // 有界队列防止 OOM new ThreadFactory() { // 自定义线程工厂方便排查 private int count 0; public Thread newThread(Runnable r) { return new Thread(r, 业务线程- (count)); } }, new ThreadPoolExecutor.CallerRunsPolicy() // 使用 CallerRuns 削峰 ); // 拒绝策略里千万别写耗时操作 // 如果必须记录请丢给另一个专用日志线程池3.3 高级定制有些场景下默认的拒绝策略不够用。你可以实现RejectedExecutionHandler接口做更复杂的逻辑。比如把被拒绝的任务存入 Redis等系统空闲了再取回来处理。但记住存入 Redis 这个动作本身也要控制时间。别为了救火把消防栓也给堵了。四、实战演练这次故障的真实场景是“订单超时取消”。我们有一个定时任务线程池负责扫描 30 分钟未支付的订单。随着流量激增线程池队列满了。当时的拒绝策略是记录一条数据库日志标记任务失败。问题在于每条日志都关联了一个巨大的“订单快照对象”。这个快照对象有几百个字段序列化后好几 KB。高并发下每秒被拒绝的任务成千上万。这些快照对象瞬间塞满了老年代。Full GC 频繁触发导致数据库连接池也被占满。整个系统形成了“内存满 - GC - 卡死 - 任务堆积 - 内存更满”的恶性循环。我们当时的修复方案是拒绝策略只记录订单 ID不记录完整对象。将重试逻辑改为消息队列异步处理。增加线程池监控报警队列超过 80% 就通知。修复后Full GC 频率从每分钟 1 次降到了每天 1 次。五、避坑指南与最佳实践5.1 技巧监控先行不要等报警了才去看线程池。把getQueue().size()和getPoolSize()接入 Prometheus。设置阈值比如队列使用率超过 70% 就发警告。5.2 ⚠️ 警告拒绝策略别干重活拒绝策略里只能做“记录”或“丢弃”。严禁在拒绝策略里发起 RPC 调用、查数据库、写大文件。这就像火灾发生时你不仅不灭火还在现场搞装修。5.3 ✅ 推荐有界队列永远不要用LinkedBlockingQueue的无界构造器。一定要指定容量防止内存溢出。如果队列满了宁可拒绝也不要让内存爆炸。5.4 技巧优雅停机应用关闭时调用shutdown()后给线程池一点时间。使用awaitTermination等待任务执行完。避免强制shutdownNow()导致任务丢失或数据不一致。六、综合实战演示下面是一套经过生产验证的线程池封装代码。它包含了异常处理、超时控制、优雅停机以及安全的拒绝策略。你可以直接拿去复用但要根据业务调整参数。import java.util.concurrent.*; import java.util.logging.Logger; public class SafeThreadPoolManager { private static final Logger logger Logger.getLogger(SafeThreadPoolManager.class.getName()); private final ThreadPoolExecutor executor; public SafeThreadPoolManager() { // 初始化线程池 this.executor new ThreadPoolExecutor( 5, // 核心线程 10, // 最大线程 60L, // 超时时间 TimeUnit.SECONDS, new LinkedBlockingQueueRunnable(500), // 有界队列 new ThreadFactory() { private final AtomicLong threadCount new AtomicLong(0); Override public Thread newThread(Runnable r) { return new Thread(r, SafeBiz-Thread- threadCount.incrementAndGet()); } }, // 自定义拒绝策略记录轻量日志不创建大对象 new RejectedExecutionHandler() { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // ✅ 推荐只记录任务特征不持有任务引用 logger.warning(任务被拒绝线程池已满任务哈希: r.hashCode()); // 这里可以投递到死信队列方便后续补偿 } } ); // 允许核心线程超时节省资源 executor.allowCoreThreadTimeOut(true); } public void submitTask(Runnable task) { try { executor.submit(task); } catch (Exception e) { // 捕获提交异常防止主线程崩溃 logger.severe(任务提交失败: e.getMessage()); } } public void shutdown() { // 优雅停机 executor.shutdown(); try { // 等待 30 秒看任务能不能跑完 if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { // 如果没跑完强制关闭 executor.shutdownNow(); // 再等 10 秒 if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { logger.severe(线程池未能正常关闭); } } } catch (InterruptedException e) { // 恢复中断状态 executor.shutdownNow(); Thread.currentThread().interrupt(); } } }这段代码的关键在于拒绝策略的克制。它只打印了任务的哈希码没有持有任务对象的引用。这样垃圾回收器就能立刻回收被拒绝的任务不会造成内存泄漏。七、总结线程池是 Java 并发的基石也是内存泄漏的重灾区。这次故障告诉我们拒绝策略不是“垃圾桶”不能往里扔重东西。监控要到位队列要有界拒绝要轻量。把复杂的问题想简单把简单的细节做到极致。这才是我们作为架构师该干的事。下次再看到 Full GC 频繁先查查线程池的拒绝策略吧。
拒绝策略里的对象晋升:探秘 Java 线程池不当配置引发的 Full GC 根源
拒绝策略里的对象晋升探秘 Java 线程池不当配置引发的 Full GC 根源前言兄弟们说实话搞技术这条路真是各种坑。咱们做开发的说白了就是要不断踩坑、不断成长这才是技术人的常态。在 Java 高并发编程中线程池的参数配置直接影响到系统的稳定性和性能。当任务队列满且线程数达到 Limits 时不当的拒绝策略如 CallerRunsPolicy可能导致任务执行线程被阻塞进而引发老年代对象晋升甚至频繁 Full GC。本文将深度剖析拒绝策略导致对象异常晋升的底层机理并提供最佳实践与规避策略。一、底层原理1.1 核心机制Java 线程池的状态流转其实像个严格的“国企晋升体系”。它一共有 5 种状态运行、关闭、停止、整理、终止。正常业务都在“运行”状态里打转。一旦线程池被 shutdown它就开始拒绝新任务。这时候拒绝策略就登场了。常见的拒绝策略有 4 种抛异常、调用者运行、丢弃、丢弃最老。问题往往出在“自定义拒绝策略”上。很多兄弟为了记录日志或者为了重试在拒绝策略里写了大量逻辑。这些逻辑会创建新的对象比如日志记录器、重试任务对象。如果这些对象存活时间稍长就会从 Eden 区晋升到 Survivor 区。如果 Survivor 区也塞满了它们就会直接“移民”老年代。老年代空间有限一旦填满JVM 只能启动 Full GC 进行大扫除。这个过程就像餐厅后厨忙不过来服务员还在旁边搞装修。不仅没帮上忙还占用了过道导致传菜员GC 线程寸步难行。graph TD A[线程池状态(运行中)] --|任务队列满 | B[触发拒绝策略] B --|执行拒绝逻辑 | C[创建临时对象] C --|对象存活 | D[Eden 区] D --|Minor GC | E[Survivor 区] E --|长期存活 | F[老年代] F --|空间不足 | G[Full GC 触发] G --|内存回收失败 | H[系统卡顿/崩溃] style A fill:#f9f,stroke:#333,stroke-width:2px style H fill:#ff6b6b,stroke:#333,stroke-width:2px1.2 与同类方案的对比很多同事觉得线程池满了直接抛异常最省事。其实不同拒绝策略对内存的影响天差地别。我们拿生产环境常见的几种方案做个对比。拒绝策略类型内存消耗系统影响适用场景AbortPolicy低抛出异常中断流程必须保证任务执行不允许丢失CallerRunsPolicy中调用者线程执行降低提交速度需要削峰填谷允许延迟自定义日志策略高创建日志对象加剧 GC不推荐除非日志极轻量DiscardPolicy低静默丢弃无反馈允许数据丢失如实时视频流你看自定义策略如果不小心就是内存杀手。二、快速上手为了复现这个问题我写了一个最小可运行的 Demo。这个 Demo 模拟了线程池满后拒绝策略疯狂创建大对象的情况。你只需要 3 分钟就能亲眼看到内存是怎么被吃掉的。import java.util.concurrent.*; import java.util.ArrayList; import java.util.List; public class ThreadPoolGCdemo { public static void main(String[] args) throws InterruptedException { // 创建一个极小的线程池核心线程 2 个最大 2 个队列容量 5 // 这样很容易触发拒绝策略 ThreadPoolExecutor executor new ThreadPoolExecutor( 2, 2, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable(5) ); // 设置自定义拒绝策略这里模拟创建大对象 executor.setRejectedExecutionHandler(new RejectedExecutionHandler() { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // ⚠️ 警告这里创建大对象是 Full GC 的元凶 // 实际业务中可能是打印日志、记录数据库、发起重试 byte[] largeData new byte[1024 * 1024]; // 1MB 数组 // 模拟处理逻辑让对象存活时间变长 System.gc(); System.out.println(任务被拒绝已创建 1MB 临时对象); } }); // 疯狂提交任务填满线程池 for (int i 0; i 100; i) { final int taskId i; executor.submit(() - { try { Thread.sleep(500); // 模拟任务耗时 System.out.println(任务 taskId 执行中); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } // 等待任务执行完毕 executor.shutdown(); executor.awaitTermination(1, TimeUnit.MINUTES); System.out.println(所有任务处理完成请观察 GC 日志); } }运行这段代码配合-XX:PrintGCDetails参数。你会看到老年代的使用率像坐火箭一样往上窜。三、核心 API / 深水区3.1 核心方法速查排查线程池问题这几个 API 是你必须熟记的。API 方法作用生产建议getPoolSize()当前线程数监控是否达到最大值getQueue().size()队列当前大小预警队列积压getCompletedTaskCount()已完成任务数计算吞吐量allowCoreThreadTimeOut()核心线程超时回收节省空闲资源3.2 生产级配置在生产环境线程池配置绝对不能“拍脑袋”。拒绝策略必须做到“轻量级”。如果一定要记录日志请用异步日志框架别在拒绝策略里同步写文件。超时控制也要做好防止任务无限期阻塞线程。// 生产级线程池配置示例 ThreadPoolExecutor safeExecutor new ThreadPoolExecutor( 10, // 核心线程数 20, // 最大线程数 60L, // 空闲线程存活时间 TimeUnit.SECONDS, new LinkedBlockingQueueRunnable(1000), // 有界队列防止 OOM new ThreadFactory() { // 自定义线程工厂方便排查 private int count 0; public Thread newThread(Runnable r) { return new Thread(r, 业务线程- (count)); } }, new ThreadPoolExecutor.CallerRunsPolicy() // 使用 CallerRuns 削峰 ); // 拒绝策略里千万别写耗时操作 // 如果必须记录请丢给另一个专用日志线程池3.3 高级定制有些场景下默认的拒绝策略不够用。你可以实现RejectedExecutionHandler接口做更复杂的逻辑。比如把被拒绝的任务存入 Redis等系统空闲了再取回来处理。但记住存入 Redis 这个动作本身也要控制时间。别为了救火把消防栓也给堵了。四、实战演练这次故障的真实场景是“订单超时取消”。我们有一个定时任务线程池负责扫描 30 分钟未支付的订单。随着流量激增线程池队列满了。当时的拒绝策略是记录一条数据库日志标记任务失败。问题在于每条日志都关联了一个巨大的“订单快照对象”。这个快照对象有几百个字段序列化后好几 KB。高并发下每秒被拒绝的任务成千上万。这些快照对象瞬间塞满了老年代。Full GC 频繁触发导致数据库连接池也被占满。整个系统形成了“内存满 - GC - 卡死 - 任务堆积 - 内存更满”的恶性循环。我们当时的修复方案是拒绝策略只记录订单 ID不记录完整对象。将重试逻辑改为消息队列异步处理。增加线程池监控报警队列超过 80% 就通知。修复后Full GC 频率从每分钟 1 次降到了每天 1 次。五、避坑指南与最佳实践5.1 技巧监控先行不要等报警了才去看线程池。把getQueue().size()和getPoolSize()接入 Prometheus。设置阈值比如队列使用率超过 70% 就发警告。5.2 ⚠️ 警告拒绝策略别干重活拒绝策略里只能做“记录”或“丢弃”。严禁在拒绝策略里发起 RPC 调用、查数据库、写大文件。这就像火灾发生时你不仅不灭火还在现场搞装修。5.3 ✅ 推荐有界队列永远不要用LinkedBlockingQueue的无界构造器。一定要指定容量防止内存溢出。如果队列满了宁可拒绝也不要让内存爆炸。5.4 技巧优雅停机应用关闭时调用shutdown()后给线程池一点时间。使用awaitTermination等待任务执行完。避免强制shutdownNow()导致任务丢失或数据不一致。六、综合实战演示下面是一套经过生产验证的线程池封装代码。它包含了异常处理、超时控制、优雅停机以及安全的拒绝策略。你可以直接拿去复用但要根据业务调整参数。import java.util.concurrent.*; import java.util.logging.Logger; public class SafeThreadPoolManager { private static final Logger logger Logger.getLogger(SafeThreadPoolManager.class.getName()); private final ThreadPoolExecutor executor; public SafeThreadPoolManager() { // 初始化线程池 this.executor new ThreadPoolExecutor( 5, // 核心线程 10, // 最大线程 60L, // 超时时间 TimeUnit.SECONDS, new LinkedBlockingQueueRunnable(500), // 有界队列 new ThreadFactory() { private final AtomicLong threadCount new AtomicLong(0); Override public Thread newThread(Runnable r) { return new Thread(r, SafeBiz-Thread- threadCount.incrementAndGet()); } }, // 自定义拒绝策略记录轻量日志不创建大对象 new RejectedExecutionHandler() { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // ✅ 推荐只记录任务特征不持有任务引用 logger.warning(任务被拒绝线程池已满任务哈希: r.hashCode()); // 这里可以投递到死信队列方便后续补偿 } } ); // 允许核心线程超时节省资源 executor.allowCoreThreadTimeOut(true); } public void submitTask(Runnable task) { try { executor.submit(task); } catch (Exception e) { // 捕获提交异常防止主线程崩溃 logger.severe(任务提交失败: e.getMessage()); } } public void shutdown() { // 优雅停机 executor.shutdown(); try { // 等待 30 秒看任务能不能跑完 if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { // 如果没跑完强制关闭 executor.shutdownNow(); // 再等 10 秒 if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { logger.severe(线程池未能正常关闭); } } } catch (InterruptedException e) { // 恢复中断状态 executor.shutdownNow(); Thread.currentThread().interrupt(); } } }这段代码的关键在于拒绝策略的克制。它只打印了任务的哈希码没有持有任务对象的引用。这样垃圾回收器就能立刻回收被拒绝的任务不会造成内存泄漏。七、总结线程池是 Java 并发的基石也是内存泄漏的重灾区。这次故障告诉我们拒绝策略不是“垃圾桶”不能往里扔重东西。监控要到位队列要有界拒绝要轻量。把复杂的问题想简单把简单的细节做到极致。这才是我们作为架构师该干的事。下次再看到 Full GC 频繁先查查线程池的拒绝策略吧。