按规约自建线程池,一样可能 OOM
七个参数背后的“无界”陷阱项目里创建多线程很多人会先用Executors提供的几种现成线程池单线程、可变缓存、定长。阿里巴巴 P3C 规约插件会直接提示手动创建线程池不允许用 Executors.newFixedThreadPool 创建。于是大家改成自己用ThreadPoolExecutor创建觉得这样就安全了。但我的观点是问题的根源不是 Executors 这个类而是“无界”。无界的队列、无界的线程数都会把资源耗尽。如果你自己创建线程池时仍然写出了无界队列那一样会 OOM只是换了一个写法。下面从原文的内容出发把这件事讲清楚并指出原文“推荐解法”里的一个隐患。01Executors 的问题都藏着一个“无界”原文指出了两种线程池的问题其实有四种要补全创建方式隐患后果newFixedThreadPool底层用LinkedBlockingQueue默认容量是Integer.MAX_VALUE等于无界队列任务堆积内存撑爆OOMnewSingleThreadExecutor同上也是无界队列任务堆积OOMnewCachedThreadPool允许创建的线程数上限是Integer.MAX_VALUE瞬间创建大量线程OOM 或系统卡死newScheduledThreadPool同样允许极大的线程数同上所以规律是**资源无界就等于没有上限保护。**前两种是“队列无界”后两种是“线程数无界”。02七个参数只有一个真正决定生死自定义线程池核心是ThreadPoolExecutor的七个参数参数含义需要注意corePoolSize核心线程数常驻线程默认不会被回收maximumPoolSize最大线程数只有队列满了才会用到keepAliveTime空闲线程存活时间默认只作用于超出核心数的线程unit存活时间的单位—workQueue存放已提交但未执行的任务必须有界这是最关键的一个参数threadFactory创建线程的工厂用来给线程命名方便排查handler队列满、线程数也到上限后的拒绝策略属于业务决策见第五点03任务到底怎么被处理用原文的例子算一算new ThreadPoolExecutor(2, 5, 1L, TimeUnit.SECONDS, new LinkedBlockingQueue(3), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());提交任务的顺序是**先用核心线程 → 核心线程满了进队列 → 队列满了再扩线程到最大数 → 都满了才拒绝。**假设任务都是长时间运行的第 1、2 个任务创建 2 个核心线程执行。第 3、4、5 个任务进入容量为 3 的队列等待。第 6、7、8 个任务队列已满线程数从 2 扩到最大值 5。第 9 个任务线程数和队列都满了触发拒绝策略AbortPolicy会抛出RejectedExecutionException。也就是说这个线程池同时最多容纳5 3 8个任务。推论如果队列是无界的队列永远不会满线程数就永远不会超过核心线程数maximumPoolSize和拒绝策略这两个参数就形同虚设。这正是下一点要说的。04原文的“推荐解法”自己踩了同一个坑原文给出的“生产推荐写法”是private ExecutorService taskExe new ThreadPoolExecutor( 10, 20, 200L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable(), namedThreadFactory);仔细看队列new LinkedBlockingQueueRunnable()没有传容量默认就是Integer.MAX_VALUE依然是无界队列。这带来两个问题问题一任务堆积时仍然可能 OOM和它要避免的newFixedThreadPool一模一样。问题二最大线程数设置的 20 永远用不上因为队列永远不会满线程数就卡在 10。修正的写法给队列设置一个明确的上限并显式指定拒绝策略下面的数字仅作示例需要按业务压测确定// 引入 Guava 依赖给线程命名便于日志与 jstack 排查 private static final ThreadFactory NAMED_FACTORY new ThreadFactoryBuilder().setNameFormat(call-runner-%d).build(); private static final ExecutorService TASK_EXE new ThreadPoolExecutor( 10, 20, // 核心线程 10最大线程 20 60L, TimeUnit.SECONDS, // 非核心线程空闲 60 秒后回收 new ArrayBlockingQueue(200), // 有界队列最多排队 200 个 NAMED_FACTORY, new ThreadPoolExecutor.CallerRunsPolicy()); // 明确的拒绝策略这个池子同时最多容纳 20 200 220 个长任务超出后由拒绝策略处理资源上限清晰。05拒绝策略不是默认值是业务决策队列满了、线程也到上限新任务怎么办这不是技术问题而是业务取舍。JDK 提供四种内置策略策略行为适合与风险AbortPolicy默认抛出RejectedExecutionException需要调用方能处理异常否则任务悄悄失败CallerRunsPolicy由提交任务的线程自己执行自带“反压”效果减慢提交速度但会阻塞调用线程DiscardPolicy直接丢弃不报错最危险任务悄无声息地丢失除非任务本来就可丢DiscardOldestPolicy丢弃队列中最老的任务再尝试提交新任务适合“新数据比旧数据重要”的场景取重要业务任务选AbortPolicy并配合告警或用CallerRunsPolicy做反压也可以自定义策略把任务写入日志或持久化。舍不要因为“不想抛异常”就用DiscardPolicy。丢了任务却没人知道比报错更难排查。06线程池建好只是开始六件事别漏给线程命名原文用 Guava 的ThreadFactoryBuilder很好。出问题时日志和 jstack 里能一眼看出是哪个池子的线程。参数要压测不要拍脑袋常见的起点是 CPU 密集型设为核数加 1 左右IO 密集型设为核数的 2 倍左右但这只是经验起点最终要靠压测和监控调整。业务隔离不同业务用不同线程池避免一个慢任务把所有业务都拖住。监控关注活跃线程数、队列长度、已完成任务数、拒绝次数设置告警。异常别吞用submit提交的任务异常会被封装进Future不取结果就看不到。要么在任务内部捕获并记录要么给线程工厂设置未捕获异常处理器。优雅关闭应用停止时调用shutdown并等待在途任务完成避免丢任务。一句话总结线程池要防的不是 Executors而是“无界”。自定义线程池时先问三个问题队列有界吗最大线程数有上限吗队列满了怎么办这三个问题答清楚才算真正规避了资源耗尽的风险。说明不同 JDK 版本的细节可能有差异请以实际环境和官方文档为准。