如果你和我一样,Java 写了几年,翻过不止一遍那本经典大部头,却总在线上出问题时发现自己“背了又不熟、看了又用不上”,那这篇应该能帮你把 JVM 底层真正该吃透的部分串起来。以前我也喜欢从类加载讲到字节码,但等你真遇到 Full GC 拖垮接口时,真正救你的其实是三件事:对象分配在哪儿、垃圾回收怎么判定、GC 日志怎么看。这篇不聊虚的,只讲入户即用的 JVM 底层核心,适合三类人:准备跳槽想一次性打通知识点的人、线上出过 GC 故障却不知道下一步该查什么的人、以及实在没时间啃大部头但又不想在面试和排障现场露怯的普通开发。
1. 对象的一生:运行时数据区里最容易踩坑的细节
1.1 从一行 new 开始:对象创建完整流程
很多人背运行时数据区背得滚瓜烂熟,什么程序计数器、虚拟机栈、本地方法栈、堆、方法区,但真问到“一个对象从 new 开始到底经历了什么”,回答就开始含糊。其实对象创建远不是一句“分配内存”就完了,它至少有五步:
类加载检查。JVM 遇到 new 指令时,先在常量池里找到这个类的符号引用,检查这个类是否已经加载、链接和初始化。没加载就触发类加载,也就是常说的双亲委派模型那一套。这个过程最容易忽略的是“符号引用”,你在 IDEA 里敲 new 和 JVM 常量池里存的符号引用是两回事,面试官问到这个细节时,能说出来会加分不少。
分配内存。类加载完成后,JVM 就从 Java 堆里给对象划一块大小确定的内存。这里有两种分配方式:如果堆内存是规整的,用指针碰撞,移动一个指针就行;如果内存是零散的,就得用空闲列表记录哪些内存块可用,再找一块足够大的分出去。堆是否规整,取决于垃圾回收器用的是标记-复制还是标记-清除,这个后面再说。
内存空间初始化零值。这一步很多人根本不提,但它很关键。JVM 要把分配到的内存空间全部初始化为零值,这样对象的实例字段在没有任何赋值时默认值就已经是 0、null、false 了。这也是为什么 Java 的实体类字段不手动赋值也不会报错,而不是“编译期默认给的”。
设置对象头。对象头里存了两类信息:一类是 Mark Word,存放哈希码、GC 分代年龄、锁状态标志、线程持有的锁等;另一类是类型指针,指向方法区的类元数据,JVM 靠它确定这个对象是哪个类的实例。如果数组对象,对象头里还得多存一个数组长度。
执行构造方法。
<init>方法才是你写的构造函数,到这里对象才算真正“造好了”。前面几个步骤完成之后,从 JVM 的角度看,它已经是一个完整的对象了,但对你写的业务代码来说,constructor 里的字段赋值才刚开始生效。
平时我们讲“对象创建”大多只讲这五步,但分配内存这一步里还有个高性能关键点,TLAB。JVM 默认是线程安全的,但如果每次分配都加锁,那创建对象的效率就被拖垮了。所以 HotSpot 默认开启 TLAB,即每个线程在 Eden 区预先申请一小块私有内存,对象创建时优先在这块内存里分配,不需要加锁。只有当 TLAB 不够用时,才用 CAS 去公共 Eden 区抢内存。这个细节在生产调优里很重要,线上对象创建特别频繁时,可以适当调大-XX:TLABSize,但别盲目调,后面会说怎么看 TLAB 命中情况。
1.2 Eden 区不是垃圾桶,而是性能主战场
对象创建后首先进入的几乎一定是新生代的 Eden 区。新生代被分成一块 Eden 和两块 Survivor,比例默认是 8:1:1,这个比例不是拍脑袋定的,而是为了配合复制算法,把浪费控制在 10% 左右。因为每次 Minor GC 后,Eden 里存活的对象会被复制到 Survivor,而 Eden 会被直接清空。两块 Survivor 交替使用,始终有一块是空的,等待下一次 GC 复制。这就是复制算法的核心思路:不整理内存,直接把存活对象搬到另一块预先留好的空闲区,代价是必须浪费一块空间。8:1:1 就是在“浪费最少”和“复制成本可接受”之间找平衡。
但有几个特殊情况值得注意。首先是“大对象直接进入老年代”。你可以在 JVM 参数里设置-XX:PretenureSizeThreshold,比如把它设为 3MB,那么大于 3MB 的对象就不会走 Eden,直接进入老年代。这么做的原因很简单:大对象在 Eden 和 Survivor 之间来回复制,成本太高。大对象进入老年代后不会立刻触发 GC,但如果这种对象特别多,老年代很快就会被塞满。特别注意,PretenureSizeThreshold其实对 Serial 和 ParNew 有效,G1 和 Parallel 不保证严格遵守。G1 有自己的 Humongous 区域专门存大对象,不能依赖这个参数。
第二个是“长期存活对象进入老年代”。JVM 给每个对象一个年龄计数器,每熬过一次 Minor GC 年龄加一,默认到 15 就晋升老年代。这个 15 就是对象头 Mark Word 里分代年龄字段能表示的最大值,4 位二进制最多 15。-XX:MaxTenuringThreshold可以调,但不建议乱调,弄低了对象过早晋升老年代,弄高了 Survivor 空间不够放。
第三个是“动态年龄判定”。这算一个容易被忽略的规则:如果 Survivor 中相同年龄的所有对象大小总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代,不需要等到 15 次。所以你在线上看到有几个对象晋升老年代,但是 age 明明只有 3、4,不一定是配置问题,可能是动态判定生效了。
第四个是“空间分配担保”。Minor GC 之前 JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果小于且设置过允许冒险,就会再检查历史晋升对象的平均大小,如果还是小于,那就直接触发一次 Full GC,风险比较大。实际工作中,真正要记的是:老年代没空间担保时,Minor GC 会升级成 Full GC,这就是一些应用明明没到老年代 OOM,却频繁 Full GC 的原因之一。
1.3 逃逸分析与栈上分配:没“逃”出去的对象可以不上堆
知道对象大概率在堆上分配,但还有一个例外:逃逸分析。JVM 在 JIT 编译时会分析对象的作用域,如果一个对象只在方法内部使用,没有被返回、没有被赋给外部变量、没有被传到其他方法里,它就算“未逃逸”。未逃逸的对象有两个优化手段:栈上分配和标量替换。栈上分配很好理解,对象可以直接在栈帧里分配,方法结束栈帧销毁时对象就直接没了,不需要 GC 接管。标量替换更彻底,JVM 会把对象的字段拆成若干个局部变量来使用,不生成真正的对象实例。比如你 new 了一个坐标对象只有 x、y 两个字段,编译器优化后可能直接变成两个局部变量,对象本身根本不存在。
这个机制在实际生产里非常有用,因为大量的临时对象本来就是用完即弃的。如果它们都去堆上溜一圈再等着 GC,Minor GC 的压力会大很多。但要注意,逃逸分析不是你想触发就触发的,它依赖 JIT 的 C2 编译器,并且要开启-XX:+DoEscapeAnalysis(HotSpot 默认是开的)。在分层编译下,逃逸分析的结果会直接影响锁消除等优化。我从实战中观察到的结论是:代码写得越“局部化”,越利于逃逸分析发挥;反过来,动不动就把临时对象塞进 List 或 Map 再返回,逃逸分析基本就没戏了,垃圾回收压力自然上来了。
2. 垃圾回收入门到精通:判定、算法与四种引用
2.1 可达性分析是判定的灵魂,引用计数法为什么被淘汰
垃圾要能被回收,前提是 JVM 得先认出谁是垃圾。有两种判定思路:引用计数法和可达性分析。引用计数法听起来很直观:对象每被引用一次计数器加一,引用失效计数器减一,计数为 0 就是垃圾。但它有一个致命缺陷:循环引用。A 引用 B,B 引用 A,两者都不再被外部使用,但引用计数永远不为 0,GC 永远收不走它们。所以 HotSpot 几乎不用引用计数,而是用可达性分析。
可达性分析的思路是从一组称为 GC Roots 的根对象出发,沿着引用链往下走,所有能走到的对象都是“存活”的,走不到的对象就是垃圾。关键在于 GC Roots 都包含什么。这里我直接列一份你面试和排障都要背的清单:
- 虚拟机栈中局部变量表里的引用,也就是当前正在执行的方法引用的对象。
- 方法区中的静态变量引用、常量引用。
- JNI(Native 方法)引用的对象。
- 被 synchronized 持有的对象(锁对象,可不能随便回收)。
- Java 线程、活着的 Thread 对象。
- JVM 内部的一些基础对象,比如系统类加载器、已加载的类等。
实战中我们常会遇到一个困惑:明明一个对象看起来已经没用了,为什么 GC 后内存没降?这通常是因为对象被某个 GC Root 间接引用了,常见来源是静态集合、JDBC/连接池等框架层对象、以及缓存容器。我踩过最典型的坑,就是用一个静态 Map 做全局缓存,用法上觉得“不用的时候 set null”就行了,但 Set 了业务层的引用,Map 还在呢,对象永远可达,内存只增不减。搞清楚 GC Roots 之后你会发现,大多数内存泄漏本质上就是“某个长生命周期的对象一直保存着短生命周期对象的引用”。
2.2 四种引用你要掌握到能随口默写
Java 在 JDK 1.2 之后把引用分成强引用、软引用、弱引用、虚引用四类,回收时机各不相同。强引用就是最常见的Object obj = new Object(),只要强引用还存在,GC 永远不会回收。软引用是内存不足时回收,适合做缓存:SoftReference指向的对象在发生 OOM 之前会被回收,所以很多图片缓存和本地缓存框架会用它。弱引用是只要 GC 一发生,不管内存够不够都会被回收,典型代表是WeakHashMap和WeakReference。虚引用最特殊,它不会决定对象生命周期,唯一用处是在对象被回收时收到一个系统通知,供堆外内存等资源做回收跟踪。
我见过很多团队给缓存用强引用,结果内存狂飙;也有人反过来,一律用弱引用,结果缓存命中率低得离谱,对象每次 GC 都丢,等于没缓存。选择引用级别要看你兜底失败的代价:弱引用适合“丢了也无所谓”的数据,软引用适合“丢了能重建但有点贵”的数据,强引用适合必须常驻的核心对象,但要控制数量和生命周期。另外注意,软引用在 JDK 对内存敏感度不同时,被回收的时机是有差异的,完全依赖软引用做缓存并不稳妥,还是得配合过期策略和 LRU。
2.3 标记-清除与标记-复制:停车场的两种极端
判定完谁是垃圾,就要决定怎么回收。JVM 主流的回收算法是三大件:标记-清除、标记-复制、标记-整理。标记-清除最朴素:先标记所有存活对象,再统一回收未标记对象。它的最大问题是内存碎片化:回收完的对象之间会留下一个个不连续的空洞,后续分配大对象时可能找不到连续空间,又得提前触发 GC。CMS 就是用标记-清除,所以它到了后期很尴尬,碎片化严重时不得不 Full GC 来压缩整理。
标记-复制就是我们前面提到的新生代方案,它解决了碎片问题,但要预留一块可复制的空间,所以浪费了 10% 的内存。类比一下:标记-清除就像停车场管理员给所有能停的车贴上条,然后把不能停的车拖走,但留下的空位彼此不相邻,新来的大车停不进去;复制算法则是把活着的车一辆辆开到另一个停车场,原停车场清空后变成规整的一大片空地,代价是多花了一个停车场的土地。
老年代不能直接玩复制,因为老年代存活对象多,复制成本太高,所以用标记-整理算法:先标记存活对象,然后把它们往一端移动,不留碎片。移动对象是要停顿业务的,所以老年代 GC 停顿往往比新生代长很多,这是老年代调优的核心矛盾:不做整理会碎片化,做整理会停顿更久。理解了这三件套,后面看 CMS 和 G1 的行为就顺理成章了。
3. 七款垃圾收集器横评:看完不被面试官问倒
3.1 分代收集器:Serial、ParNew、Parallel 与 CMS 的恩怨情仇
新生代三兄弟先说清楚。Serial 是单线程收集器,GC 时必须暂停所有业务线程,现在只在单核或 Client 模式下有意义,你在服务端基本不会用。ParNew 是 Serial 的多线程版本,多个线程并行做 Minor GC,JDK 8 时代配合 CMS 是很多互联网公司的黄金组合。Parallel Scavenge 则是吞吐量优先,追求在单位时间内完成尽量多的业务,它的独特之处是有一个自适应调节策略,JVM 会根据运行情况自动调整新生代大小和晋升阈值。
老年代里最值得说透的是 CMS,Concurrent Mark Sweep,名字就告诉你它是并发标记-清除。它的目标是尽量缩短停顿时间,所以设计了四步:初始标记、并发标记、重新标记、并发清除。初始标记很短,只标记 GC Roots 直接可达的对象,需要 Stop The World。并发标记最长,业务线程和 GC 线程同时跑,顺着引用链逐步标记。重新标记又要 STW,用来修正并发标记期间业务线程产生的变化。最后并发清除,业务线程和 GC 线程再次同时跑,把垃圾清掉。
CMS 看起来美,实际用起来有三个老坑。第一个是 CPU 敏感,并发阶段会占用一部分 CPU,核心少的时候业务吞吐会掉。第二个是浮动垃圾,并发清除阶段业务线程还在产生新垃圾,这部分只能下次再清,所以 CMS 不能等堆满了才启动,它有一个触发比例,-XX:CMSInitiatingOccupancyFraction,一般设 70 到 80。第三个是碎片化,标记-清除算法带来内存碎片,严重时要触发 Full GC,CMS 会退化成 Serial Old 单线程整理,停顿时间惨不忍睹。配套参数还要记住-XX:+UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction,但这俩在 JDK 9 之后被废弃了,CMS 本身也在 JDK 9 标记弃用、JDK 14 正式移除。所以现在学 CMS 更多是为了维护老系统和过面试,新项目就别往这个坑里跳了。
3.2 GPU 之外的现代答案:G1 到底赢在哪里
G1,Garbage First,是 JDK 9 之后服务端默认收集器。它的核心设计是抛弃物理分代,把堆划分成一个个 Region,每个 Region 大小默认 1MB 到 32MB,逻辑上仍分年轻代和老年代,但年轻代和老年代不再是一整块连续内存,而是各自一批 Region。G1 的目标是用有限的停顿时间处理尽可能多的垃圾,所以它叫 Garbage First:优先回收垃圾占比最高的 Region,而不是全堆扫描。
为了让各个 Region 之间互不干扰地回收,G1 引入 RSet,记录别的 Region 对自己的引用。写引用时,JVM 用卡表维护脏卡信息,这样 GC 时能快速找到谁引用了当前 Region。并发标记阶段用 SATB 快照,在标记开始时为堆生成一个逻辑快照,保证并发期间新增的引用也能被正确识别,避免了漏标问题。
G1 的实际运作分几个阶段:Young GC 和普通新生代回收类似,把 Eden 的存活对象复制到 Survivor;然后是并发标记周期,标记出老年代中值得回收的 Region;之后是 Mixed GC,回收部分年轻代 Region 和部分老年代 Region;如果并发标记阶段空间不够、RSet 过大或者存活对象太多,就会退化为 Full GC,G1 的 Full GC 在 JDK 10 之前还是单线程,JDK 10 之后并行化了,但依然是最后的手段。
调 G1 最核心的两个参数是-XX:MaxGCPauseMillis,停顿目标,默认 200 毫秒;以及-XX:G1HeapRegionSize,Region 大小。触发 G1 的回收不能光看堆占用,它是基于停顿预测模型,GC 时会根据历史统计判断每个 Region 的回收集合能否满足停顿目标。这里我要明确说:G1 不是万金油,它对大堆、多核场景表现很好,但如果是 2GB 以下的小堆、对停顿极度敏感的应用,选 ZGC 或干脆用 Parallel 也许更合适。不过现实中大多数服务用 G1 都没问题,默认参数足够扛住日常业务。
3.3 看一眼未来:ZGC 与 Shenandoah 适合什么人
ZGC 在 JDK 11 以 Experimental 形态出现,JDK 15 转正,目标是把停顿时间压缩到 10 毫秒以内,甚至亚毫秒级别。它的秘密武器是染色指针和读屏障。染色指针把对象引用指针的高位用于 GC 状态标记,GC 线程可以并行处理,而读屏障让业务线程在读取对象指针时如果能感知到 GC 状态变化,就协助修正,不需要全部 STW。ZGC 天然适合超大堆、低延迟的场景,比如几百 GB 堆且要求接口延迟抖动的系统,优势非常明显。但 ZGC 对内存和 CPU 的开销也很大,普通应用强行换 ZGC 未必能感受到好处,反而可能增加 GC 线程占用。Shenandoah 的思路与 ZGC 类似,是 Red Hat 主导的收集器,在 OpenJDK 里能用到,Oracle JDK 里没有。现阶段普通业务团队不用急着追新,先搞清楚自己手里系统用的是哪一代、核心瓶颈是不是 GC 再决定要不要升。
4. 调优三板斧:不多但管用的参数组合
4.1 先定目标再调参,别上来就改内存
很多开发一遇到 GC 频繁就调-Xmx,这是典型的本末倒置。调优之前先问自己三个问题:这个系统更看重延迟还是吞吐?当前遇到了什么具体问题,是 OOM、Full GC 频繁还是响应变慢?改动后怎么验证有效而不是瞎猜?心里有谱了再动参数。
内存参数必须先说清楚:-Xms和-Xmx建议设成一样大,避免堆大小动态伸缩带来的性能波动。-Xmn是新生代大小,它直接影响 Minor GC 频率和老年代可用空间。新生代太小,对象频繁 Minor GC,会拖慢吞吐;新生代太大,老年代变小,Full GC 会更频繁,而且单次 GC 停顿时间变长。我通常建议新生代占堆的 1/3 到 1/2,具体看对象分配速率。Survivor 比例-XX:SurvivorRatio=8是默认,一般不用动。JDK 8 以后还要注意元空间,-XX:MaxMetaspaceSize一定要设一个上限,否则动态生成类太多时会把系统内存耗光。再次提醒,这不是万能的,很多应用调了半天内存才发现问题在代码层,所以调参前先看一眼 GC 日志和对象分布比什么都重要。
4.2 GC 日志:看懂它才是真调优
没有 GC 日志的调优等于闭眼开车。JDK 8 及以前常用的开关是-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -Xloggc:gc.log。JDK 9 之后统一日志系统上线,原来的参数风格变了,用-Xlog:gc*:file=gc.log这一套。线上如果有条件,一定要留 GC 日志,而且要保留足够的历史文件,否则出问题时你会满脸问号:上次 GC 什么样?改完参数前后对比数据在哪?
我随便给一段简化版的 GC 日志格式,你对照着看就懂字段含义了,线上真实日志字段会更多。单看一行,你要抓住几个关键信息:GC 类型(Minor GC / Full GC)、GC 前后堆大小、耗时、以及停顿时间。如果看到 Full GC 前后老年代占用几乎不降,那不是垃圾回收的问题,是内存泄漏;如果老年代占用降了但下一次很快又涨回来,那是对象分配或晋升速度过快。日志里还有一个隐藏宝藏:GC 之前的老年代用量变化。它是判断“是否要调大堆、是不是大对象太多、该不该换收集器”的直接依据。
4.3 三种典型的调优思路
我把常见场景归成三类,你直接对号入座。
一是吞吐量优先,常见于后台任务型系统。JDK 8 时代可以选 Parallel Scavenge 加 Parallel Old:-XX:+UseParallelGC -XX:+UseParallelOldGC,把吞吐量目标交给他,它可以自适调节。JDK 11 之后 G1 也是吞吐量不错的选择,重点是别把停顿时长设得太苛刻,否则 GC 频率会上升。
二是延迟优先,常见于在线交易、接口服务。JDK 8 老旧系统用 CMS 配合 ParNew,注意设置-XX:CMSInitiatingOccupancyFraction=70左右并开启压缩整理。新系统直接用 G1,适当设置-XX:MaxGCPauseMillis=100或 200。如果堆很大且延迟要求很高,评估 ZGC。
三是“改动最少”方案,适用于那些改任何参数都有风险的存量系统。只做两件事:第一,把-Xms和-Xmx设成一致并略调大,给对象更多空间;第二,给-XX:MaxMetaspaceSize设上限并加上 GC 日志开关。做完这两步再观察,往往比一顿操作猛如虎要可靠得多。因为很多时候 GC 频繁不是 JVM 的问题,而是代码写得太离谱,调参只会掩盖问题。
5. 线上 Full GC 排查实录:一次山重水复的诊断之旅
5.1 事件回溯与初步诊断
之前接手过一个某在线交易系统的排查,现象是下午高峰期接口延迟从 30ms 一路飙升到 3 秒,CPU 冲到 150% 以上,业务方反馈订单接口超时。当时第一反应就是看 GC,因为响应时间全面劣化一般逃不出三个方向:GC 停顿、锁竞争、线程池打满。我让值班同学先跑jstat -gcutil <pid> 1000,每秒打一次 GC 统计,很快就看到两个关键数据:Full GC 次数在快速上涨,几乎每分钟一次;老年代使用率从 30% 一路冲到 90% 以上,而且每次 Full GC 之后回不到低位。单看这个现象,内存泄漏嫌疑已经很大。
顺手翻了 GC 日志,发现每次 Full GC 前老年代都有大量对象晋升,Survivor 永远放不下,对象直接漏到老年代。这说明问题不在 GC 参数,而在对象生命周期。为了进一步确认对象来源,我先用jmap -histo:live <pid> | head -20看了一眼直方图,确认有某个数据结构的实例数量和占用特别离谱。然后和业务团队确认,高峰期正好有新功能上线,大量用户同时触发了某个报表查询接口。线索基本指向就是这个接口往 heap 里塞了什么大对象。
5.2 堆转储与 MAT 分析:找出元凶
要锁定具体代码路径,直方图还不够,得看引用链。我选择在低峰期用jmap -dump:live,format=b,file=/tmp/heap.bin <pid>导出一份堆转储,然后用 MAT 打开,看 Dominator Tree,也就是支配树,找占用内存最大的对象路径。很快就看到了一个缓存组件的内部 Map,Key 是某个业务维度,而 Value 是一长串 List,里面挂着大量报表查询结果对象,数量远超预期。
根因浮出水面:业务代码在查询结果为空时,会把一个默认值节点也塞进缓存,而且重复操作不会更新,导致 List 越积越长;缓存本身又没有过期淘汰,成了无限膨胀的静态持有者。修复方案也不算复杂:给缓存加上容量上限和时间过期策略;查询为空时不缓存空结果,直接删除 Key;对同一维度加并发锁,防止重复构造大对象。上线后 Full GC 回归到半小时一次的常态化水平,接口延迟恢复到 30ms。这个案例我拿来复盘总是提醒自己:JVM 层能背的锅其实很小,绝大多数 Full GC 是代码层对象生命周期错乱造成的。
5.3 常见问题与排查技巧速查表
我把自己踩过的坑和带团队时遇到的高频问题整理成一张速查表,至少能帮你少走一半弯路。
| 现象 | 优先排查方向 | 常用命令或手法 |
|---|---|---|
| Full GC 频繁,接口变慢 | 内存泄漏、大对象过多、堆太小、元空间溢出 | jstat -gcutil、jmap -histo:live、堆转储 MAT |
| 老年代占用高但 Full GC 次数不多 | 对象自然增长、缓存容量膨胀、结构不合理 | 观察 GC 日志中 Full GC 前后占用差 |
| CPU 高但 GC 不频繁 | 死循环、正则回溯、锁竞争、序列化热点 | jstack多次采样看线程栈、perf 分析热点 |
| 元空间 OOM | 动态生成类太多,比如反射、大量代理类 | 查看加载类日志,确认是哪个框架或业务产生的 |
| 频繁 Minor GC 但堆还有余量 | 对象分配速率过高,临时对象太多 | 逃逸分析可能未生效,检查代码是否大量创建集合副本 |
| System.gc() 被隐式调用 | 显式 GC 触发、NIO 里某些框架会调用 | 看 GC 日志是否存在 “Full GC (System)” |
排查技巧里我想特别强调一点:别迷信-XX:+DisableExplicitGC,禁用System.gc()会把某些依赖显式 GC 做清理的框架坑到,尤其是使用了堆外内存的组件。真要处理,先定位是谁调的,再决定是优化还是禁用。
5.4 工具收藏夹:jps、jstat、jinfo、jmap、jstack、jcmd 的正确打开姿势
最后把 JDK 自带工具过一遍,这几个命令值得每个 Java 工程师记牢。
jps -l,最快确认目标 Java 进程 PID 的方式,排障第一步。jstat -gcutil <pid> 1000,看 GC 频率和内存占比,排查 GC 问题的头号武器。jinfo -flags <pid>,查看进程实际生效的 JVM 参数,排查“为什么我配的没生效”。jmap -histo:live <pid>,快速看对象实例数排行,能定位绝大多数内存异常。jmap -dump:live则用于导出堆转储,大堆上执行会 STW,低峰期再用。jstack <pid>,抓线程栈,配合多次采样能找死循环、线程阻塞、锁竞争。jcmd <pid> help,JDK 8 之后出现的多功能瑞士军刀,很多jmap和jinfo的老用法在它这里都有替代。
有一个使用心得:线上排障先jstat再看栈,别一上来就jmap -dump。堆转储文件动辄好几 GB,传下来很费劲,分析也要时间。先确认是不是 GC 问题,再确认对象集中在哪一块,最后才考虑要不要 dump。这是省时间的核心排序。
前面聊了这么多,最后我再给一条个人体会最深的小经验。很多人觉得 JVM 调优是玄学,其实不是。把对象分配、GC 判定、收集器行为这三条主线理清了,再看线上问题就会清晰很多。你不需要第一天就背下所有参数,但至少要会看 GC 日志,并且能从日志里判断出“现在的问题到底是代码问题还是参数问题”。我见过太多工程师把一个明显是缓存没清理的问题,硬生生调了一个月的-Xmx,最后该 Full GC 照样 Full GC。还有,如果 GC 日志里每次 Full GC 之后老年代占用率都回不到低点,那你基本可以断定有人在堆里藏了一堆不该长命的对象,调 Java 参数治不好这种病,去改代码才是正经路子。