尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

生产环境 JVM 内存溢出案例分析 2 万字详解:从原理、工具到实战复盘

生产环境 JVM 内存溢出案例分析 2 万字详解:从原理、工具到实战复盘 1. 引言为什么生产环境的 JVM 内存溢出如此棘手生产环境的 JVMJava Virtual MachineJava 虚拟机内存溢出通常被工程师称为 OOMOutOfMemoryError是线上系统最让人头疼的故障之一。它不像普通的业务异常那样可以提前捕获并友好提示一旦发生轻则导致单次请求失败重则造成整个应用不可用、服务雪崩甚至集群整体宕机。OOM 的可怕之处在于它的突发性和连锁性。很多系统平时运行平稳却在流量高峰、定时任务执行、数据批量导入或长时间运行后突然抛出OutOfMemoryError而且往往在重启后短暂恢复过一段时间又再次出现呈现“周期性抖动”的特征。工程师如果缺乏排查经验很容易陷入“重启治标不治本”的恶性循环。更隐蔽的是不少“内存问题”表面上看起来是堆溢出实际上原因却可能藏在元空间、直接内存、线程资源甚至操作系统层级的本地内存里。如果只凭一句“内存不够调大堆”的经验去处理往往会越调越糟甚至掩盖真正的故障根因。本文从 JVM 内存模型讲起系统梳理常见 OOM 类型、排查工具与命令并给出多个贴近真实生产环境的典型案例覆盖堆溢出、元空间溢出、直接内存溢出、线程溢出、栈溢出、数据量级失控等场景。每个案例都会还原“现象—定位—根因—修复—预防”的完整链路帮助读者在面对线上内存问题时快速建立解题思路。本文适合有一定 Java 基础、正在接触或负责线上服务稳定性的后端工程师阅读。文中代码示例以 Java 为主排查过程以 Linux 环境为例工具覆盖 JDK 自带命令行工具、MAT、Arthas 等常见组合。2. JVM 内存模型基础回顾理解内存溢出先要清楚 JVM 的内存如何划分。不同 JDK 版本在分区名称和管理方式上有所变化但总体上可以分为线程私有区和线程共享区两大部分。2.1 程序计数器程序计数器Program Counter Register是每条线程私有的小块内存用于记录当前线程执行字节码的位置。它占用的空间极小也是 JVM 规范中唯一没有规定任何 OutOfMemoryError 情况的区域在 OOM 讨论中基本可以忽略。2.2 Java 虚拟机栈虚拟机栈VM Stack同样是线程私有。每个 Java 方法执行时都会创建一个栈帧用于保存局部变量表、操作数栈、动态链接和返回地址等信息。栈空间在 JDK 8 之后主要通过-Xss参数控制。当线程请求的栈深度超过虚拟机允许的最大深度且无法扩展时会抛出StackOverflowError当栈容量扩展时无法申请到足够内存则会抛出OutOfMemoryError: unable to create new native thread。前者常见于无限递归后者常见于线程数过多。2.3 本地方法栈本地方法栈与虚拟机栈类似区别在于它为 Native 方法服务。HotSpot 虚拟机直接将虚拟机栈和本地方法栈合二为一因此在实践中通常不单独讨论。2.4 Java 堆Java 堆Heap是 JVM 管理的最大一块内存也是所有线程共享的区域。它用于存放对象实例和数组是垃圾收集器Garbage CollectorGC管理的主要区域。从垃圾回收角度堆分为新生代Young Generation和老年代Old Generation新生代又细分为 Eden 区、两个 Survivor 区。堆的大小由-Xms初始值和-Xmx最大值控制。堆溢出是最常见的 OOM错误信息为OutOfMemoryError: Java heap space。从 JDK 9 开始G1Garbage First成为默认垃圾收集器JDK 15 以后 ZGC、Shenandoah 等低延迟收集器逐渐成熟。不同收集器在堆布局上有差异但“堆空间耗尽”的本质逻辑一致。2.5 方法区与元空间JDK 8 之前方法区由永久代PermGen实现JDK 8 开始HotSpot 用元空间Metaspace取代永久代。元空间使用本地内存Native Memory受-XX:MaxMetaspaceSize控制。它主要存放类元信息、方法信息、常量池、静态变量等。元空间溢出的错误为OutOfMemoryError: Metaspace通常在动态生成大量类、滥用动态代理、频繁热部署时出现。2.6 直接内存直接内存Direct Memory不属于 JVM 运行时数据区而是通过java.nio包中的ByteBuffer.allocateDirect()分配的堆外内存。它不受-Xmx限制但受操作系统物理内存和-XX:MaxDirectMemorySize限制。NIO、Netty、Kafka 等高性能 IO 框架大量使用直接内存。直接内存溢出常见错误为OutOfMemoryError: Direct buffer memory。2.7 线程资源与本地内存每个 Java 线程除了栈空间外还需要占用一定的本地内存来维护线程结构。当线程数过多、系统可分配的内存或ulimit -u进程数限制达到上限时会抛出OutOfMemoryError: unable to create new native thread。这种 OOM 在 JDK 8 中通常不归属堆而是本地内存耗尽需要格外注意盲目调大-Xmx反而可能加剧问题。理解上述内存分区能帮助我们在看到具体 OOM 异常时快速判断问题出在堆内还是堆外进而选择正确的排查方向。3. 常见内存溢出类型全景错误信息内存区域典型原因Java heap spaceJava 堆对象无法回收、集合无界增长、大对象分配失败GC overhead limit exceededJava 堆GC 频繁但回收效率极低垃圾占比过高Metaspace元空间动态生成过多类、类加载器泄漏、热部署Direct buffer memory直接内存Netty/NIO 堆外内存未释放或阈值过小unable to create new native thread本地内存线程数过多、线程池失控、系统限制StackOverflowError虚拟机栈无限递归、深链路调用、循环依赖Requested array size exceeds VM limitJava 堆尝试创建超过堆剩余空间的巨型数组在实际生产环境中出现概率最高的是Java heap space其次是元空间溢出和直接内存溢出。线程溢出虽然相对少见但一旦发生往往影响更大因为线程无法创建时整个应用的新增并发能力都会被冻结。4. 内存溢出的排查工具箱“工欲善其事必先利其器。”在进入案例之前先掌握一套实用的排查工具能让定位效率大幅提升。下面按照使用顺序介绍。4.1 JDK 自带命令jps列出当前用户下的 Java 进程及其 PID通常作为排查第一步。bashjps -ljstat实时监控 GC 和类加载情况无需停服。最常用参数如下bashjstat -gcutil 12345 1000 20上面的命令表示每 1000 毫秒输出一次 PID 为 12345 的进程 GC 情况共输出 20 次。重点观察EEden 区使用率、O老年代使用率、FGCFull GC 次数、FGCTFull GC 总耗时。若老年代长期接近 100% 且 Full GC 频繁基本可以判断为堆内存不足。jmap导出堆转储文件、查看内存映射等。bash# 导出堆转储文件 jmap -dump:formatb,file/tmp/heap.hprof 12345 # 查看堆中对象的统计信息JDK 8 可用线上慎用 jmap -histo:live 12345 | head -30注意jmap -dump在导出时会触发一次 STWStop The World暂停生产环境建议使用-XX:HeapDumpOnOutOfMemoryError让 JVM 在发生 OOM 时自动导出避免手动操作的风险。jstack打印线程栈常用于定位死锁、线程阻塞、无限循环等问题。bashjstack -l 12345 /tmp/thread.dumpjinfo查看或动态修改 JVM 参数。bashjinfo -flags 123454.2 堆转储分析工具MATEclipse Memory Analyzer强大的堆分析工具能自动生成 Leak Suspects泄漏嫌疑报告、Dominator Tree支配树、Histogram对象直方图等视图。排查时重点看“Leak Suspects”和“Path to GC Roots”找出对象为何无法被回收。jhatJDK 自带但性能一般适合小文件快速浏览生产环境大型堆文件不推荐。VisualVM可实时监控也可加载堆转储进行分析适合开发环境。4.3 Arthas线上排查利器Arthas 是 Alibaba 开源的 Java 诊断工具可以在不重启应用、不改代码的情况下完成线上诊断。常用的有dashboard查看整体状态heapdump导出堆快照thread查看线程classloader查看类加载器等。bash# 启动 java -jar arthas-boot.jar # 查看面板 dashboard # 查看内存相关 memory # 导出堆转储 heapdump /tmp/arthas-heap.hprof4.4 GC 日志GC 日志是排查内存问题的重要数据源建议生产环境始终开启。JDK 8 与 JDK 9 的参数略有不同以 JDK 8 为例bash-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles10 -XX:GCLogFileSize20MJDK 11 及以上建议使用统一日志bash-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount10,filesize20M通过 GC 日志可以观察每次回收前后各区域容量变化特别要关注 Full GC 后老年代是否明显下降。如果 Full GC 后老年代只回收了很小一部分说明存在大量仍被强引用的对象即真正的内存泄漏。5. 案例一集合类无界增长导致的堆溢出5.1 故障现象某互联网公司的订单服务在电商大促当天开始出现接口响应变慢随后频繁触发 Full GC。监控显示 JVM 老年代使用率持续攀升最终应用抛出OutOfMemoryError: Java heap space服务被迫重启。重启后半小时内再次复现。5.2 排查过程第一步确认环境参数。通过jinfo -flags查看 JVM 参数发现该服务堆内存初始化和最大值均为 4G其中新生代约 1.3G对订单服务来说并不小。bashjinfo -flags 12345第二步观察 GC 趋势。使用jstat -gcutil观察发现老年代使用率从 60% 一路上升到 99%Full GC 平均每 20 秒发生一次但每次 Full GC 后老年代使用率只下降不到 5%说明绝大部分对象处于强引用状态无法被回收。bashjstat -gcutil 12345 1000 30第三步导出堆转储。由于已提前配置了-XX:HeapDumpOnOutOfMemoryErrorOOM 发生时自动生成了heap.hprof文件。将该文件下载到本地用 MAT 打开。第四步分析支配树。MAT 的 Dominator Tree 显示一个名为OrderContextHolder的静态类持有的ConcurrentHashMapString, ListOrderInfo占用了约 3.2G 堆内存远远超过其他对象。5.3 根因分析阅读源码发现该服务的营销活动模块为了“临时缓存用户参与的拼团订单”定义了一个静态 Mapjavapublic class OrderContextHolder { private static final MapString, ListOrderInfo ORDER_CACHE new ConcurrentHashMap(); public static void put(String userId, OrderInfo orderInfo) { ORDER_CACHE.computeIfAbsent(userId, k - new ArrayList()) .add(orderInfo); } }正常业务中用户拼团完成后本应调用remove(userId)清理缓存但由于某次代码优化把清理逻辑注释掉了导致每个用户的订单列表一直留在内存中。大促期间大量用户参与拼团缓存对象持续堆积最终撑爆堆内存。5.4 解决方案短期应急重启应用并临时限制拼团活动的并发和参与人数减少缓存增长速度。长期修复恢复缓存清理逻辑同时引入Caffeine等具备过期淘汰能力的缓存组件避免再次出现“只进不出”的情况。javaprivate static final CacheString, ListOrderInfo ORDER_CACHE Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(30, TimeUnit.MINUTES) .build();5.5 预防与复盘静态集合类要谨慎使用必须保证有清理或淘汰机制。生产环境开启-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump为事后分析保留现场。缓存组件优先使用成熟框架不自己造无界缓存轮子。6. 案例二ThreadLocal 使用不当导致内存溢出6.1 故障现象某中台权限认证服务运行一段时间后出现内存缓慢上升、Full GC 越来越频繁的现象。排查监控发现堆内存使用呈“锯齿状缓慢抬升”但每次 Full GC 后仍有一块内存无法释放最终触发 OOM。6.2 排查过程导出堆转储后用 MAT 分析发现大量UserContext对象占据内存。这些对象通过一条引用链被ThreadLocalMap持有。进一步查看 Thread 视图发现这些ThreadLocalMap隶属于 Tomcat 的工作线程池。javapublic class UserContextHolder { private static final ThreadLocalUserContext USER_CONTEXT new ThreadLocal(); public static void set(UserContext context) { USER_CONTEXT.set(context); } public static UserContext get() { return USER_CONTEXT.get(); } public static void clear() { USER_CONTEXT.remove(); } }6.3 根因分析进一步调阅认证拦截器源码后发现开发人员在过滤器中设置了用户上下文但只在正常返回分支调用了clear()异常分支和部分提前返回分支都没有清理 ThreadLocal。javaOverride public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContextHolder.set(null); // 错误没有真正移除 ThreadLocal }问题的本质在于 Tomcat 工作线程池的长生命周期与 ThreadLocal 的线程绑定特性叠加。Tomcat 线程处理完请求后并不会销毁而是回到线程池等待下一个任务。此时如果 ThreadLocal 没有被remove()线程对应的 ThreadLocalMap 会继续强引用 UserContext 对象导致这些本应随请求结束而回收的对象被长期保留。随着请求不断进来泄漏对象越积越多堆内存被慢慢耗尽。6.4 解决方案短期应急重启服务释放内存并在认证过滤器中临时补上全局清理逻辑确保所有返回路径都执行remove()。长期修复将 ThreadLocal 的清理放到 afterCompletion 的 finally 块中保证无论是否发生异常都会执行。javaOverride public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { try { // 其他收尾逻辑 } finally { UserContextHolder.clear(); } }同时建议使用拦截器注册机制统一管理或者优先选择框架提供的请求上下文组件避免每个业务模块自行“造轮子”维护 ThreadLocal。6.5 预防与复盘ThreadLocal 用完必须调用remove()最好写在 finally 块中。使用线程池复用的场景下要特别关注线程绑定数据的生命周期管理。通过代码扫描规则检查ThreadLocal.set()后是否存在对应remove()。堆转储分析时如果发现 ThreadLocalMap 持有大量业务对象要优先排查拦截器、过滤器、AOP 等公共组件。7. 案例三类加载器泄漏导致的元空间溢出7.1 故障现象某业务中台为了支持运营活动的灵活上下线引入了 Groovy 脚本引擎动态执行规则。系统发布并稳定运行几天后监控平台突然告警大量请求出现延迟升高随后应用抛出OutOfMemoryError: Metaspace。重启后恢复但每隔一两天又会再次出现且触发间隔越来越短。7.2 排查过程第一步观察元空间变化。使用jstat -gcutil观察发现 MMetaspace 使用率持续上升到 100%而堆内存使用率并不高Young GC 和 Full GC 也无法回收元空间说明加载到元空间的类元数据无法被卸载。bashjstat -gcutil 12345 1000 20第二步查看类加载器。使用jmap -clstats或 Arthas 的classloader命令查看类加载器数量和已加载类数量发现系统中存在数千个GroovyClassLoader实例并且数量还在持续增长。bash# 查看类加载器统计 jmap -clstats 12345第三步定位加载点。结合业务代码发现每次执行规则都会调用以下类似逻辑javaGroovyClassLoader loader new GroovyClassLoader(); Class? scriptClass loader.parseClass(ruleScript); Script script (Script) scriptClass.getDeclaredConstructor().newInstance(); Object result script.run();执行完毕后 loader 没有被关闭局部变量虽然失效但由于每次编译生成的新类元数据仍被类加载器引用而类加载器又因为某些缓存结构没有被及时回收最终导致元空间被不断增长的类元数据占满。7.3 根因分析问题的根因有两点一是每次编译脚本都新建GroovyClassLoader又没有显式close()二是业务侧将脚本编译结果缓存时错误地把脚本的执行结果放入了一个静态 Map导致旧 ClassLoader 和它加载的类长期存活。两者叠加后元空间中的类元数据只增不减最终溢出。7.4 解决方案短期应急重启应用并暂时降低活动规则脚本的编译频率将高频执行的脚本提前编译并缓存复用。长期修复统一封装脚本执行入口复用同一个GroovyClassLoader在任务结束时统一关闭同时使用带容量上限、可淘汰的缓存保存编译结果。javapublic class GroovyExecutor { private static final GroovyClassLoader LOADER new GroovyClassLoader(); public static Object execute(String scriptText) throws Exception { Class? scriptClass LOADER.parseClass(scriptText); Script script (Script) scriptClass.getDeclaredConstructor().newInstance(); return script.run(); } }如果编译脚本的场景非常频繁还可以考虑预编译、脚本版本化管理避免每次请求都触发元数据加载。7.5 预防与复盘动态类加载、热部署、脚本引擎等场景要高度关注元空间使用。所有URLClassLoader、GroovyClassLoader等自定义类加载器用完后要及时close()。监控元空间使用率设置合理的-XX:MaxMetaspaceSize同时留意“类加载器泄漏”告警。禁止将脚本编译结果无界缓存到静态容器中。8. 案例四Netty 直接内存泄漏导致的 Direct buffer memory8.1 故障现象某长连接网关服务基于 Netty 构建线上运行一段时间后开始出现间歇性超时。某个流量高峰时段应用突然抛出OutOfMemoryError: Direct buffer memory。令人困惑的是监控显示堆内存使用率一直不算高物理机内存却接近耗尽。8.2 排查过程第一步确认溢出区域。错误信息明确指向直接内存而非 Java 堆。结合服务使用 Netty 接收请求的事实初步怀疑堆外内存存在泄漏。第二步观察进程 native 内存。通过/proc/PID/maps或 NMTNative Memory Tracking查看进程的 native 内存占用发现进程实际内存远超-Xmx且 Internal 区域持续增长。bash# 查看进程内存映射 pmap -x 12345 | tail -30第三步分析业务代码。在 Netty 的channelRead中开发人员手动读取了ByteBuf但没有在 finally 中释放引用计数。javaOverride public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf buf (ByteBuf) msg; byte[] bytes new byte[buf.readableBytes()]; buf.readBytes(bytes); handle(bytes); // 异常情况或后续分支未释放 ByteBuf }8.3 根因分析Netty 使用直接内存分配ByteBuf并通过引用计数管理内存释放。当开发者手动读取ByteBuf却没有调用ReferenceCountUtil.release(buf)时直接内存不会被回收。流量大、连接多的情况下泄漏的 ByteBuf 数量快速累积直至触发 Direct buffer memory 异常。8.4 解决方案短期应急重启网关服务并按连接维度重新评估入流量降低单机瞬时压力。长期修复统一使用SimpleChannelInboundHandler该类会在消息处理结束后自动释放 ByteBuf避免手工管理引用计数。javapublic class BizHandler extends SimpleChannelInboundHandlerByteBuf { Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) { byte[] bytes new byte[buf.readableBytes()]; buf.readBytes(bytes); handle(bytes); // 方法结束后 SimpleChannelInboundHandler 会自动释放 } }若必须在普通ChannelInboundHandler中手动读取则要保证 finally 中调用ReferenceCountUtil.release(msg)。同时可在测试环境开启泄漏检测bash-Dio.netty.leakDetection.levelPARANOID8.5 预防与复盘涉及直接内存的框架要正确管理引用计数优先使用自动释放机制。为直接内存设置合理阈值必要时通过 NMT 监控 native 内存。Netty、Kafka 等依赖堆外内存的组件其 OOM 不能只盯着-Xmx排查。9. 案例五线程池失控导致的 unable to create new native thread9.1 故障现象某报表中心在月底批量生成报表时前端长时间无响应。登录服务器查看日志发现大量OutOfMemoryError: unable to create new native thread。请求无法进入后台线程处理整个服务处于假死状态。9.2 排查过程第一步统计线程数。通过 jstack 查看线程总数发现线程数量接近 2 万个远超正常承载范围。bashjstack -l 12345 | grep java.lang.Thread.State | wc -l第二步检查系统限制。使用ulimit -u查看当前用户最大进程数限制同时查看操作系统总体内存水位。结果表明系统可分配内存和进程数限制已到达上限。bashulimit -u free -g第三步定位线程来源。结合线程名和业务代码发现报表模块每次生成任务都通过new Thread()创建线程而不是使用线程池。并发任务较多时线程数量失控。javapublic void buildReport(ReportTask task) { new Thread(() - { render(task); }).start(); }9.3 根因分析线程溢出虽然被归为 OOM但根因通常不是 Java 堆内存不足而是本地内存或系统线程数限制被耗尽。每个线程都要占用栈空间和本地内存当线程数过多时本地内存首先耗尽unable to create new native thread便会出现。此时如果盲目调大-Xmx压缩了本可分配给 native 内存的空间反而会让问题更严重。9.4 解决方案短期应急重启服务并对报表任务做入口限流控制同时生成的报表数量。长期修复使用有界的线程池统一管理任务根据业务特点设置合理的核心线程和最大线程数。javaprivate static final ExecutorService REPORT_POOL new ThreadPoolExecutor( 8, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(500), new ThreadFactoryBuilder().setNameFormat(report-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy()); public void buildReport(ReportTask task) { REPORT_POOL.submit(() - render(task)); }同时合理控制-Xss大小避免线程栈占用过多本地内存必要时由运维调整ulimit -u和容器内存限制。9.5 预防与复盘严禁在业务代码中直接new Thread()统一使用线程池。设置线程池有界队列和拒绝策略防止任务无限堆积。监控线程数、线程池队列深度及时告警。线程 OOM 要优先检查本地内存和系统限制而不是无脑调大堆内存。10. 案例六递归导致 StackOverflowError10.1 故障现象某内容平台的评论模块上线新版树形评论功能后部分深度较长的评论链无法正常展开接口直接抛出StackOverflowError。该异常不像堆 OOM 那样容易通过监控发现直到业务方反馈才暴露出来。10.2 排查过程第一步抓取线程栈。使用 jstack 抓取异常发生时的线程栈可以看到大量重复的方法帧。bashjstack -l 12345 | grep -A 60 StackOverflowError第二步定位递归方法。源码显示评论树组装使用递归遍历javapublic ListCommentVO buildTree(ListComment comments, Long parentId) { ListCommentVO nodes new ArrayList(); for (Comment comment : comments) { if (comment.getParentId().equals(parentId)) { CommentVO vo convert(comment); vo.setChildren(buildTree(comments, comment.getId())); nodes.add(vo); } } return nodes; }当评论链过长或出现脏数据导致的循环引用时递归深度超出虚拟机栈容量触发StackOverflowError。10.3 根因分析主要原因是数据出现异常。评论数据中由于历史迁移产生了一条子评论的 parent_id 指向其子孙节点形成环形结构递归遍历时无法终止。即便不是循环引用过深的评论层级在极端情况下也可能超出栈容量。10.4 解决方案短期应急对评论深度做限制对深度异常的脏数据做降级处理只展示前几层。长期修复将递归遍历改为迭代方式使用 Deque 显式维护遍历栈避免依赖虚拟机栈深度同时在上游维护评论层级对可疑的循环引用做兜底检测。javapublic ListCommentVO buildTreeIterative(ListComment comments, Long rootParentId) { // 使用显式栈结构替代递归规避 StackOverflowError MapLong, ListComment byParent comments.stream() .collect(Collectors.groupingBy(Comment::getParentId)); ListCommentVO roots toVO(byParent.getOrDefault(rootParentId, List.of())); DequeCommentVO stack new ArrayDeque(roots); while (!stack.isEmpty()) { CommentVO current stack.pop(); ListComment children byParent.getOrDefault(current.getId(), List.of()); ListCommentVO childVOs toVO(children); current.setChildren(childVOs); stack.addAll(childVOs); } return roots; }除了改递归为迭代还可以通过对数据加深度上限、在入库时校验父子关系避免环形引用进入系统。10.5 预防与复盘对可能深度不确定的数据结构优先考虑迭代而非递归。在数据层增加父子关系校验禁止出现环形引用。对评论、目录树等业务设置合理的层级上限并在接口层做深度保护。监控中增加对StackOverflowError的捕获与告警避免问题被长期掩盖。11. 案例七数据量级失控导致的大对象与批量操作溢出11.1 故障现象某数据导出服务在支持“一键导出全部订单”的功能后频繁在导出大客户数据时抛出OutOfMemoryError: Java heap space。小客户导出正常大客户几乎必然失败。11.2 排查过程第一步查看异常信息。错误信息明确指向 Java heap space说明是堆内存不足。第二步分析堆转储。导出堆转储后发现绝大多数内存被一个巨大的ListOrderExportDTO占用单个列表元素上百万且每个 DTO 都包含多个字段和嵌套对象。第三步定位代码。源码中导出逻辑一次性将全部数据查出来并放进内存javapublic void export(Long customerId, OutputStream out) { ListOrderExportDTO all orderMapper.selectAllByCustomer(customerId); writeExcel(all, out); }11.3 根因分析问题的本质是数据量级失控导出功能默认按“全量加载 内存处理”设计而大客户的数据量远超堆内存容量。即使对象本身不算大数量一多总内存占用也会迅速攀升到几十 GB触发堆溢出。11.4 解决方案短期应急限制单次导出的数据量或按时间范围拆分导出。长期修复改造为流式导出边查边写避免一次性将所有数据放入内存。javapublic void export(Long customerId, OutputStream out) { try (ExcelWriter writer new ExcelWriter(out)) { orderMapper.streamByCustomer(customerId, row - { writer.writeRow(convert(row)); }); } }如果底层数据库支持游标查询应优先使用游标或分页流式读取把内存占用控制在一个可控的窗口内。11.5 预防与复盘涉及大数据量处理的接口默认应采用流式或分批方案。在业务层设置导出上限超过阈值时提示用户按条件拆分。对导出、报表、批量任务等场景进行容量评估避免把压力直接暴露给 JVM 堆。监控堆内存增长与 Full GC 频率及时发现数据量级失控的隐患。12. 总结与预防清单生产环境的 JVM 内存溢出从来不是单一原因造成的它往往是代码、配置、数据量级和运行环境共同作用的结果。通过本文的案例可以看到堆溢出、元空间溢出、直接内存溢出、线程溢出和栈溢出背后对应的是完全不同的根因与排查路径。为了降低线上 OOM 的发生概率可以从以下几个层面建立预防机制代码层面静态集合要有清理或淘汰机制ThreadLocal 用完必须remove()类加载器用完要close()大数据量操作要流式处理递归要有深度保护。配置层面合理设置-Xms、-Xmx、-Xss、-XX:MaxMetaspaceSize、-XX:MaxDirectMemorySize开启-XX:HeapDumpOnOutOfMemoryError和 GC 日志为事后分析保留现场。监控层面对堆、元空间、直接内存、线程数、GC 频率与耗时建立持续监控和告警。演练层面定期做容量评估和故障演练验证限流、降级和应急预案的有效性。流程层面每次 OOM 后要完成复盘把根因和修复方案沉淀到文档和代码规范中避免同类问题重复发生。
返回列表