摘要:作为互联网软件开发人员,你是不是也遇到过这种情况:本地测试时多线程代码跑得顺顺利利,一部署到生产环境,没几天就触发 OOM 告警?更头疼的是,日志里只飘着 “OutOfMemoryError: java heap space”,翻遍线程栈也找不到明确指向,最后
作为互联网软件开发人员,你是不是也遇到过这种情况:本地测试时多线程代码跑得顺顺利利,一部署到生产环境,没几天就触发 OOM 告警?更头疼的是,日志里只飘着 “OutOfMemoryError: java heap space”,翻遍线程栈也找不到明确指向,最后只能临时重启服务应急 —— 但下次故障还会悄摸摸找上门。
前阵子我帮同事排查的一个线上问题,就完美踩中了这个坑:他们的订单处理服务用了线程池异步处理消息,上线一周后突然宕机,JVM 堆内存直接飙到 95%。查了半天才发现,是代码里用 ThreadLocal 存了大对象,线程池复用线程后没清理,导致对象一直占着内存不放,累积到一定量就炸了。其实这种多线程 OOM 问题,不是个案,很多时候都是因为我们忽略了 “线程资源复用” 和 “内存回收” 的隐性关联。
在解决问题前,咱们得先理清一个核心逻辑:多线程本身不会导致 OOM,但 “线程资源的不当使用” 会让内存漏洞被无限放大。这里有 3 个你必须清楚的技术背景,也是很多 OOM 事故的 “隐形前提”。
第一个是线程池的 “线程复用” 机制。咱们平时用 ThreadPoolExecutor 创建线程池,核心线程默认是 “空闲时不销毁” 的,目的是减少线程创建 / 销毁的开销。但这也意味着,线程里的 ThreadLocal、静态变量等资源,会跟着线程一起 “常驻内存”—— 如果线程处理完一个任务后,没清空这些资源,下一个任务复用线程时,旧资源就会一直堆在堆内存里,相当于 “线程变成了内存垃圾的‘收容所’”。
第二个是JVM 对 ThreadLocal 的回收规则。很多开发觉得 ThreadLocal 是 “线程私有” 的,线程结束就会自动回收,其实不然。ThreadLocal 的底层是靠 “Thread 中的 ThreadLocalMap” 存储数据,而 ThreadLocalMap 的 key 是弱引用(会被 GC 自动回收),但 value 是强引用。如果线程没结束(比如线程池的核心线程),key 被回收后,value 就会变成 “无主的强引用对象”,既没法被 GC 清理,又没法被代码访问,直接造成内存泄漏,累积多了就是 OOM。
第三个是 **“大对象 + 高并发” 的叠加效应 **。如果多线程处理的是小对象,哪怕有内存泄漏,短期内也不会触发 OOM;但如果是处理订单明细、用户画像这类大对象(比如一个对象占几十 KB),在每秒几百次的并发下,一个小时就能累积出几 GB 的 “无效内存”—— 而生产环境的 JVM 堆内存通常也就 4-8GB,很容易被撑爆。
上次排查订单服务的 OOM 时,我没走 “翻遍所有代码” 的弯路,而是按 “定位泄漏点→追踪资源流向→验证回收逻辑” 的步骤来,2 小时就找到根因。这套方法你也能直接用,尤其是线上紧急故障时,能少走很多冤枉路。
日志里的 OOM 提示太笼统,咱们得用工具抓 “内存快照”(Heap Dump)—— 推荐用 JDK 自带的 jmap 命令,或者 Arthas 的 heapdump 命令,操作很简单:比如在服务器上执行jmap -dump:format=b,file=heap.hprof [PID],就能把当前 JVM 堆内存的快照存成文件。
拿到快照后,用 MAT(Memory Analyzer Tool)打开,先看 “Leak Suspects” 报告。像上次的案例,报告直接指出 “ThreadLocal$ThreadLocalMap” 关联的对象占了堆内存的 68%,而且这些对象都是 “OrderDetailDTO”(订单明细大对象)—— 这一步就把 “泄漏对象类型” 和 “关联的 ThreadLocal” 锁定了,不用再漫无目的地查代码。
知道了是 ThreadLocal 存的 OrderDetailDTO 没回收,接下来就要找 “哪里的 ThreadLocal 在存这个对象”。在 MAT 里选 “Path to GC Roots”,就能看到对象的创建链路:从 ThreadLocal 的 set 方法,一路追溯到具体的代码行 —— 当时定位到的是 “OrderProcessService” 类里的一个静态 ThreadLocal,代码是这么写的:
// 问题代码:静态ThreadLocal存大对象,未清理private static final ThreadLocalorderThreadLocal = new ThreadLocal;public void processOrder(OrderMessage msg) { // 存大对象到ThreadLocal OrderDetailDTO detail = parseMsgToDetail(msg); orderThreadLocal.set(detail); // 业务处理逻辑(中间可能抛异常,导致后续remove执行不到) doBusiness(detail); // 只在正常流程清理,异常时漏了 orderThreadLocal.remove;}这里的问题很明显:remove 方法放在了业务逻辑后面,一旦 doBusiness 抛异常,remove 就不会执行,ThreadLocal 里的 OrderDetailDTO 就会一直留在线程里。而且 ThreadLocal 是静态的,跟线程池的核心线程绑定后,内存泄漏就成了必然。
找到可疑代码后,别着急改,先在本地复现问题,确认根因。咱们可以模拟线程池复用线程的场景:创建一个核心线程数为 1 的线程池,提交 2 个任务,第一个任务往 ThreadLocal 存对象后抛异常,第二个任务不存对象,看第一个任务的对象是否还在内存里。
比如写个简单的测试代码:
public class ThreadLocalOOMTest { private static final ThreadPoolExecutor executor = new ThreadPoolExecutor( 1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue ); private static final ThreadLocalthreadLocal = new ThreadLocal; public static void main(String args) { // 第一个任务:存大对象+抛异常 executor.submit( -> { byte bigArr = new byte[1024 * 1024 * 50]; // 50MB大对象 threadLocal.set(bigArr); throw new RuntimeException("故意抛异常,不执行remove"); }); // 第二个任务:不存对象,查看ThreadLocal是否有残留 executor.submit( -> { byte residual = threadLocal.get; if (residual != null) { System.out.println("ThreadLocal残留对象!大小:" + residual.length / 1024 / 1024 + "MB"); } else { System.out.println("ThreadLocal已清理"); } }); executor.shutdown; }}运行后你会发现,第二个任务能打印出 “ThreadLocal 残留对象!大小:50MB”—— 这就实锤了:线程池复用线程时,未清理的 ThreadLocal 会残留对象。线上环境就是这样,每次任务异常都会留下 “内存垃圾”,最后堆内存被撑爆。
搞懂了原理和排查方法,更重要的是 “从源头避免”。结合我平时的开发经验,总结了 4 个实操性强的避坑方案,覆盖 ThreadLocal、线程池、内存监控三个核心场景,你直接加到代码规范里就行。
这是最基础也最关键的一步 —— 只要用了 ThreadLocal,就必须在 try 代码块里存值,finally 代码块里清理,哪怕你觉得 “业务逻辑不会抛异常” 也不能省。正确的写法应该是这样:
private static final ThreadLocalorderThreadLocal = new ThreadLocal;public void processOrder(OrderMessage msg) { try { OrderDetailDTO detail = parseMsgToDetail(msg); orderThreadLocal.set(detail); doBusiness(detail); // 哪怕这里抛异常,finally也会执行 } finally { // 关键:无论正常还是异常,都清空ThreadLocal orderThreadLocal.remove; }}这里还要提醒一句:如果用的是 Java 8+,可以试试InheritableThreadLocal的替代方案,但清理逻辑同样不能少 —— 它只是解决 “父子线程数据传递” 问题,内存回收规则和 ThreadLocal 是一样的。
很多 OOM 事故,其实是线程池参数 “拍脑袋配置” 导致的。比如为了追求 “快”,把核心线程数设得跟 CPU 核数一样多,又把队列容量设成 Integer.MAX_VALUE—— 结果任务堆积时,队列里的任务对象占满内存,直接触发 OOM。
给你一个简单的参数计算逻辑,适合大多数业务场景:
核心线程数:CPU 密集型任务(如计算、加密)设为 “CPU 核数 + 1”;IO 密集型任务(如 DB 查询、接口调用)设为 “CPU 核数 * 2”(可以用Runtime.getRuntime.availableProcessors获取 CPU 核数)。队列容量:别设太大,一般设 50-200 就够了,超过这个值就触发 “拒绝策略”(比如用 CallerRunsPolicy,让主线程临时处理,避免任务堆积)。拒绝策略:绝对别用 AbortPolicy(默认,直接抛异常),推荐用 CallerRunsPolicy 或自定义策略(比如把任务丢到 MQ 里重试)。举个正确的配置例子:
// CPU核数(假设是8核),IO密集型任务,核心线程数设为16int corePoolSize = Runtime.getRuntime.availableProcessors * 2;int maximumPoolSize = corePoolSize; // 非峰值场景,最大线程数等于核心线程数BlockingQueueworkQueue = new LinkedBlockingQueue(100); // 队列容量100ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, maximumPoolSize, 60L, TimeUnit.SECONDS, workQueue, Executors.defaultThreadFactory, new ThreadPoolExecutor.CallerRunsPolicy // 队列满了让主线程处理);如果业务里必须用大对象(比如每次任务都要创建 100KB 以上的对象),别每次都 new,用 “对象池” 复用 —— 比如 Apache Commons Pool 的 GenericObjectPool,或者自己写个简单的对象池,避免频繁创建大对象导致 GC 跟不上,进而触发 OOM。
举个简单的对象池示例(用 GenericObjectPool):
// 1. 定义对象池的“对象工厂”PooledObjectFactoryfactory = new BasePooledObjectFactory { @Override public OrderDetailDTO create throws Exception { // 创建大对象(实际场景可以加初始化逻辑) return new OrderDetailDTO; } @Override public PooledObjectwrap(OrderDetailDTO obj) { return new DefaultPooledObject(obj); } // 归还对象时重置数据,避免数据残留 @Override public void passivateObject(PooledObjectp) throws Exception { OrderDetailDTO obj = p.getObject; obj.setOrderId(null); obj.setDetailList(null); // 清空集合,避免内存泄漏 }};// 2. 配置对象池参数GenericObjectPoolConfigconfig = new GenericObjectPoolConfig;config.setMaxTotal(50); // 最大对象数config.setMaxIdle(20); // 最大空闲对象数config.setMinIdle(5); // 最小空闲对象数// 3. 创建对象池并使用GenericObjectPoolobjectPool = new GenericObjectPool(factory, config);// 任务中复用对象executor.submit( -> { OrderDetailDTO detail = null; try { detail = objectPool.borrowObject; // 从池里借对象 detail.setOrderId("123456"); doBusiness(detail); } catch (Exception e) { log.error("处理订单异常", e); } finally { if (detail != null) { objectPool.returnObject(detail); // 归还对象到池里 } }});这样一来,大对象不用每次创建,也不用占着 ThreadLocal 的内存,既能减少 GC 压力,又能避免 OOM。
最好的防御是 “提前预警”。咱们可以在服务里加个简单的内存监控逻辑,定期检查 JVM 堆内存使用率,超过阈值就发告警(比如发钉钉 / 企业微信通知),让问题在 “萌芽阶段” 就被发现。
用 JDK 的 ManagementFactory 就能实现,代码不复杂:
import java.lang.management.ManagementFactory;import java.lang.management.MemoryMXBean;import java.lang.management.MemoryUsage;import java.util.concurrent.Executors;import java.util.concurrent.TimeUnit;public class MemoryMonitor { // 堆内存使用率阈值,超过80%发告警 private static final double WARNING_THRESHOLD = 0.8; public static void startMonitor { // 每5分钟检查一次内存 Executors.newSingleThreadScheduledExecutor.scheduleAtFixedRate( -> { MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean; MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage; // 计算堆内存使用率 double usedPercent = (double) heapUsage.getUsed / heapUsage.getMax; String warningMsg = String.format( "堆内存告警!已用:%.2fMB,总大小:%.2fMB,使用率:%.1f%%", heapUsage.getUsed / 1024 / 1024, heapUsage.getMax / 1024 / 1024, usedPercent * 100 ); // 超过阈值发告警(实际场景替换成钉钉/企业微信接口) if (usedPercent > WARNING_THRESHOLD) { System.out.println("[告警] " + warningMsg); // DingTalkUtil.sendAlert(warningMsg); // 调用告警工具类 } else { System.out.println("[正常] " + warningMsg); } }, 0, 5, TimeUnit.MINUTES); }}把这个监控类在服务启动时初始化(比如在 SpringBoot 的 Application 类里调用MemoryMonitor.startMonitor),就能实时掌握内存动态,不用再 “被动等故障”。
回顾咱们今天聊的内容,其实多线程 OOM 的核心原因就两个:一是 “资源没清理”(比如 ThreadLocal 漏 remove),二是 “资源用得太猛”(比如线程池参数乱配、大对象频繁创建)。而解决办法也很直接:清理逻辑用 try-finally 兜底,线程池参数算着配,大对象用池复用,再加上内存监控 —— 这四步做好了,90% 的多线程 OOM 问题都能规避。
作为互联网软件开发人员,咱们写代码时不仅要 “实现功能”,更要 “考虑稳定性”。比如下次写 ThreadLocal 的时候,先想想 “清理逻辑加了吗”;配线程池的时候,先算算 “核心线程数是不是合理”。这些小细节,往往是区分 “能写代码” 和 “会写好代码” 的关键。
最后也想跟你互动下:你之前有没有遇到过多线程 OOM 问题?当时是怎么排查的?欢迎在评论区分享你的经历,咱们一起交流更多技术坑点和解决方案。如果觉得这篇文章有用,也可以转发给身边做开发的同事,让更多人少踩 OOM 的坑~
来源:从程序员到架构师