JVM内存核心解析:堆、堆外、GC调优与线上排查实战 JDK自带的jmap -histo:live有时会把线上服务卡住排查问题我用的是jhsdb jmap --histo --pid配合jstat做初步定位高危场景优先用Arthas的memory命令在线查看避免影响业务。1. 从一份真实崩溃报告讲起JVM内存到底分了几块说个我前阵子处理的线上事故。某个Java服务运行了大概三周突然某个下午开始频繁Full GC每次停顿超过5秒紧接着就抛出了java.lang.OutOfMemoryError: Java heap space。查监控发现堆内存使用率从60%一直爬到了99%GC日志里老年代回收效果几乎为零。当时团队里几个同事的第一反应都是堆不够了加内存但我拦住了——因为从现象看老年代一直回收不掉对象这大概率不是堆大小的问题而是有对象被某个全局容器引用住了典型的内存泄露。这件事最终排查下来确实是个缓存Map只往里put、从不清理导致的但整个排查过程让我意识到一个很扎心的事实很多开发者对JVM内存的理解停留在堆和栈这两层一旦遇到内存问题除了-Xmx就是-Xms,连堆外内存、元空间、直接内存这些概念都模糊不清。这种认知水平别说调优了连定位问题都费劲。所以这篇文章我想系统性拆一遍JVM内存。不整那种教科书式的罗列就按我实际排查问题的思路来先搞清每个区域是干什么的、参数怎么控制、对象怎么流动再讲堆外那些容易忽略的地方然后是垃圾回收怎么配合最后是诊断手段。内容主要面向写了两三年Java、想真正搞懂内存这块的开发者也适合准备JVM面试的人。2. 堆内存的内部结构与对象分配路径2.1 堆里的三个行政区Eden、Survivor、老年代堆内存是所有线程共享的一块区域几乎所有的对象实例都在这里分配。但堆不是一个大平地而是被分成了两个大区新生代Young Generation和老年代Old Generation新生代内部又细分为Eden区、Survivor From区、Survivor To区也就是常说的S0和S1。为什么这么分核心思路是分代假设——绝大多数对象的生命周期极短创建之后很快就不再被引用了。比如一个方法里的临时对象、循环里的中间变量可能存活几毫秒就死了。把这样的对象集中放在新生代用复制算法做GC只需要把少数存活对象搬到另一个Survivor区剩下的区域直接整体清空。复制算法有个前提就是存活对象少这正好和新生代的特点匹配。Eden区还有个容易忽略的点几乎所有对象都先分配到Eden区而不是直接进老年代。只有一种情况例外——大对象可以直接进老年代。这个阈值由-XX:PretenureSizeThreshold控制默认是0也就是说默认不开启直接分配。如果设置了比如-XX:PretenureSizeThreshold10485761MB那么超过1MB的对象会直接进老年代。这么做的原因是大对象在新生代里做复制搬迁成本太高一个10MB的数组在Eden区死了之后它的空间就是一次YGC里的一个大窟窿不如直接扔老年代让大对象占连续空间。Survivor区的存在很多人不理解为什么不能Eden死了就直接把存活对象放老年代因为YGC的频率很高很多对象其实死而复生在几次GC之间一直存活。如果每次都晋升老年代老年代很快就会被填满Full GC就频繁了。Survivor区起的是缓冲带作用让对象在S0和S1之间来回倒腾几次扛过几次YGC之后再晋升。默认比例是Eden:S0:S1 8:1:1由-XX:SurvivorRatio控制默认值是8。这里有个经典的坑如果把SurvivorRatio调成2Eden占比就只有50%YGC频率会明显上升反而得不偿失。2.2 对象晋升老年代的四条路径对象从新生代进入老年代不是随便定的有四条明确的路径。理清这四条路径看GC日志时才不会一头雾水。第一条是年龄阈值晋升。每个对象有一个年龄计数器每经过一次YGC且存活下来年龄就加1。默认当年龄达到15时晋升到老年代这个值由-XX:MaxTenuringThreshold控制。15这个数字其实和HotSpot实现在对象头里用4个bit标记年龄有关最大就是15再大就溢出了。第二条是动态年龄判定。这个规则很多书上一笔带过但其实很关键在Survivor区中所有对象的年龄从小到大累加当某个年龄对应的对象总大小超过Survivor空间的一半时年龄大于等于这个值的对象将直接进入老年代不需要等到15岁。举个例子如果S0区一共64MB其中年龄为1的对象总共占了40MB超过了32MB的一半那么下一次YGC时所有年龄1的对象都会直接晋升老年代。这个机制的目的是防止Survivor区被长期存活的钉子户撑爆。第三条是大对象直接分配。刚才说过超过PretenureSizeThreshold的对象直接进老年代不走新生代。这条路径和第一条的区别在于它不是熬到老而是大到被迫进老。第四条是YGC后Survivor区装不下。如果一次YGC结束后存活对象总大小超过Survivor区的容量多出来的部分会直接溢出到老年代这个也叫分配担保。HandlePromotionFailure这个参数在JDK 6之后就基本废了因为规则已经改成只要老年代连续空间大于等于新生代对象总大小或者历次晋升的平均大小就直接进行担保分配。2.3 堆参数到底怎么设一次推算过程网上流传的各种堆参数推荐什么-Xmx设成物理内存的1/4-Xmn设成堆的1/3其实都是经验值真正要设好必须结合自己的业务场景算一遍。我自己的推算方法是这样的先看业务的并发量和对象分配速率。假设一个订单服务每秒处理2000个请求每个请求创建的对象总量大约是50KB这个数可以用jstat -gc里的Alloc Rate算出来也可以用-verbose:gc配合-Xlog:gc*看分配速率那么每秒分配出去就是100MB左右。如果希望YGC大约5秒一次新生代容量就得有500MB。按Eden:S0:S18:1:1来算新生代总大小就是500/0.8625MB。再假设一次YGC之后最终晋升老年代的对象大约占存活对象的10%也就是每次大约有5MB晋升那么老年代至少要扛住30分钟到一个小时的晋升量不然Full GC太频繁。30分钟是5MB×18009GB所以老年代至少9GB。加上新生代625MB堆总大小大约10GB加上元空间等非堆部分-Xmx可以定在11GB左右。这个推算过程当然不精确但比看心情设可靠得多。关键是要理解堆大小不是一个孤立值它直接决定YGC和Full GC的频率而GC频率直接决定响应时间。堆设小了GC频繁停顿多堆设大了单次GC的活多单次停顿时间长。这是个跷跷板没有最优解只有最适合你的业务场景的解。3. 堆外内存与直接内存最容易被忽略的雷区3.1 直接内存是什么它和堆内存有什么本质区别堆外内存这个说法其实比较笼统严格来说包括两块一块是JVM自己管理的非堆区域比如元空间、JIT编译器内存、线程栈等另一块是通过ByteBuffer.allocateDirect()分配的直接内存Direct Memory也叫堆外缓冲区。很多人把堆外内存和直接内存直接画等号严格来讲是不准确的但日常交流中这么叫也没太大问题。直接内存最本质的特点是它不受JVM垃圾回收的直接管辖而是由操作系统直接管理。-XX:MaxDirectMemorySize这个参数控制了它的上限默认值实际上等于堆大小。我见过很多服务堆内存明明还有1GB空闲却抛出了OutOfMemoryError: Direct buffer memory就是因为直接内存被吃满了。为什么会有直接内存这种东西核心原因是要解决拷贝的问题。假设用传统FileChannel做网络传输一次read/write操作是这样的磁盘把数据读入内核缓冲区再拷贝到JVM堆内的一块内存然后在用户态和内核态之间切换几次最终写出去。这个过程中发生了两次内存拷贝。而如果使用了直接内存数据直接从内核缓冲区映射到直接内存里再通过sendfile或者writev这类系统调用从直接内存发出去省掉了堆内堆外之间的一次拷贝。在高性能网络编程、消息中间件、日志采集、NIO框架里这个效率差异非常明显。Netty这个框架就是直接内存的超级重度用户。它的PooledDirectByteBuf直接封装了直接内存的池化分配EpollEventLoop配合sendfile做零拷贝传输。如果你用过Netty会发现在高并发场景下它的内存占用构成非常特殊堆内存可能只有几百MB但堆外内存可以占到好几个GB。这也是为什么线上排查内存问题绝对不能只盯着堆一定要把MaxDirectMemorySize和Native Memory Tracking一起看。3.2 元空间永久代换了个活法JDK 8把永久代PermGen换成了元空间Metaspace这个改动很多开发者的理解是永久代改了个名字这其实差得远。永久代是堆的一部分受堆内存大小限制里面装了类的元数据、静态变量、常量池等。它有个著名的问题就是永久代会满一旦类的元数据太多就会抛java.lang.OutOfMemoryError: PermGen space。像频繁地动态生成代理类、大量使用反射、热部署场景下反复加载类都是PermGen的常见杀手。元空间用的则不再是堆内存而是本地内存Native Memory也就是操作系统给进程分配的内存。这意味着它的大小上限默认情况下只受物理内存限制不再受堆大小约束。对应的参数是-XX:MaxMetaspaceSize默认是无限大实际上受进程可寻址空间限制。元空间的这种设计带了个双刃剑效果好处是类加载器泄漏不再那么轻易导致OOM坏处是如果类加载器本身设计有缺陷比如每次热部署都重新创建ClassLoader但旧的没释放元空间会像黑洞一样持续吞内存直到把物理机器内存吃垮。判断元空间出了问题一个典型特征是机器可用内存持续下降但是JVM堆内存曲线很平稳GC日志也很正常最后系统被OOM Killer处理。这个场景我遇到过不止一次。诊断的时候可以用jstat -gc看MC和MU这两列或者用jmap -clstats看类加载器的统计。3.3 堆外内存的回收机制与两个经典坑堆外内存怎么回收这是理解它整个生命周期的关键。DirectByteBuffer对象本身是堆上的对象但它背后引用的是堆外内存。当这个DirectByteBuffer对象被GC回收时它会触发Cleaner机制——Java的Cleaner是一个清洁器在被回收对象被判定不可达之后cleaner回调用run()方法释放对应的堆外内存。换句话说堆外内存的释放依赖于堆上的关联对象被GC回收但堆外内存本身不被GC遍历所以无法在GC发生时就同步释放。这就引出了第一个经典坑堆外内存泄露。如果你在代码里大量分配DirectByteBuffer但持有了很长生命周期或者分配速度远大于释放速度堆外内存就会被吃光。更恶心的是它不像堆内OOM那么明显经常是进程直接挂掉OS日志里出现Out of memory: Kill process。排查方法是用Google的gperftools或者JDK自带的Native Memory Tracking启动参数加-XX:NativeMemoryTrackingsummary然后jcmd的VM.native_memory命令打印分类明细。第二个坑和GC频率有关。因为堆外内存是堆上的关联对象触发清理的如果这个关联对象一直没有被GC碰到堆外内存就无法被释放。举个例子Netty的ByteBuf如果忘记release()堆外内存就会持续涨堆内存却看起来正常。这也是为什么写Netty代码时一定要用try-finally或者ReferenceCountUtil.release()来保证每次消费完都释放。我见过一个项目Netty的堆外内存两周涨了6GB排查后发现就是某条handler链里漏了一次release。4. 内存问题诊断从现象到根因的排查路径4.1 三类ООМ症状与它们的真实含义OutOfMemoryError不是一个错误而是一族错误不同类型的OOM对应的根因完全不同。把类型认错排查方向就全错。java.lang.OutOfMemoryError: Java heap space是最常见的一种标识堆空间不足。但堆空间不足有两种情况一种是堆真的不够大比如一个对象大小超过堆最大值或者项目启动时-Xmx就没设够另一种是内存泄露有根引用链一直拽着对象不让GC回收堆越来越满。区分这两者要看GC日志里Full GC是否还能回收。堆真的不够时Full GC之后堆使用率会降下来一些只是很快又涨满内存泄露时Full GC基本回收不了多少使用率持续90%以上不回头。java.lang.OutOfMemoryError: Metaspace是元空间不足。反思的维度包括是不是反射、动态代理、CGLIB生成的类太多了是不是存在类加载器泄漏反复redeploy不释放。java.lang.OutOfMemoryError: unable to create new native thread是线程数量达到OS限制。这个其实和堆关系不大它说明进程已经无法再创建新的原生线程。Linux下每个线程会占用一小段栈空间默认1MB。如果-Xss设得过大或者代码里无节制创建线程且不归还就会出现这个错误。排查时注意看ulimit -u用户最大进程/线程数和/proc/pid/status里的Threads字段。还有一种很冷门的是Requested array size exceeds VM limit说明你试图分配一个超出JVM支持的数组长度上限的数组。这个上限大概是Integer.MAX_VALUE - 8左右很少有业务会踩到但写工具类或者读取超大文件时偶尔会出现。4.2 诊断工具链的正确搭配我在线上排查内存问题时习惯按这样的顺序组合工具而不是一把梭第一步是jstat。jstat -gc pid 1000每秒打一次GC信息重点看YGC、FGC次数、FGCT耗时和O区使用率老年代。这一步3分钟内就能判断有没有Full GC频繁、老年代是否在涨是热身用的。第二步是jmap。如果判断可能有内存泄露用jmap -dump:live,formatb,fileheap.bin pid导堆快照。但注意两个事一是-dump:live会触发一次Full GC在堆很大的服务上可能卡很久一般凌晨低峰期操作二是堆文件动不动几个GB导出前确认磁盘够用。第三步是用MATMemory Analyzer分析堆文件。MAT的Leak Suspects报告非常实用它会自动找出可疑的根引用链比如某个静态Map或者ThreadLocal把一堆对象堵住了。我一般先看这个报告再手工看Dominator Tree找大对象经验是报告里列的前三个可疑点覆盖了大约70%的真实泄露案例。Arthas也是我线上排查的常备工具。它不需要提前配JMX端口通过Agent方式attach到进程用memory命令直接看堆、非堆、直接内存的使用情况用sct追踪线程的栈帧。尤其在不想导堆文件只想快速验证是不是哪个容器把对象堆死了的场景Arthas的vmtool和ognl命令能直接访问某个对象的内部字段做验证比导dump快一个量级。4.3 一次完整的内存泄露排查实录分享一个真实案例完整走一遍排查路径。某服务每隔几个小时就会Java heap space重启后恢复过几小时再次爆满。初步看GC日志Full GC后老年代使用率几乎没降基本排除堆不够大的可能锁定了内存泄露方向。第一步用jstat -gc看了10分钟老年代从1.2GB涨到1.8GB稳定上升不回吐这验证了泄露判断。第二步用jmap -dump:live导了一个4GB的堆文件。第三步MAT打开堆文件Leak Suspects报告里列出一个可疑项java.util.HashMap的实例数量异常多占用了大概2.3GB内存被一个ThreadLocal引用链持有。进一步看Dominator Tree发现这些HashMap的key全是订单号字符串value是一个带大量字段的对象再追踪类的引用关系发现是某个定时任务每隔5分钟就把一批订单状态对象塞进一个ThreadLocal作用域的缓存里但这个缓存的设计初衷是同一个请求线程内复用定时任务却是在一个固定线程池里跑的导致线程池里的线程持有这些对象一直不释放。修复方式很简单把这个ThreadLocal缓存改成用完即清除的局部变量或者定时任务结束后remove()。但有意思的是那个Map为什么一直不释放——因为ThreadLocal里的Map是线程私有的如果不是用完即清它会跟随线程的生命周期。线程池里的核心线程不销毁Map里的对象就永远被强引用。这个坑太典型了。5. GC调优实战在快与稳之间找平衡5.1 先搞清楚调优目标再动手很多人一上来就问怎么调GC参数但这个问题的前提是你得先回答你调的目的是什么。GC调优无外乎三个目标一是降低GC停顿时间Latency二是提高吞吐量Throughput三是减少内存占用Footprint。这三个目标互相制约不可能同时做到最优——把堆调大可以降低GC频率从而提高吞吐但单次GC的清扫范围变大停顿时间和占用的系统资源也会上升反过来用小堆可以缩短单次GC时间但GC频率暴涨总耗时反而增加。所以正常的调优流程不是我拍脑袋改参数而是先压测量化现状。第二步定目标比如99%的请求延迟不超过200msGC停顿不超过100ms。第三步才是调参每次只动一个参数压测验证效果然后再动下一个。我见过最糟糕的调优行为是一次改了八个参数出了问题根本不知道是哪个参数引起的只能全部还原重来。5.2 垃圾回收器选型思路JDK 8默认是Parallel Scavenge Parallel Old的组合主打吞吐量。如果你的服务是大量CPU密集型的计算任务、批处理作业对单次停顿不太敏感这个组合其实挺合适的。但如果是互联网API服务响应时间敏感Parallel的停顿时间就很伤。CMS是JDK 8时代低延迟的扛把子但它的两个著名缺陷——内存碎片化和Concurrent Mode Failure——在堆大于8GB时会让调优变得极其痛苦。碎片化严重时会触发Serial Old作为后备直接STW大停顿。JDK 11之后G1逐渐成为主流。G1的设计理念是分Region管理把堆切成大小相等的Region每个Region可以扮演Eden、Survivor或者Old的角色。它的优势在于可以预测停顿时间-XX:MaxGCPauseMillis设了之后G1会尽量在限定时间内完成收集。但G1的可预测是有代价的如果堆太大或者并发标记跟不上对象变化速率G1的Full GC依然会退化回Serial GC停顿反而更吓人。ZGC是JDK 15之后真正实现几乎零停顿的收集器停顿时间一般在亚毫秒级别。它通过染色指针和读屏障实现了并发转移但代价是CPU开销比较高内存占用也比G1多出约10%-20%。如果业务对延迟有极致要求且CPU有多余余量ZGC是值得冒险尝试的但绝大多数业务G1已经够用了。我的选型经验是这样的参考框架堆小于4GB且吞吐量优先用Parallel堆在4GB到16GB之间且延迟敏感用G1并设好MaxGCPauseMillis通常设100ms起步堆大于16GB且硬件资源充足、延迟要求极高再上ZGC。这个框架不是绝对标准但避免了很多无意义的选型摇摆。5.3 调优参数解读与一次实战记录参数类的调优我挑几个被误解得最深的讲讲。第一个是-Xmn这是新生代大小。新生代越大YGC间隔越长但单次拷贝量变大新生代太小短命对象频繁进老年代老年代压力山大。一般建议Eden和Survivor的比例维持8:1:1不要为了看起来均衡而把手一抖改成5:5。第二个是-Xss默认1MB对普通业务线程栈完全够用。如果递归深度很深优先优化递归逻辑而不是盲目加大栈空间除非有明确的深度栈帧需求。第三个是-XX:UseStringDeduplicationG1特有的字符串去重对字符串占比大的堆有奇效能省下5%-15%的堆。但它只对已经存活的字符串做去重元数据开销也存在要压测确认收益大于开销再用。有一次我调一个数据处理服务堆设了8GB却总是YGC频繁平均每2秒一次。一开始怀疑堆太小但加堆之前先看了分配速率用jstat -gc算了一下每分钟的新生代分配量是18GB——这明显是对象分配速率过高不是堆的问题。再往代码里查发现有个JSON序列化工具在循环体内反复创建JsonParser每个请求序列化了20多次中间对象。修掉这个逻辑之后分配速率直接降到2GB/minYGC频率降为原来的1/9。这个案例我想说的是GC调优的第一顺位永远是减少对象的产生而不是调整回收策略。很多所谓调优最后都变成代码优化。6. JVM内存高频面试题与快速诊断速查表6.1 面试官最常问的十个问题这些年我既面试过别人自己也被面过很多次。围绕JVM内存面试官翻来覆去问的就是那么几个点但每个都能往深处挖。整理出来Java内存模型JMM和JVM运行时内存布局有什么区别这是第一道分辨题。JMM是一套关于多线程读写共享变量的抽象规则解决的是可见性、原子性、有序性JVM运行时内存布局指堆、栈、方法区等物理区域。两者经常被混用但其实完全不是一个层面。对象一定分配在堆上吗现代JVM做了逃逸分析如果对象不会被外部访问可能直接在栈上分配甚至完全消除对象。JDK 6之后默认开启了逃逸分析配合DoEscapeAnalysis。说说栈帧里有什么局部变量表、操作数栈、动态连接、方法返回地址。面试里常追问两个数相加的字节码执行过程中操作数栈怎么变化。怎么判断一个对象是死是活可达性分析GC Roots包括栈上局部变量、静态变量、JNI引用、活跃线程等。引用计数方案因为循环引用问题被HotSpot抛弃了。常见的GC Roots有哪些这个必须能背出来虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、JNI中引用的对象、活跃线程、锁持有的对象、JMX等内部引用。从GC角度讲一下CMS和G1的区别除了并发标记、回收阶段模式不同最好能提一下G1用Region和RSet做增量回收RSet记录别的Region对当前Region对象的引用避免全堆扫描。什么情况下会触发Full GC系统.gc()可通过DisableExplicitGC关、老年代空间不足晋升失败、元空间空间不足、CMS concurrent mode failure、G1的Humongous分配失败。怎么排查CPU飙高伴随频繁GC先用top看进程再用jstack看线程重点看GC线程和业务线程栈通常会发现某个线程在做大量日志拼接或者正则匹配导致短期对象激增再换jstat看回收曲线验证。什么时候用堆外内存而不是堆内内存答案是需要零拷贝传输时、缓存对象体积大且生命周期长导致GC压力大时、希望绕过堆限制时。但前提是必须能手工释放或依赖Cleaner机制否则宁可堆内。内存溢出和内存泄露有什么关系内存泄露是长期持有不再使用的对象引用最终导致内存溢出。但溢出不一定由泄露引起也可能是容量规划不足。6.2 一张速查表把排查工具串起来工具不在多关键是在正确的时候用正确的工具。我整理一张常用排查速查表贴墙上或存备忘录都挺实用场景命令/工具关键信息看GC整体情况jstat -gc pid 1000YGC次数、FGC次数、FGCT耗时、O区%看堆各区域占用jcmd pid GC.heap_info各区当前大小、Region数量看元空间jstat -gc pid中的MC/MU列类元数据容量和用量看线程栈jstack pid stack.txt或Arthas thread锁等待、死锁、栈内对象引用分析堆快照MAT /jhat更推荐MATLeak Suspects、Dominator Tree看Native Memoryjcmd pid VM.native_memory分类看JVM自身各内存区域使用在线定位大对象Arthas的vmtoolognl不导dump直接查看对象内部引用这张表也建议收藏起来真正线上出问题的时候现翻文档远不如靠肌肉记忆来得快。尤其jcmd这个命令很多人只在国内博客里见过但没实际用过它是JDK自带的诊断瑞士军刀集成了heap_info、finalizer_info、thread_print等大量子命令值得提前把help输出过一遍。7. 一些个人实操经验和避免踩坑的建议写到这里我发现JVM内存这个主题其实是个无底洞展开讲还可以讲对象头布局、偏向锁撤销、压缩指针、零拷贝、GC日志分析器、甚至Arthas源码但作为一篇系统梳理的文章到这里基本把主干说清了。最后再分享几条我个人这几年摸爬滚打出来的经验和教训希望对你有些启发。第一不要迷信默认参数更不要迷信别人的参数。JVM的默认值是在大多数场景下不至于出大问题的权衡下定的不代表适合你的业务。我曾经照抄过一个技术分享里优化后的参数组结果在自己的服务上越调越差。原因很简单他做的是一次性批处理任务我的是低延迟高并发在线服务诉求根本不同。调参之前一定先压测出基线数据让数据帮你做决策。第二GC日志是便宜又有效的体检报告从第一天就开起来。启动参数加上-Xlog:gc*:file/var/log/myapp-gc.log,fileSize20m:time,uptime,levelJDK 11或者JDK 8用-verbose:gc -Xloggc:/var/log/myapp-gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps。线上出问题时没有GC日志等于让侦探蒙着眼睛办案很多排查根本无从下手。等出了问题再想改配置加日志现场已经被污染了。第三多线程持有ThreadLocal一定要强制remove。前面那个内存泄露的案例本质就是ThreadLocal使用不当。我的习惯是在finally里写xxxThreadLocal.remove()这是死规矩不分场景。ThreadLocal本身不是泄漏的元凶但它常常是持有泄漏的元凶。尤其是线程池场景线程是复用的你不清它就会一直替一个早已结束的任务背着黑锅。第四压测才是检验调优效果的唯一手段。改完JVM参数不要只盯着本机跑一次正常流程觉得看起来不错就行。Concurrency级别的压测能暴露出GC停顿的真实影响比如用wrk或JMeter压出稳定流量同时开jstat观察GC曲线对比调优前后的P99延迟、GC频率和停顿时间。只有这些数据都验证过了才敢把参数推到生产环境。第五别把锅都甩给JVM。很多所谓JVM内存问题最后定位到应用层代码——一个没关的流、一个无限增长的List、一个保留全量历史的Session缓存。JVM只是忠实地执行了你定义的对象生命周期。所以排查内存问题时心里默念一句先怀疑代码再怀疑配置最后怀疑JVM本身。这个顺序帮我少走了很多弯路。JVM内存这块知识其实和实践是深度绑定的。光看书、看文章不实际动手去压测、去导一次dump、去分析一次GC日志总觉得隔层纱。找个自己的项目开上GC日志压一压再对着监控曲线看看自己设置的参数有没有达到预期跑两轮就有感觉了。后面如果遇到具体某个参数拿不准、或者某个GC日志看不懂只要能把你实际看到的现象和日志发出来我们可以再继续往深了折腾。