先说个场景。某天下午我刚把手头一个需求提测,线上告警群突然炸了:核心服务的Full GC频率从每小时几次飙到每10分钟一次,接口P99延迟从50ms涨到3秒,用户侧开始出现超时重试。当时团队第一反应是"加内存",堆从8GB加到16GB之后,Full GC倒是少了,但每次GC停顿时间反而更长,服务卡得更难受。那次之后我才真正意识到,JVM调优不是拍脑袋调参数,而是一整套从观测、定位到验证的闭环流程。
这篇东西我会把JVM调优从基础到实战完整串一遍,适合刚接触JVM的Java工程师、准备面试的开发者,以及做微服务和分布式架构时被线上GC问题困扰的同学。内容会覆盖运行时数据区到底怎么影响调优决策、垃圾收集器选型逻辑、从一台"卡死"的JVM开始怎么一步步排查,再加上两个线上典型案例复盘,最后聊聊分布式和微服务场景下JVM调优的特殊性。文章不会堆砌所有参数,只讲那些你真正用得上、并且理解了就能举一反三的部分。
1. 先把地基打牢:JVM调优到底在"调"什么
很多人一聊JVM调优就张口就是Xmx、Xms、G1、ZGC,但问一句"这些参数作用在哪个区域、影响了什么行为"就卡壳了。这不怪你——网上大部分文章都在讲"怎么设",很少有人讲"为什么设"。在动参数之前,先把运行时数据区的分工搞清楚,后面所有决策都会自然长出来。
1.1 运行时数据区:堆、栈、元空间各自管什么
JVM的内存按职责分成几块区域,但真正需要你日常关心的是三个:Java堆、虚拟机栈、元空间。
堆是最大的那块内存,几乎所有的Java对象实例都在这里分配。用一个仓库来类比:仓库里堆放着各种货物(对象),货物有生命周期,有的周转快(短命对象),有的常年放着(长命对象)。仓库空间有限,满了就得清理(GC)。调优的大部分工作,本质上都是在调整这个仓库的容量、分区分隔方式,以及清理策略。
虚拟机栈是线程私有的,每个线程执行方法时都会创建栈帧,栈帧里保存局部变量表、操作数栈、方法返回值等。栈的大小有限,方法嵌套太深会抛出StackOverflowError,这个一般不算调优主战场,但要清楚递归深度和线程数量会挤压内存。如果给每个线程的栈设成2MB,300个线程就占掉600MB内存,这部分是在堆之外的。
元空间在JDK 8之后取代了永久代,存放类的元数据、方法信息、常量池等。它不占用堆内存,默认使用本地内存,上限取决于操作系统。听起来"无限"很爽,但实际上类加载过多(比如频繁热部署、动态代理生成大量类)会把元空间撑爆,抛出OutOfMemoryError: Metaspace。所以生产环境一定要设置MaxMetaspaceSize,这不是可有可无的。
1.2 对象的分配与回收路径,决定了参数设置的依据
JVM把堆分成新生代和老年代,新生代内部又分成Eden区和两个Survivor区(默认比例是8:1:1)。新对象出生在Eden区,Eden满了触发Minor GC,存活下来的对象进入Survivor区,每经历一次Minor GC存活下来,年龄就加一,达到阈值(默认15)后晋升到老年代。大对象(比如很大的数组或字符串)不会走Survivor区绕路,直接在老年代分配。
这条路径和GC参数是直接绑定的:NewRatio控制新生代和老年代的比例,SurvivorRatio控制Eden和Survivor的比例,MaxTenuringThreshold控制晋升年龄阈值,PretenureSizeThreshold控制大对象阈值。这些参数不是随便拍脑袋定的,而是由你的对象分配特征决定的——如果你的服务大量产生短命对象,那新生代就得给足空间,避免对象过早晋升到老年代引发Full GC;如果你的服务偶尔会出现大对象,那要评估是否用PretenureSizeThreshold把它直接送进老年代,减少新生代拷贝开销。
Full GC是整个调优最怕遇到的事情,因为它会STW(Stop The World),暂停所有业务线程。老年代空间不足、元空间不足、或者调用System.gc(),都会触发Full GC。调优的目标,就是尽量让对象在新生代完成生老病死,减少对象晋升到老年代的频率,从而降低Full GC次数。
1.3 调优目标不是"不卡了",而是三个指标的权衡
JVM调优的终极指标有三个:吞吐量、停顿时间、启动时间。吞吐量是指CPU用于运行业务代码的时间占总时间的比例;停顿时间是GC导致的应用暂停时长;启动时间是指应用从启动到对外可服务的时间。
这三者不可兼得。追求低停顿时间,就得用并发收集器,它们在GC过程中并发执行一部分工作,但本身会占用CPU,导致业务吞吐量下降。追求高吞吐量,往往意味着更大的新生代、更长的GC间隔,但单次GC停顿时间会更长。启动时间则和堆大小、类加载数量、初始化逻辑相关。
调优之前,先问自己:这个服务的核心痛点是什么?如果是高并发交易系统,用户不能等,那停顿时间是第一优先级;如果是离线批处理任务,跑得越快越好,吞吐量是第一优先级;如果是微服务快速弹性扩容场景,启动时间是第一优先级。目标都不定义清楚,调出来的参数就是碰运气。
2. 垃圾收集器选型:别再"装了就完",选错收集器等于白调
上面搞清楚了内存长什么样,接下来就是谁来清理的问题。垃圾收集器选型是JVM调优中最有技术含量、也最容易被"跟风"带偏的一个环节。很多人一听G1好就上G1,一听ZGC新就上ZGC,结果在4GB堆的小服务上折腾半天,收益几乎为零。
2.1 主流收集器一览:从Serial到ZGC的演进脉络
JVM发展到现在,主流收集器就这么几个,搞清楚它们之间的差异,你就明白为什么会有选型问题了。
Serial是最古老的收集器,单线程GC,GC期间必须STW。它简单可靠,适合单核CPU、堆很小(几百MB)、对停顿时间无所谓的场景,比如一些客户端程序。Parallel(并行收集器)在JDK 8里是默认的,多线程并行GC,注重高吞吐量,适合批处理和科学计算这类追求吞吐的场景,但GC期间同样STW。
CMS是JDK 7时代的主流,全称Concurrent Mark Sweep,并发标记清除。它做到了GC线程和业务线程大部分时间并发执行,大幅降低了停顿时间,但它有两个经典问题:标记清除算法会产生内存碎片,碎片多到一定程度触发Full GC升代价严重;CMS并发失败(Concurrent Mode Failure)时也会退化为Serial Old做Serial全堆GC,那停顿直接上秒级。CMS在JDK 9被废弃,JDK 14被移除,生产环境不建议再用。
G1(Garbage First)是JDK 9之后的服务端默认收集器,它把堆划分成一个个Region,优先回收垃圾最多的Region,能做到可预测的停顿时间。G1同时兼顾吞吐量和低停顿,在4GB到32GB的堆上表现良好。ZGC是最新的低延迟收集器,目标是停顿时间控制在10ms以下,它通过染色指针、读屏障等技巧,让GC几乎不影响业务线程,适合超大堆(几百GB级别)和超低延迟场景。
2.2 收集器选型的三条判断标准
我的经验是,别看网上吹什么,只看你的堆大小、停顿目标和硬件资源。三条标准如下:
第一条,堆大小。如果堆在4GB以下,Parallel GC是首选,因为它的吞吐量最高,G1在小堆上的优势不明显,反而可能因为维护Region表增加开销。堆在4GB到32GB之间,G1最稳,这是G1的设计甜点区。堆超过32GB,ZGC是更合理的选择——超大堆用G1,Full GC的停顿时间会让人崩溃。
第二条,停顿时间目标。如果业务要求GC停顿不能超过10ms,那就直接考虑ZGC,G1很难做到这个级别。如果停顿容忍到100ms级别,G1完全够用。如果对停顿完全不敏感,Parallel GC性价比最高。
第三条,硬件资源。ZGC和G1都会占用更多CPU做并发标记、并发清理,如果CPU核数不多(比如只有2核4线程),并发收集反而会和业务线程抢CPU,导致吞吐量下降。这时宁可选择Parallel GC,用短暂停顿换吞吐量。
2.3 一个很常见的选型误区
我见过最多的选型错误,是小堆上强行上G1。有一个内部管理系统的例子,堆只有2GB,团队因为"G1是默认"就直接上了,结果GC停顿没降下来,CPU使用率反而上升了10%。后来改回Parallel GC,一切恢复正常。调优一定要记住:收集器没有绝对的好坏,只有匹配不匹配。堆小、对停顿不敏感、CPU资源有限的场景,Parallel就是最优解。堆大、延迟敏感的互联网后端服务,G1或ZGC才是应该考虑的。
还有个误区是以为用了G1就不需要关心老年代了。G1虽然把堆分成了Region,但逻辑上依然有新生代和老年代的概念,对象晋升逻辑没有变。该出现的Full GC还是会出现,只是在G1里叫Mixed GC(混合回收,回收部分老年代Region)和Full GC,后者依然会STW,要命的是混合回收失败的Full GC停顿时间通常不低。
3. 实战步骤:从一个"卡死"的JVM开始排查
工具和参数都聊完了,现在进入正题:线上出现GC问题,到底怎么一步步排查?我给自己总结了一套固定流程,每次遇到JVM问题都这么走,基本不会乱。
3.1 第一步:用工具看清现状,别靠猜
排查JVM问题的第一步不是改参数,而是看数据。JDK自带的命令行工具在关键时刻比任何可视化监控面板都靠谱,因为它们不依赖Agent,不会因为监控脚本本身出问题而失真。
jstat是第一个要用的工具,它是JVM统计信息工具,直接看GC情况。命令格式是jstat -gcutil <pid> <间隔毫秒> <次数>,输出S0、S1、E、O、M几个区的使用率,以及YGC(Minor GC次数)、YGCT(Minor GC总耗时)、FGC(Full GC次数)、FGCT(Full GC总耗时)、GCT(GC总耗时)。实战中我见过最典型的输出:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 31.20 45.30 96.20 97.10 95.20 1283 45.123 827 120.876 166.999这个输出代表什么?老年代使用率96.2%,元空间使用率97.1%,Full GC次数827次且总耗时120秒,平均每次145ms,但FGCT已经远超YGCT,说明这个JVM的瓶颈就在老年代和元空间。看到这种数据,方向就有了,不用瞎猜。
jmap用来查看堆内存的整体情况和对象统计。jmap -heap <pid>可以看到Heap内存各区域的最大值、当前使用量、参数配置;jmap -histo:live <pid>把存活对象按占用内存排序,前面几名基本就是问题线索;jmap -dump:format=b,file=heap.hprof <pid>导出一份堆转储快照,配合MAT或JProfiler做离线分析。注意,生产环境导出堆转储会暂停应用,务必在低峰期操作,或者用jmap -dump:live只导出存活对象,减小体积和影响。
jstack用来打印线程快照,看线程在干什么。Full GC期间业务线程普遍处于阻塞状态,线程栈上大量线程卡在等待锁或者等待GC;死循环的话能看到CPU跑满的线程栈;死锁的话能看到两个线程互相持有锁等待。配合top -Hp查看线程CPU占用,基本能定位代码层面的问题。
还有一个jcmd工具,集合了JVM大部分诊断功能,比如jcmd <pid> GC.heap_info、jcmd <pid> VM.command_line,可以替代部分jmap功能且更安全,新版JDK推荐优先用它。
3.2 第二步:读懂关键参数,把"想要的配置"写清楚
看清楚了现状,下一步才是调整参数。JVM调优参数非常多,但真正高频使用的就这么一组,我按功能分类讲清楚。
堆大小是最基础的。-Xms设置初始堆大小,-Xmx设置最大堆大小。生产环境强烈建议把两者设成一样,这样可以避免JVM运行时反复扩容和缩容的抖动。堆大小怎么定?不是"越大越好",要根据业务对象分配速率、GC频率和物理内存综合判断。我的经验公式是:先压测观察默认配置下的老年代增长速度,如果4小时老年代只涨到30%,那堆大小按当前值的2到3倍设就够了,留出峰值余量,又不浪费资源。
新生代相关参数里,-XX:NewRatio控制新生代和老年代的比例,默认是2(即新生代为堆的1/3)。如果业务对象绝大多数是短命的,把比例调成3或4,新生代占堆的一半甚至六成,能有效减少对象晋升老年代的频率。-XX:SurvivorRatio控制Eden和Survivor的比例,默认8(Eden占新生代的8/10),如果Minor GC后存活对象比例较高,可以调到6或4,给Survivor更多空间,避免对象因Survivor空间不足直接晋升老年代。
对象晋升参数里,-XX:MaxTenuringThreshold控制对象最多经历多少次Minor GC后晋升老年代,默认15。如果日志里显示对象在Age=1或Age=2时就大量晋升,说明Survivor太小或者阈值太低,需要调整。-XX:PretenureSizeThreshold控制大对象直接在老年代分配的阈值,值单位是字节,默认0表示不启用。举个例子,如果业务里有大量1MB以上的数组,而新生代Eden区只有256MB,每次Minor GC拷贝这个大对象代价很高,设置-XX:PretenureSizeThreshold=1048576让它直接进老年代更划算。
元空间参数,-XX:MetaspaceSize是触发元空间GC的初始阈值,-XX:MaxMetaspaceSize是元空间最大上限。前面说过,生产环境一定设置MaxMetaspaceSize,避免无限使用本地内存拖垮整台机器。常见配置是512M或1G,具体看应用类加载规模。
还有一个容易忽略的参数是-XX:+HeapDumpOnOutOfMemoryError,加上它,JVM在OOM时会自动导出堆转储文件,这对排查OOM问题是救命稻草。没有它,OOM现场一消失,你就等着靠猜排查了。
GC日志参数,不同JDK版本命令不一样。JDK 8用-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log;JDK 9以后统一用-Xlog:gc*:file=/path/to/gc.log。建议所有生产JVM都开启GC日志,不仅排查问题要用,日常监控GC频率的基线也靠它。
3.3 第三步:验证调整效果,不能只看"不那么卡了"
改完参数不是结束,验证才是关键。很多人调优以后说"感觉不那么卡了",这不行。要有数据,要能对比。
验证的第一个层面是GC日志数据。调整前后各跑一段压测,压测场景要尽可能模拟真实流量。对比YGC次数、FGC次数、平均停顿时间、最大停顿时间这几个指标,形成表格。比如调整前FGC每小时12次、平均停顿350ms,调整后FGC每小时2次、平均停顿80ms,这才叫有效的调整。
验证的第二个层面是业务指标。接口P99延迟、成功率、CPU使用率、内存使用率,这些要在监控面板上看到明确变化。GC指标变好了但业务指标没变化,说明你的调整方向可能错了——比如你降低了GC频率但增加了每次GC的时间,整体到业务上的效果被抵消了。
验证过程中有个重要原则:一次只改一个变量。如果同时改了堆大小、收集器、晋升阈值,出问题了不知道是哪个参数导致的,出正面效果了也不知道是哪个参数贡献的。一次只调一个,跑一段时间观察,再调下一个,这是调优的基本纪律。
4. 线上案例复盘:Full GC频繁和内存溢出的完整排查链路
理论讲再多,不如看两个真实案例。这两个案例是我在实际项目中处理过的,一个典型的老年代打满问题,一个典型的容器化环境OOM问题,各自的排查思路值得完整拆一遍。
4.1 案例一:8GB堆的老服务,Full GC每10分钟一次
现象:一个运行了两年的老服务,某天起FGC频率从每小时几次变成每10分钟一次,每次停顿时间150ms以上,接口成功率明显下降。
排查第一步,用jstat看了下GC情况,发现老年代使用率持续在95%以上,Full GC之后只能降到80%左右,然后很快又涨上去。这说明老年代里有东西一直在堆积,而且堆积速度很快。Minor GC次数其实不高,新生代压力不大,问题集中爆发在老年代。
排查第二步,用jmap -histo:live看了存活对象Top10,排在前面的除了正常的业务对象,还有一个自定义的缓存对象占了2GB多。顺着代码查,发现这是个静态集合类型的缓存,往里面写数据的入口没有设置过期时间,只增不减。业务高峰期每秒钟往缓存里塞大量数据,老年代自然被打满。
排查第三步,为了确认缓存的引用关系,导出了一份堆转储文件,用MAT打开看支配树,确认这个缓存对象确实被一个单例服务持有着,而且持有者已经把"清理"操作注释掉了。这部分是线上数据说话,不用猜。
解决方案分两步:代码层面给缓存加容量上限和过期淘汰策略,用LinkedHashMap的removeEldestEntry实现LRU,或者直接换成成熟缓存框架;JVM层面把堆从8GB调到12GB,给业务增长留出缓冲。上线后观察一周,FGC降到每天几次,接口P99从2秒回到60ms。这个案例的教训是:GC调优只能缓解症状,代码里的内存泄漏不修掉,参数调得再好也只是推迟爆炸时间。
4.2 案例二:容器里部署的Java服务频繁OOM
现象:一个微服务跑在Kubernetes集群里,容器内存限制设了1GB,但Java应用频繁OOM,日志显示Java heap space。运维第一反应是把容器内存限制调到2GB,结果OOM更频繁了。
这里有个非常经典的坑:JVM不会自动感知容器内存限制。如果启动参数里没有配置容器感知,JVM默认把物理机内存当作可用内存,然后按物理机内存的1/4来设置默认最大堆。在一台64GB物理机上,JVM默认最大堆可能算出来是16GB,但容器只允许用1GB,堆还没用满内存就被容器OOM Killed了,表现就是进程直接消失,或者抛出各种分配失败。
解决方法是显式告诉JVM容器限制。JDK 10之后JVM默认开启-XX:+UseContainerSupport,可以感知容器内存和CPU限制,但这只是"感知",实际还要配合比例参数使用。推荐的方式是不要设置固定Xmx,而是设置-XX:MaxRAMPercentage=75.0,意思是JVM最大堆使用容器内存的75%,留出一部分给元空间、线程栈和堆外内存。有同事习惯设成90%,觉得堆越大越好,但忽略了JVM除了堆还占其他内存,结果堆没打满,容器先OOM了,教训深刻。
案例里我们把-Xmx1024m删掉,改成-XX:MaxRAMPercentage=70.0,并调低MetaspaceSize上限到256M,同时减少线程栈大小到512K,一段时间的运行就稳定了。这个案例的教训是:容器化环境下,JVM调优的第一课是理解"你踩的内存地板是容器限制,而不是物理机资源"。
4.3 复盘时的几条通用原则
这两个案例放在一起,能总结出几条通用原则:
第一,先看数据,再猜原因。没有GC日志、没有堆转储之前不要动参数。我见过很多团队在问题还没定位时就把堆翻倍、换收集器,结果问题依旧,还丢了原始排查线索。
第二,堆转储是定位内存问题的银弹。OOM或者内存持续上涨时,导出一份堆转储,用MAT看支配树和泄漏报告,比自己翻代码高效得多。前提是启动参数里加上-XX:+HeapDumpOnOutOfMemoryError,让系统自动留档。
第三,参数调整是最后一步,代码和架构才是根本。绝大部分GC问题本质是代码问题:缓存无上限、数据库连接不关闭、一次性加载大量数据到内存、线程池创建过于频繁。修掉这些,参数不用调,问题自解。
5. 从单机到架构:JVM调优在分布式与微服务场景下的不同侧重
上面聊的场景基本都是单机视角的JVM调优,但现阶段大部分Java服务都跑在分布式架构里。容器化、服务拆分、多实例部署这些因素,会实实在在改变JVM调优的决策逻辑。很多人还拿单体时代的调优思路套微服务,效果自然好不了。
5.1 容器化给JVM调优带来的变量
容器改变了两个关键资源:内存和CPU。内存方面,前面案例二已经讲了,JVM需要显式感知容器限制,用-XX:MaxRAMPercentage替代固定Xmx。CPU方面,容器如果限制为2核,而JVM默认的GC线程数可能按宿主机核心数计算,比如宿主机32核,GC线程默认可能是8个甚至更多,这些线程在一个2核容器里互相抢CPU,GC效率反而下降。这时候需要显式设置-XX:ParallelGCThreads和-XX:ConcGCThreads,让GC线程数和容器的CPU配额匹配。
还有一点容易被忽略:容器内存限制看的是RSS(常驻内存),不只是堆。JVM的DirectBuffer、线程栈、Metaspace、Compressed Class Space都会占内存。我在实践中通常把MaxRAMPercentage控制在70到80之间,给非堆内存留足余量。
5.2 微服务拆分后,堆变小了反而更难调
微服务架构里,每个服务看着都不大,内存也都限额,但JVM调优反而比单体时代更细碎。原因在于堆变小了,GC停顿的抖动对整体影响变大。举一个实际例子,一个订单服务的堆从8GB被限制到2GB,原本用G1挺稳,堆变小后G1的优势发挥不出来,Full GC反而多了,后来换回Parallel GC好了一阵,但随着业务量上涨,2GB堆还是不够,最终解决方案是把一个2GB的堆拆成4个512MB的堆,通过多副本和负载均衡分摊流量,让单个实例的GC压力显著下降。
这里透露了一个架构层面的思路:当单个实例的JVM调优到瓶颈时,横向扩容和拆分比继续压榨堆空间更有效。分布式环境下,一个问题可能有多个解决维度,JVM调优只是其中一个杠杆。
另外,微服务环境下,要注意线程池和连接池对内存的实际占用。一个服务里塞了多个线程池,每个线程池200个线程,每个线程栈1MB,光线程栈就吃掉几百MB内存,这部分在堆之外但真实存在。把线程栈从1MB降到512K,理论上能省出一半线程栈内存,在堆大小受限的容器场景里非常划算。
5.3 架构设计阶段就该考虑的JVM问题
很多内存问题,在架构设计阶段就已经埋下伏笔了。举三个最常见的例子:
第一,缓存放在堆内还是堆外。堆内缓存(比如用ConcurrentHashMap做本地缓存)实现简单、访问快,但会占用堆内存,直接影响GC。当一个服务缓存了上GB数据,堆再大也扛不住,GC停顿也会明显增加。更好的方案是引入分布式缓存(Redis)、或者使用堆外缓存(比如MapDB、Chronicle Map),把内存压力从JVM堆里移走,给GC减负。
第二,大对象的序列化和传输。业务接口如果一次性返回几MB的JSON,这些大对象在堆里分配、拷贝、GC都要承受较高代价。架构上应该尽量做数据裁剪、流式处理、分页拉取,避免大对象在堆里到处飘。
第三,批量任务的调度方式。定时大批量处理任务,如果每个任务都无节制地加载数据到内存,堆会快速上涨。架构上应该分页分批处理,配合PretenureSizeThreshold让大对象直接进老年代,减少新生代拷贝。这些都属于"架构决定JVM"的例子,比调参更治本。
6. 最后聊聊面试:JVM调优怎么答才能不踩坑
JVM调优是Java面试的高频话题,而且问法越来越刁。以前能说清楚Xmx和Xms就算及格,现在面试官更关注你有没有一套完整的分析框架。我后来跟几个做过技术面试官的朋友聊过,我们一致认为,答JVM调优题,最重要的不是背参数,而是看候选人脑子里有没有一套清晰的思路。
6.1 面试官真正想听什么
面试官问"线上Full GC频繁怎么办",想听到的不是一个答案,而是一条链路:先通过jstat看GC数据,再通过jmap看堆内存分布和对象统计,必要时导出堆转储用MAT分析,拿到证据后判断是对象分配速率过快、内存泄漏还是配置不合理,最后针对性调整参数并验证。能按这个链路讲出来的候选人,说明真的处理过线上问题,有实战经验。
一个通用答题框架是:现象描述 -> 工具采集 -> 数据分析 -> 假设定位 -> 方案验证。把每一次调优都按这个套路讲,比背一百个参数都管用。
6.2 三个高频问题怎么答
第一个高频问题:"线上Full GC频繁,你会怎么排查?"直接答:先用jstat -gcutil看老年代和元空间使用率,确认是哪个区域引发了Full GC;再用jmap -histo:live看对象分布,锁定大对象来源;如果是老年代持续打满,导出堆转储用MAT看支配树,大概率能定位到缓存无上限、连接未关闭、或者单次任务加载数据过多这类根因。修根因优先,调参数为辅。
第二个高频问题:"堆大小怎么设置?"答的时候体现出权衡:先看业务对象分配速率和存活数据量,压测观察老年代增长速度;堆不是越大越好,太大导致GC停顿时间过长,太小导致GC频率过高;生产环境Xms和Xmx设成一致;容器环境用MaxRAMPercentage而不是固定Xmx,给非堆内存留余地。
第三个高频问题:"遇到过OOM吗?怎么处理的?"答的时候要体现出你分得清OOM的类型:Heap Space OOM,大概率是对象泄漏或分配过快,看堆转储;Metaspace OOM,大概率是动态类加载太多,调MaxMetaspaceSize并且检查加载逻辑;Direct buffer memory OOM,大概率是堆外内存泄漏,检查Netty或DirectByteBuffer使用。把类型分清楚,回答的深度完全不一样。
6.3 我自己的一点体会
带过这么多实习生,我发现大家学JVM调优很容易陷入两个极端:要么只背参数不碰原理,要么只研究原理不跑实战。我个人的建议是,自己用本地开发环境搭个服务,用-Xmx64m人为制造OOM,用jmap导出堆转储,用MAT打开,亲眼看看一个OOM现场的堆里都有什么。这个过程走上几遍,你对JVM调优的理解会超过看十篇文章。参数会忘,但排查的思路和"先看数据再动手"的纪律不会忘。这大概就是JVM调优最值钱的部分了。