那是一个双十一的凌晨,作战室里灯火通明,大屏幕上的GMV正在疯狂跳动。然而,就在零点高峰过去不久,告警系统突然像疯了一样,刺耳的红色警报响彻整个楼层——订单服务雪崩,P0级故障!
所有人的心都沉到了谷底。经过半小时的紧急排查,最终的罪魁祸首让所有人大跌眼镜:一个新上线的“订单短信通知”功能。负责这块代码的实习生小A脸色惨白,喃喃自语:“不可能啊,我用了线程池做的异步发送,就是一行Executors.newCachedThreadPool(),怎么会崩呢?”
我立刻抓取了线程堆栈,看着那密密麻麻、成千上万个名为pool-x-thread-x的线程,我叹了口气。小A,你用一行代码,几乎葬送了我们全年的努力。
1. Executors的甜蜜陷阱,通往OOM的地狱之路
很多新手,甚至一些老鸟,都喜欢用Executors的静态方法来创建线程池。因为它简单、方便,一行搞定。但《阿里巴巴Java开发手册》中明确规定“强制禁用”,这背后是无数血淋淋的教训。
让我们来看看,两种“瞬间爆炸”的写法。
1.1 newCachedThreadPool -> 无限线程,撑爆系统
小A犯的正是这个错误。他的短信发送服务,在正常情况下100毫秒就能完成。但在双十一那天,短信服务商的接口出现延迟,超时时间被设置成了60秒。
// 看起来岁月静好,实则暗藏杀机
ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newCachedThreadPool();
for (int i = 0; i < 10000; i++) {
// 高峰期,大量请求涌入
threadPool.execute(() -> {
// 调用一个耗时60秒的外部短信接口…
try {
TimeUnit.SECONDS.sleep(60);
} catch (InterruptedException e) { /* … */ }
});
}
灾难后果:newCachedThreadPool的核心线程数是0,最大线程数是Integer.MAX_VALUE(约等于无限)。它的工作队列SynchronousQueue不存储任何任务。这意味着来一个任务,如果没有空闲线程,就立刻创建一个新线程。
高峰期每分钟涌入6000个订单,就需要创建6000个线程。每个线程都要消耗大约1MB的栈内存,服务器瞬间被数千个线程榨干,最终抛出java.lang.OutOfMemoryError: unable to create new native thread,整个应用轰然倒塌。
1.2 newFixedThreadPool -> 无限队列,撑爆内存
有些同学可能会说:“那我用newFixedThreadPool限制线程数不就行了?”天真!
// 线程数是固定的,但队列是无底洞
ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
for (int i = 0; i < 100_000_000; i++) {
threadPool.execute(() -> {
// 创建一个大对象,模拟业务数据
String payload = UUID.randomUUID().toString();
try {
TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) { /* … */ }
});
}
灾难后果:newFixedThreadPool的线程数是固定的,但它的工作队列LinkedBlockingQueue的容量是Integer.MAX_VALUE。如果任务处理速度跟不上提交速度,任务就会在队列里无限堆积,最终把你的堆内存撑爆,抛出OutOfMemoryError: Java heap space。
1.3 你的地盘,你必须做主!
Executors的便利方法,本质上是隐藏了线程池最重要的几个参数,让你失去了对线程池行为的控制权,这在生产环境中是极其危险的。正确的做法应该是手动创建,做到心中有数。
// 使用 guava 的 ThreadFactoryBuilder
ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("order-sms-pool-%d").build();
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
10, // corePoolSize: 核心线程数
50, // maximumPoolSize: 最大线程数
60L, // keepAliveTime: 空闲线程存活时间
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), // workQueue: 有界阻塞队列
threadFactory,
new ThreadPoolExecutor.AbortPolicy() // rejectedExecutionHandler: 拒绝策略
);
核心:必须手动创建ThreadPoolExecutor,明确指定核心线程数、最大线程数、有界队列和拒绝策略,并给线程起一个有意义的名字(如order-sms-pool-%d),这样在排查问题时,你一眼就能看出是哪个业务的线程出了问题。
2. 假性复用,那个被反复创建的“一次性”线程池
事故之后,团队禁止了Executors。但不久后,另一个项目又报警了:线程数频繁抖动,时不时飙升到几千。抓取线程堆栈后发现,内存里竟然有上千个线程池实例!
业务代码看起来很正常,调用一个ThreadPoolHelper来获取线程池。
// 业务代码
ThreadPoolExecutor threadPool = ThreadPoolHelper.getThreadPool();
threadPool.execute(/* … */);
// 辅助类的实现
class ThreadPoolHelper {
public static ThreadPoolExecutor getThreadPool() {
// 灾难的根源:每次调用都创建一个新的线程池!
return (ThreadPoolExecutor) Executors.newCachedThreadPool();
}
}
灾难后果:每次调用getThreadPool()都在创建一个新的线程池。线程池的意义就在于复用,这种写法比不用线程池还糟糕,因为它不仅没有复用线程,还制造了大量的线程池对象和垃圾回收压力。
池化技术的核心是复用:线程池、连接池等技术的精髓在于缓存和复用昂贵的对象。每次都new一个,完全违背了其设计初衷。
参考代码如下:
class ThreadPoolHelper {
// 使用 static final 确保线程池全局唯一且不可变
private static final ThreadPoolExecutor THREAD_POOL = new ThreadPoolExecutor(
10, 50, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000)
);
public static ThreadPoolExecutor getThreadPool() {
return THREAD_POOL;
}
}
3. 线程池内战,IO任务与CPU任务的殊死搏斗
最后一个事故,更加隐蔽。用户反馈某个查询接口(纯内存计算)越来越慢,从几十毫秒劣化到好几秒。CPU使用率不高,系统也没有崩溃,但就是慢得离谱。
混用线程池的“悲惨世界”:排查发现,这个“倒霉”的查询任务,和一个后台的“文件批处理”任务,共用了一个线程池。
// 一个被IO密集型和CPU密集型任务共享的线程池
private static ThreadPoolExecutor sharedPool = new ThreadPoolExecutor(
2, 2, 1, TimeUnit.HOURS, new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy() // 注意这个拒绝策略!
);
// 任务1:后台批处理文件(慢,IO密集型)
public void backgroundFileProcessing() {
sharedPool.execute(() -> {
// … 疯狂写文件,耗时很长 …
});
}
// 任务2:用户查询(快,CPU密集型)
public int userQuery() {
Future<Integer> future = sharedPool.submit(() -> {
// … 纯内存计算,本该很快 …
return 1;
});
return future.get();
}
灾难后果: 这个线程池只有2个线程。后台的文件批处理任务是慢速的IO操作,它会长时间霸占着这2个宝贵的线程。当用户的查询请求过来时,线程池已满,队列也很快被文件任务塞满。
更致命的是CallerRunsPolicy这个拒绝策略,它的意思是:线程池处理不了,谁提交的谁自己去执行。于是,本该异步执行的内存计算任务,被硬生生地交给了处理Web请求的Tomcat线程来同步执行!这不仅让当前查询变慢,还阻塞了Tomcat线程,使其无法处理其他用户的请求,最终导致整个应用的吞吐量急剧下降。
不同性质的任务对线程池的需求截然不同
- IO密集型任务:如文件读写、网络请求。线程大部分时间在等待IO,CPU是空闲的。因此需要较多的线程来提高CPU利用率。
- CPU密集型任务:如复杂计算。线程一直在使用CPU。线程数不宜超过CPU核心数,否则过多的线程切换反而会降低性能。
把它们混在一起,就像让短跑运动员和长跑运动员在同一条赛道上比赛,互相干扰,谁也跑不好。
正确的做法是专池专用,互不干扰。
// 为IO密集型任务创建一个独立的线程池
private static ThreadPoolExecutor ioThreadPool = new ThreadPoolExecutor(...);
// 为CPU密集型任务创建另一个独立的线程池
private static ThreadPoolExecutor cpuThreadPool = new ThreadPoolExecutor(...);
// 各自使用自己的池子
ioThreadPool.execute(/* 文件处理 */);
cpuThreadPool.submit(/* 内存计算 */);
线程池不是万金油,隔离和复用才是王道
让我们总结一下从这三次血淋淋的事故中学到的教训:
记住,线程池就像你手下的兵,胡乱指挥,他们就会在战场上给你制造天大的麻烦。精心编排,他们才是你攻城拔寨的利器。如果觉得有用,麻烦点个赞吧~~~
评论前必须登录!
注册