ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JVM内存分配与垃圾回收全解:从对象生死到OOM排查实战

JVM内存分配与垃圾回收全解:从对象生死到OOM排查实战 凌晨两点你的手机响了——线上服务又OOM了。这已经不是第一次了上次是凌晨三点上上次是半夜十二点半。C程序员看着你笑指针全交给你们了还不够吗你默默打开JVM参数清单开始检查堆内存设置。说真的Java开发者都欠JVM一笔账。我们每天new对象、调接口、写业务从来不想这个对象到底被分配到了哪里什么时候被回收。直到线上告警打过来才发现自己对内存的理解停留在堆和栈两个词上。这篇博客就是想把Java内存分配与回收这件事彻底讲透从运行时数据区划分、对象分配全过程到垃圾回收的底层算法、主流收集器的选型再到OOM的排查实战一条线拉通。不管你是准备面试、还是正在调线上服务都可以直接参考。1. 内存区域拆解Java到底把内存分成了几块先说整体。JVM在运行Java程序时会把自己管理的内存划分为若干个区域各司其职。这些区域有些是线程共享的有些是线程私有的理解清楚每个区域的职责是后面所有调优和排查的基础。1.1 堆所有对象的老家堆是Java内存中最大的一块也是垃圾回收的主战场。几乎所有对象实例都在这里分配。堆被划分为新生代和老年代新生代又细分为Eden区、From Survivor区、To Survivor区默认比例是8:1:1。为什么要分成这些区域后面讲对象分配流程的时候会详细说。从实践角度来看堆大小的设置直接影响系统稳定性。设置得太小对象分配不出去直接OOM设置得太大GC停顿时间会变长响应延迟上不去。这里面有个平衡的艺术没有绝对正确的数值只有适合当前场景的配置。1.2 虚拟机栈线程私有的执行空间虚拟机栈描述的是Java方法执行的线程内存模型每个方法执行时都会创建一个栈帧栈帧里存放局部变量表、操作数栈、动态链接、方法出口等数据。说白了你每调用一个方法就会在栈上压入一个栈帧方法执行完栈帧弹出。这个区域比较有意思的一个点是栈深度的限制。默认情况下线程栈大小是1MB左右不同系统、不同JVM版本有差异如果递归调用层数太深没等堆内存耗尽栈先溢出了抛StackOverflowError。这就是为什么我们经常说递归要谨慎使用能用迭代解决的尽量不要递归。1.3 方法区与元空间类的元数据放哪里方法区存储的是已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。在JDK 8之前方法区的实现叫永久代JDK 8之后被彻底移除取而代之的是元空间。这里有个非常重要的变化永久代时期方法区的大小是可以通过-XX:MaxPermSize控制的而且它本身就在堆内会受堆大小的制约。而元空间使用的不是JVM堆内存而是本地内存理论上只受本机物理内存总大小的限制。这个改动直接解决了一大类问题——之前经常遇到的java.lang.OutOfMemoryError: PermGen space在JDK 8以后基本消失了取而代之的是Metaspace相关的OOM但后者出现的概率低很多因为你只需要关注元空间的默认上限。1.4 程序计数器和本地方法栈两个容易被忽略的角色程序计数器是当前线程所执行的字节码的行号指示器。它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域线程私有。它的存在是为了线程切换后能恢复到正确的执行位置。本地方法栈是给native方法服务的HotSpot虚拟机直接把本地方法栈和虚拟机栈合并成了一个所以用HotSpot开发时不需要额外关心这个区域。但面试被问到你还是要能说出来它的定位。1.5 直接内存堆外内存的隐性占位直接内存不是JVM运行时数据区的一部分但Java NIO里的DirectByteBuffer可以直接分配堆外内存这块内存不受JVM堆大小限制只受本机物理内存限制。很多高性能框架如Netty就是靠它做零拷贝的。排查线上内存问题的时候直接内存是最容易被忽视的地方。你的堆设置很合理GC也没有问题但机器物理内存就是持续飙升最后莫名其妙进程被系统杀掉。这种情况八九不离十是堆外内存泄漏了。所以排查内存问题的时候除了堆转储一定也要去看直接内存的使用情况。2. 对象的一生从new到被回收的完整路线图理解了内存区域划分接下来看一个对象从创建到销毁到底经历了什么。很多人以为new出来的东西往堆里一放就完事了实际上JVM为了保证分配效率、控制GC停顿在对象分配这件事上做了大量优化。2.1 一个对象是如何分配出去的假设你的代码里写了一行User user new User();。JVM拿到这条指令后会经历这么几步第一步检查User类是否已经被加载、解析、初始化过如果没有先执行类加载过程。第二步为对象分配内存。对象所需内存大小在类加载完成之后就可以完全确定分配方式有两种——指针碰撞Bump The Pointer和空闲列表Free List。堆内存规整的情况下用指针碰撞只需要把指针往空闲方向挪动一段与对象大小相等的距离不规整的情况下用空闲列表JVM维护一个列表记录哪些内存块是可用的分配时找一块足够大的划分给对象实例。第三步把分配到的内存空间初始化为零值不包括对象头这样对象的实例字段不赋初值也能直接使用因为它们是零值。第四步JVM对对象头进行必要设置包括这个对象是哪个类的实例、对象的哈希码、对象的GC分代年龄等信息。第五步执行init方法按照代码中的初始化逻辑设置字段的初始值。这整个流程背后有个问题在并发环境下多个线程可能同时分配对象指针碰撞就冲突了。JVM用了两个方案解决一个是CAS加失败重试保证更新操作的原子性另一个就是下面的TLAB方案。2.2 TLAB线程本地分配缓冲区TLABThread Local Allocation Buffer是JVM在Eden区为每个线程划分的一块私有缓冲区。对象分配时线程优先在自己的TLAB里分配TLAB用完或者对象太大放不下时才去共享的Eden区域分配。这样做有什么好处想一下如果没有TLAB每次分配对象都要保证线程安全CAS操作是有开销的。有了TLAB之后大部分小对象的分配都是线程私有的不需要同步分配效率大幅提升。你可以通过-XX:UseTLAB开启-XX:TLABSize设置TLAB大小。这里有个实际经验如果线上系统创建的线程非常多、每个线程创建的对象又很多TLAB太小会导致频繁的TLAB重分配反而影响性能。但手动调整TLAB大小是个精细活没有性能压测数据支撑不要乱动。2.3 新生代到老年代对象是怎么晋升的大部分对象都在Eden区出生经过垃圾回收后存活下来的对象会被移动到Survivor区。当对象在Survivor区熬过了一定次数的Minor GC默认15次就会被晋升到老年代。每次GC后对象的年龄加1这个年龄记录在对象头里。但晋升不只靠年龄这一条路有几种特殊情况大对象直接进入老年代。可以通过-XX:PretenureSizeThreshold设置一个阈值超过这个大小的对象直接在老年代分配。这样做是为了避免大对象在Eden和Survivor区之间反复复制消耗大量内存空间。但要注意这个参数只对Serial和ParNew收集器有效。动态年龄判定。JVM并不强制要求对象年龄达到15才晋升如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代无需等满15次GC。空间分配担保。进行Minor GC前JVM会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果大于Minor GC是安全的如果不大于就检查HandlePromotionFailure设置看是否允许担保失败。如果允许继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小如果大于就尝试Minor GC否则改为Full GC。2.4 Minor GC、Major GC与Full GC这三个概念面试必问我见过好几个人把Major GC和Full GC混为一谈。简单来说Minor GC清理新生代频率高、速度快。Major GC清理老年代通常伴随Minor GC。Full GC清理整个堆包括新生代、老年代和元空间。Full GC的成本最高停顿时间最长是调优时最想避免的事情。触发Full GC的常见原因包括老年代空间不足、元空间空间不足、System.gc()被显式调用虽然它只是一个建议但很多情况下JVM真的会执行Full GC、CMS的Concurrent Mode Failure等。线上环境的Full GC次数是需要重点监控的指标如果频繁出现系统响应时间一定会出现明显波动。3. 垃圾回收的底牌对象生死是怎样判定的垃圾回收要做的第一件事是判断哪些对象是垃圾。这看起来很简单实际上是个非常严谨的问题。判断对象是否存活主流方案是可达性分析但在早期JVM也用过引用计数法。3.1 引用计数法简单但不靠谱引用计数法的思路是给对象添加一个引用计数器每当有一个地方引用它计数器加1引用失效计数器减1计数器为0的对象就是垃圾可以回收了。实现简单、判定高效但它有个致命缺陷——无法解决循环引用问题。假设对象A引用了BB也引用了A除此之外这两个对象没有其他任何引用。它们的引用计数器永远不为0但它们在逻辑上已经是死对象了。这套方案被主流JVM抛弃了。3.2 可达性分析从GC Roots出发现在的HotSpot虚拟机用的是可达性分析算法。思路是从一组称为GC Roots的根对象出发通过引用链向下搜索搜索过程中走过的路径称为Reference Chain。如果一个对象到GC Roots没有任何引用链相连说明这个对象不可达可以被判定为可回收对象。可以作为GC Roots的对象包括虚拟机栈中引用的对象也就是正在执行的方法里的局部变量方法区中类的静态属性引用的对象方法区中常量引用的对象本地方法栈中JNI引用的对象JVM内部的引用如基本数据类型对应的Class对象、常驻的异常对象、系统类加载器等被同步锁synchronized持有的对象这一段看起来偏理论但排查内存泄漏的时候非常有用。你把堆转储文件导出来后用MAT或者JProfiler看对象到GC Roots的引用路径立刻就能定位到底是谁钉住了这些对象让它们无法被回收。这个过程后面讲OOM排查时还会回到这里。3.3 四种引用类型强、软、弱、虚在JDK 1.2之后Java把引用分成了四种引用类型的强弱直接决定了对象的回收时机。强引用是最普通的Object obj new Object()这种写法只要强引用还存在垃圾收集器永远不会回收被引用的对象。这也是最常见的内存泄漏原因——一个对象还被强引用持有但它已经用不到了。软引用用来描述一些还有用但非必须的对象。在系统将要发生内存溢出异常之前JVM会先把软引用关联的对象列入回收范围进行第二次回收如果这次回收后内存还是不够才会抛出OOM。软引用非常适合做缓存比如图片缓存、网页缓存可以在内存紧张时自动释放一部分缓存对象。弱引用比软引用更弱只能生存到下一次垃圾收集之前。当垃圾收集器工作时无论内存是否充足都会回收掉只被弱引用关联的对象。WeakHashMap就是利用弱引用实现的它的典型应用场景是缓存数据但要注意弱引用被回收后WeakHashMap的Entry里value还残留着强引用的话还是会造成内存泄漏。虚引用最弱一个对象是否有虚引用的存在完全不会对其生存时间构成影响也无法通过虚引用来获取一个对象实例。它唯一的用途是在对象被收集器回收时收到一个系统通知主要用来实现堆外内存的回收。3.4 经典回收算法标记-清除、复制、标记-整理判定对象可回收之后接下来就看怎么回收了。业界有三种经典的回收算法它们各有优缺点现代垃圾收集器都是它们的组合或变体。标记-清除算法是最基础的算法分为标记和清除两个阶段。先标记出所有需要回收的对象标记完成后统一回收。它的缺点是效率不高而且清除之后会产生大量不连续的内存碎片空间碎片太多会导致以后分配大对象时没有足够连续内存而提前触发GC。复制算法把内存按容量划分为大小相等的两块每次只使用其中一块当这一块的内存用完了把还存活的对象复制到另一块上面然后直接把已使用的那块内存整块清理掉。这样每次都是对半个区域进行内存回收分配内存时也不用考虑碎片问题。缺点是可用内存变为原来的一半空间浪费比较明显。当前商业虚拟机的新生代回收采用的就是复制算法但并不是按1:1划分而是把Eden和两个Survivor区按8:1:1的比例划分每次把Eden和一块Survivor中存活的对象复制到另一块Survivor上这样就只浪费10%的空间。当Survivor空间不够时需要依赖老年代进行分配担保。标记-整理算法适合老年代场景。标记阶段和标记-清除一样但后续不是直接清理而是让所有存活对象向一端移动然后直接清理掉端边界以外的内存。这样做解决了碎片问题但移动对象意味着要更新所有指向这些对象的引用会有额外的开销。4. 主流垃圾收集器从Serial到ZGC的演进算法是理论方案垃圾收集器是理论落地的工程实现。各款收集器的区别本质上是在延迟停顿时间和吞吐量之间做取舍。4.1 Serial与Parallel最朴素的年代Serial是最基础的单线程收集器进行垃圾收集时必须暂停所有工作线程也就是Stop The World。它的优点是简单高效对于单核CPU或者客户端模式下的JVM来说没有线程切换开销反而更高效。Parallel收集器也叫吞吐量优先收集器它的设计目标是达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间-XX:GCTimeRatio直接设置吞吐量大小。实际使用中Parallel是JDK 8默认的新生代收集器Parallel Scavenge Parallel Old很多跑批任务的系统至今还在用它。4.2 CMS并发收集的先行者CMSConcurrent Mark Sweep是一款以获取最短回收停顿时间为目标的收集器基于标记-清除算法。它的运作过程分为初始标记、并发标记、重新标记、并发清除四个步骤其中初始标记和重新标记仍然需要Stop The World但耗时很短耗时最长的并发标记和并发清除阶段都可以和用户线程一起工作。CMS有两个比较让人头疼的问题。第一个是它无法处理浮动垃圾可能会出现Concurrent Mode Failure进而触发一次Full GC。解决办法是预留一部分空间给用户线程使用-XX:CMSInitiatingOccupancyFraction可以设定老年代使用率达到多少时触发CMS GC预留空间可以缓解这个问题。第二个问题是空间碎片因为是标记-清除算法长期运行会产生碎片之后可以通过-XX:UseCMSCompactAtFullCollection在Full GC时进行碎片整理。JDK 9开始CMS被废弃JDK 14正式移除但大量老项目还在用它你面试时候说自己在维护老项目时还在用CMS是完全合理的。4.3 G1区域化分代的集大成者G1收集器是JDK 9之后的默认收集器。它把整个堆划分成多个大小相等的Region虽然还保留了新生代和老年代的概念但新生代和老年代不再物理隔离它们都是一部分Region的集合。G1通过追踪每个Region的回收价值和回收所需时间维护一个优先列表每次根据允许的收集时间优先回收价值最大的Region这就是Garbage First这个名字的由来。G1相比CMS最大的改进是通过基于Region的内存布局和局部回收的设计做到了可预测的停顿时间模型。同时它使用的整体上是标记-整理算法不会产生碎片。判断一个大对象能否存进某个Region时G1有一个特殊的Humongous区专门存放超过Region容量一半的大对象连续多个Humongous Region存放更大的对象。G1真正用好的关键在于参数调优。核心参数包括-XX:MaxGCPauseMillis目标停顿时间默认200ms、-XX:G1NewSizePercent新生代初始占比默认5%、-XX:G1MaxNewSizePercent新生代最大占比默认60%、-XX:G1HeapRegionSizeRegion大小默认由JVM自动计算。实际调优时要根据应用的实时吞吐量和延迟要求来调整这些参数不能盲目照抄网上的配置。4.4 ZGC与Shenandoah超低延迟的探索ZGC的目标是把GC停顿时间控制在10毫秒以内而且无论堆多大停顿时间都不受影响。它基于Region内存布局引入了染色指针和读屏障技术。染色指针把部分信息直接编码在指针上这要求堆内存的起始地址必须对齐而且不支持32位平台。ZGC的读屏障会在程序访问对象时参与进来通过判断指针上的标记信息来决定是否需要处理这个过程大多在用户态完成因此大幅减少停顿。Shenandoah是OpenJDK开源的一个低延迟收集器也是第一款由非Oracle团队开发的收集器来自RedHat目标和ZGC类似。两者的实现思路存在一定差异但对应用来说选哪个更多看所在JDK版本和操作系统是否支持。4.5 收集器选型建议根据我的实际经验如果系统对延迟没有极致要求、追求吞吐量Parallel是比较稳妥的选择尤其是跑批处理任务时。如果系统是Web服务、对响应时间有要求G1是目前最均衡的默认选择。如果堆内存超大几十GB甚至上百GB、要求延迟极低ZGC值得考虑但需要容忍它更高的CPU占用和可能的兼容性问题。不要轻易相信XX收集器性能最强的说法不同场景、不同内存规模下最优收集器完全不同。最好用压测工具模拟真实流量对比不同收集器下的GC日志和响应时间用数据说话。5. JVM内存参数调优一份可以照抄的配置清单参数调优是Java开发者绕不开的实践。这里整理一份常用配置清单附上说明和推荐值你可以直接参考着用但所有数值都要结合自己系统的实际情况调整。5.1 堆内存参数基础的堆内存参数有三个-Xms设置堆的初始大小-Xmx设置堆的最大大小-Xmn设置新生代大小。一般建议-Xms和-Xmx设为相同值避免堆扩容带来的性能损耗。新生代大小通常设置为堆的1/3到1/4具体看对象的生命周期分布。-XX:MaxMetaspaceSize设置元空间最大值如果系统动态生成大量类比如热部署频繁、反射频繁这个值要给足否则可能会抛出Metaspace OOM。注意元空间用完和堆OOM不完全一样排查时需要区分。5.2 GC日志参数JDK 8时代的GC日志参数和JDK 11以后有变化。JDK 8常用的是-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC -Xloggc:/path/to/gc.logJDK 11以后统一了日志配置-Xlog:gc*:file/path/to/gc.log:time,uptime,level,tags -Xlog:gc*:/path/to/gc.log:time,uptime,level,tagsGC日志是定位内存问题的第一手资料里面可以看到每次GC的原因、耗时、回收前后各区域的使用情况。我不会想当然地说日志不重要实际上排查任何一个线上内存问题我做的第一步永远是打开GC日志看Full GC的频率和触发原因这一步能筛掉一半以上的问题。5.3 OOM日志参数-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath/path/to/dump.hprof这两个参数一定要加上。当发生OOM时JVM会自动把当前堆内存快照导出到指定路径这是后续分析的关键数据。很多同学线上环境没加这个参数出了OOM之后只能靠猜效率极低。另外-XX:OnOutOfMemoryError参数可以指定一个脚本路径OOM时自动执行脚本比如自动重启服务或者发送告警这在无人值守的线上环境里很实用。5.4 一个参考配置示例这里给出一个中等流量Web服务的参考配置假设物理内存8GB-Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/var/log/jvm/dump.hprof -Xlog:gc*:/var/log/jvm/gc.log:time,uptime,level,tags堆设4GB是给系统其他进程留了余量新生代2GB让大部分短期对象在新生代就完成了回收。G1目标停顿100ms对绝大多数Web服务来说体感很好。这个配置不是最优解但是一套合理的起步配置后续根据监控数据再做微调。6. 内存泄漏与OOM一次完整排查实战到这一节来点真正实战的东西。假设你在线上遇到一个OOM日志显示java.lang.OutOfMemoryError: Java heap space你该怎么一步步排查。6.1 一个典型的内存泄漏示例先看一个典型的泄漏场景public class LeakDemo { private static final Listbyte[] CACHE new ArrayList(); public void addData() { // 模拟缓存数据不断积累 CACHE.add(new byte[1024 * 1024]); // 每次添加1MB } }这段代码中CACHE是静态变量生命周期和JVM一样长。每次调用addData()都会向List中塞1MB数据日积月累堆内存就会被耗尽。这就是最常见的内存泄漏——对象被静态容器持有永远不会被回收。6.2 排查步骤用MAT定位泄漏源头第一步确认OOM类型。Java heap space说明堆空间不足Metaspace说明元空间不够unable to create new native thread说明操作系统线程数到了上限和堆无关。不同类型的OOM排查方向完全不同。第二步拿到堆转储文件。如果之前配置了HeapDumpOnOutOfMemoryErrorOOM后自动生成的hprof文件就是排查的起点。如果没有只能通过jmap -dump:formatb,file/path/to/dump.hprof pid手动导出但注意导出过程对线上性能有影响谨慎操作。第三步用MATEclipse Memory Analyzer打开堆转储文件。MAT会自动分析出Leak Suspects也就是最可能导致OOM的对象和它们的GC Roots引用链。点击查看Dominator Tree可以让大对象一目了然。我遇到过一个真实案例一张报表查询每次执行都会往一个静态HashMap里塞查询参数接口没人调用了但Map里的数据一直在增长MAT一看Dominator Tree一个HashMap占了几百MB顺藤摸瓜就找到了代码里那个只增不减的静态Map。第四步分析完定位到具体的业务代码后修复代码逻辑。修完重新压测确认内存水位稳定再发布上线。6.3 除了堆OOM这几种内存问题也很常见栈溢出StackOverflowError通常是无限递归导致的比较极端的场景是JSON序列化时对象的循环引用A对象里有BB对象里有A序列化时无限递归栈先爆了。直接内存溢出表现为进程物理内存持续增长但堆内存和GC日志都正常最后进程被操作系统杀掉。常见原因是使用NIO或者Netty时堆外内存分配后没及时释放。排查方法是用NMTNative Memory Tracking查看本地内存占用情况启动参数加-XX:NativeMemoryTrackingdetail然后jcmd pid VM.native_memory查看明细。线程数过多导致的OOM报错是unable to create new native thread这通常不是内存不足而是操作系统限制的线程数被打满了。排查思路是查看ulimit -u用户最大进程数、/proc/sys/kernel/threads-max等系统参数同时检查代码里是否有线程创建后没有正确停掉的情况。6.4 避免内存泄漏的几个编程习惯第一警惕静态集合。静态容器配合add操作的场景一定要考虑对象生命周期明确什么时候remove。第二注意IO流、数据库连接、网络连接等资源的关闭推荐用Java 7之后的try-with-resources语法它会自动调用close。第三事件监听器和回调函数里如果被注册的对象比监听器活得久一定要提供反注册的方法。第四善用WeakReference、WeakHashMap缓存场景下尤其适用。7. 面试高频题速查这些坑你都踩过吗搜一下Java面试相关的热词排在前面的基本都绕不开内存这一块。这里整理几个高频题顺带把我踩过的坑也写进去供你自查。问题一Java对象创建的过程是什么面试官想听到的是从类加载检查、分配内存、初始化零值、设置对象头到执行init方法的完整链路而不是一句new出来的。答的时候能带上指针碰撞、空闲列表、TLAB这些关键字基本就过关了。问题二什么时候触发Full GC老年代空间不足是最常见的答案但完整的回答还包括元空间不足、System.gc()、CMS的Concurrent Mode Failure、堆外内存回收请求等。能说出-XX:CMSInitiatingOccupancyFraction参数和它解决的问题面试官会认为你确实调过参。问题三如何排查线上OOM这个一定要按步骤答先看GC日志和OOM错误类型然后通过HeapDumpOnOutOfMemoryError拿到堆转储用MAT分析泄漏嫌疑最后定位到GC Roots引用链上的业务代码。能顺带说出不要在生产环境手动jmap导堆这个经验非常有加分效果。问题四强引用、软引用、弱引用、虚引用的区别除了说清楚回收时机如果聊到软引用适用缓存、弱引用适用WeakHashMap再进一步说到虚引用用于堆外内存回收的通知机制这题就答透了。问题五G1和CMS有什么区别可以从内存布局Region vs 物理分代、停顿模型可预测 vs 不可预测、碎片问题标记-整理 vs 标记-清除、适用场景大堆 vs 中小堆四个维度回答。8. 常见问题与避坑指南最后给你一张速查表把日常工作中经常遇到的情况整理成一组对照直接照用就好问题现象常见原因排查方式程序运行一段时间后变卡Full GC频繁看GC日志统计Full GC次数和间隔内存占用持续上涨且不下降存在内存泄漏导出堆转储用MAT分析进程被操作系统杀掉物理内存耗尽可能与直接内存有关用NMT查Native Memory启动时报无法创建线程线程数达到系统上限查ulimit -u检查线程使用Metaspace OOM动态生成类过多适当调大-XX:MaxMetaspaceSize频繁Young GC但对象还在增长Eden设置的太小或对象生命周期太长调大新生代或者检查对象是否被过早晋升再补充两个容易被忽视的坑。第一个不要在代码里使用System.gc()尤其是老项目很多中间件监听了这个调用会触发Full GC线上性能会莫名其妙出现波动。第二个测试环境一定要模拟生产环境的JVM参数很多问题只在特定堆大小下才会暴露比如小堆下G1表现良好大堆下就会频繁并发标记失败。我自己的经验是处理内存问题最忌讳加内存这个万能解法。加内存只是把问题往后推迟了泄漏的代码不修复堆再大也有一天会爆。正确做法是把OOM当作一次事故复盘的机会先通过GC日志定位触发点再通过堆转储找到泄漏源修完代码后还要持续观察内存水位曲线确认问题真正解决。这篇文章写到这里涵盖了Java内存区域划分、对象分配与晋升机制、垃圾回收的判定和算法、主流收集器选型、参数调优配置以及OOM排查的完整路径。你在实际操作中如果遇到JVM内存相关的问题把这些内容当作一份checklist来对照排查会比直接搜报错信息高效得多。最后说一句JVM参数和垃圾回收器的选择没有银弹每一条配置决定都意味着某些场景的取舍真正理解原理、学会看日志和数据才是排查问题的底气。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进