1. 先弄清楚JVM到底是什么:从一次OOM排查说起
很多人学了几年Java,张口就能背出“Java虚拟机是Java运行环境的核心”,但真到了线上服务报警、堆内存被撑爆、应用假死的时候,却不知道从哪里下手。这不怪你——因为大多数资料把JVM讲成了“八股文”,只告诉你结论,不告诉你为什么会有这些结论。
先聊一个真实的场景。某个电商系统在高峰期突然出现接口大面积超时,日志文件疯狂刷报错:
java.lang.OutOfMemoryError: Java heap space团队第一反应是“堆内存不够了,加参数-Xmx2g改成4g”。改完重启,撑了半小时,又挂了。后来看了监控才发现,GC日志里Full GC的频率从原来的每小时几次飙升到每分钟几十次,每次回收后内存只能释放一小部分,紧接着又被占满。这根本不是容量问题,而是代码里有人把大对象缓存进了静态Map,且永不清理——内存泄漏。
这个案例能拆出好几个值得深挖的问题:JVM到底是怎么分配内存的?一个对象从创建到被回收,经历了什么?为什么Full GC一多,系统就卡得像死机?这些问题的答案,全部藏在“JVM到底是什么”这个看似基础的问题里。
简单说,JVM是一台“运行在操作系统之上的抽象计算机”。它屏蔽了底层操作系统和硬件的差异,让Java代码编译出的字节码(.class文件)能在任何装有JVM的平台上运行。这就是“一次编写,到处运行”的核心秘密。但JVM不只是翻译字节码的执行器,它同时承担着内存管理、线程调度、垃圾回收、运行时编译优化等一系列复杂工作。不理解这些,你写出来的Java程序就像开着一辆从不保养的车——平时没事,上了高速就抛锚。
2. 内存区域划分:每个对象在JVM里住什么样的“房子”
2.1 运行时数据区到底分几块,每块是干什么的
JVM在运行Java程序时,会把自己管理的内存划分成若干个区域,统称“运行时数据区”。你可以把这个结构理解成一家酒店的楼层分配:
程序计数器:每层楼的“值班表”,记录当前线程执行到哪一条字节码指令。线程私有,生命周期和线程一致。它也是JVM规范里唯一没有规定OOM(OutOfMemoryError)的区域,因为每个线程单独记录,空间极小。
虚拟机栈:每层楼的“工作台”,存放栈帧。每次方法调用,JVM往栈里压入一个栈帧;方法结束,栈帧弹出。栈帧里装着局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表存放基本数据类型和对象引用——注意,对象本身不在这里,在堆里。线程私有。如果线程请求的栈深度超过虚拟机允许的深度,抛StackOverflowError;如果可以动态扩展却扩不动了,抛OutOfMemoryError。
本地方法栈:和虚拟机栈作用类似,只不过它为Native方法服务。所谓Native方法,就是用C/C++等非Java语言实现的方法,典型如Java NIO底层的读写操作。这部分栈在很多实现里直接合并进虚拟机栈,但规范层面是独立概念。
堆:全酒店最大的一层,几乎所有的对象实例和数组都在这里分配内存。堆被所有线程共享,是垃圾回收器重点照顾的区域。这就是为什么堆的大小、GC策略、内存分配方式,直接决定应用的吞吐量和响应时间。堆本身还可以细分:新生代(Eden区、From Survivor、To Survivor)、老年代,后续专门讲。
方法区:存类的结构信息——类名、方法字节码、字段描述、常量、静态变量等。它也是线程共享的。在JDK 8之前,方法区被实现为“永久代”,也在堆内;JDK 8开始改为元空间(Metaspace),使用本地内存。这个调整影响深远,后面章节细说。
运行时常量池:方法区的一部分,存放编译期生成的各种字面量和符号引用——比如字符串常量、final修饰的常量值、类名和方法名。字符串常量池在JDK 7之后被挪到了堆里,这是一个很多面试者会踩坑的细节。
2.2 栈帧背后的执行逻辑
虚拟机栈的每个栈帧对应一次方法调用,这个“对应”关系很关键。观察一下它的结构:
栈帧里,局部变量表以“槽”为单位,long和double占两个槽,其余类型占一个槽。这里有个实践经验:局部变量表在编译期就已经确定大小,不会在运行期变化。所以一个方法能装载多少个局部变量是固定的。
操作数栈是字节码运算的临时工作区。看一段简单的代码:
public int add(int a, int b) { return a + b; }对应的字节码大致是:
iload_1 // 将局部变量槽1的int值压入操作数栈 iload_2 // 将局部变量槽2的int值压入操作数栈 iadd // 弹出栈顶两个int,相加,结果压回栈 ireturn // 返回栈顶int每一步的数据都从局部变量表加载到操作数栈,运算结果再写回局部变量表或者给方法出口。理解这个模型,你就明白为什么字节码是“栈式”的,而不是像x86寄存器那样直接操作寄存器。这是JVM实现跨平台、跨架构的一个关键设计。
2.3 JDK 8的元空间改动,不只是一个版本差异
永久代换成元空间,很多人在面试里被问过,但真正理解意义的没几个。
永久代的大小难以预估。项目依赖越多、动态生成类越多(比如CGLIB代理、反射),永久代越容易撑爆,然后报java.lang.OutOfMemoryError: PermGen space。这是当年很多框架爆过的雷。JDK 8把类的元数据挪到本地内存,默认情况下元空间大小只受操作系统可用内存限制,这样不会因为“永久代太小”而轻易崩溃,但代价是如果类加载失控,元空间可能无限增长,直接把机器内存吃光。
实践中的做法是:给元空间设置上限,-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m。线上环境建议明确设置,防止某些框架在运行时大量生成代理类导致内存失控。另外,本地内存不像堆内存那样受-Xmx控制,审视内存预算时一定要把这部分单独算进去。
3. 类加载与对象创建:一个对象从.class到进入堆的完整旅程
3.1 加载、验证、准备、解析、初始化
Java程序使用的类很多是“用到时才加载”——这被称为懒加载。一个类从被读入内存到完全可用,经历五个阶段:
加载:通过类的全限定名获取该类的二进制字节流,将字节流中的静态存储结构转换为方法区(元空间)中的运行时数据结构,并在堆中生成一个代表该类的Class对象,作为方法区这个类的访问入口。
验证:检查字节流是否符合JVM规范。Class文件不一定由Java编译器生成,也可能是手写字节码或者其他语言编译产物,所以这一步不能省。验证包括文件格式验证、元数据验证、字节码验证、符号引用验证。很多反编译再修改的字节码,就是在这里被拦下来的。
准备:为类的静态变量分配内存并设置零值。比如static int x = 100,准备阶段后x的值是0,不是100。真正的赋值动作发生在初始化阶段。但有一个例外:如果静态字段是常量(final修饰),编译期就写入字段属性里的ConstantValue,准备阶段直接赋成指定值。
解析:将常量池内的符号引用替换为直接引用。符号引用相当于“门牌号码的查找线索”,直接引用才是“实际门牌”。这阶段涉及类、接口、字段、方法等解析,也是Java动态绑定机制的重要占位——因为多态,某些方法调用只有在运行时才能确定真实地址。
初始化:终于执行类的静态代码块、静态变量赋值语句。这里触发类初始化的典型时机:创建实例、调用静态方法、访问静态字段、反射、初始化子类时父类未初始化等。
这五个阶段里,实际编码中最容易出问题的是“初始化”阶段——比如静态代码块抛异常,导致类加载失败,但后续引用这个类时仍然会尝试初始化,反复抛异常,日志里出现ExceptionInInitializerError和NoClassDefFoundError并存的情况。
3.2 双亲委派模型到底在防什么
类加载器之间有严格的层级关系。启动类加载器(Bootstrap ClassLoader)加载JDK核心类,比如java.lang.*;扩展类加载器(Extension ClassLoader)加载JDK扩展目录里的类;应用程序类加载器(Application ClassLoader)加载classpath下的类。
双亲委派模型的核心规则:当一个类加载器收到类加载请求时,先不自己加载,先交给父加载器尝试加载,父加载器又往上抛,直到顶层。只有父加载器反馈自己无法完成加载,子加载器才尝试自己加载。
这个机制保护了Java核心类库不被篡改。你想想,如果没有这层约束,你自己写一个java.lang.Object放进classpath,被加载了,整个Java运行的环境就被污染了。正因为双亲委派,java.lang.Object永远由启动类加载器加载,你写的同名类根本没有机会被执行。
但有些场景需要打破双亲委派,典型代表是Tomcat。一个Web容器里部署多个应用,每个应用可能依赖不同版本的Spring、不同版本的类库,如果全交给同一个应用类加载器,版本冲突会直接爆炸。所以Tomcat实现了WebAppClassLoader,优先自己加载WEB-INF/classes和WEB-INF/lib下的类,加载不到才交给父加载器。这就是“打破双亲委派”的经典应用。热部署也是类似原理——用新的类加载器重新加载类,新对象使用新类,旧类加载器整体作废。
3.3 对象创建的四步:内存分配、零值初始化、设置对象头、执行构造
类加载完成之后,对象创建就是从new指令开始的一连串动作。JVM在堆中为新对象分配内存时,有两种策略:
指针碰撞(Bump the Pointer):堆内存规整,空闲空间和已用空间之间存在一个“分界指针”,对象新建就把指针往空闲侧挪动一段距离。配合Serial、ParNew这类带压缩整理功能的收集器使用。
空闲列表(Free List):堆内存碎片化严重时,JVM维护一个列表记录哪些内存块可用,需要多大就找一块足够大的分出来。配合CMS这种基于标记-清除算法的收集器使用。
内存分配还需要考虑并发安全。JVM有两种处理方式:一种是用CAS配上失败重试保障分配操作的原子性;另一种是“线程本地分配缓冲”(Thread Local Allocation Buffer,TLAB)。每一个线程在Eden区划一小块私有的内存区域,对象优先在TLAB里分配,TLAB空间不够了,才去共享区通过CAS方式分配。实践中很多小对象的创建都发生在TLAB内,避免了对共享栈的锁竞争。
分配完内存后,JVM将这块内存的空间(不包括对象头)都初始化为零值,这样实例字段即使不显式赋值也能有默认值。然后设置对象头:这个对象是哪个类的实例、对象的哈希码、对象的GC分代年龄、锁状态等。最后才执行<init>方法——即构造方法里开发者写的那段初始化逻辑,这时一个真正可用的对象诞生了。
一个经验:对象内存布局分三块,对象头、实例数据、对齐填充。对象头在64位虚拟机默认开启压缩指针时可占8字节或12字节不等。如果你是做高并发写入场景,特别在意内存占用,对象头的大小影响是很实际的——成千上万个对象累计下来,占用差距非常可观。
4. 垃圾回收:比想象中复杂的“自动挡”,分代机制与收集器选型
4.1 怎么判断一个对象该回收:引用计数法的致命缺陷和可达性分析
Java里判断对象是否存活,不用引用计数法。为什么?引用计数法极其简单——每个对象维护一个计数器,被引用加1,引用失效减1,减到0就回收。但循环引用问题能直接让它瘫痪:A引用B,B引用A,双方计数器都不是0,却又没有外部引用,这两个对象就应该回收却收不掉。
JVM采用可达性分析(Reachability Analysis)。从一组称为“GC Roots”的根对象出发,沿着引用链向下搜索,凡是能链到的对象都是存活的,没有链到的就是可回收的。GC Roots包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中静态属性引用的对象、常量引用的对象、本地方法栈中JNI引用的对象等。
这里有一个面试常问的细节:软引用(SoftReference)和弱引用(WeakReference)。软引用的对象在内存充足时不会被回收,内存不够时才回收;弱引用则在下一次GC时必定被回收。实践里,做本地缓存时软引用比较实用,但一般建议用成熟缓存框架而非自己手写。弱引用经常配合ThreadLocal使用,解决ThreadLocalMap里Entry的key不释放问题——正因为Entry继承自WeakReference<ThreadLocal<?>>,ThreadLocal被业务代码丢弃后,key会被GC掉,value才能在下一次操作时被清理。
4.2 垃圾回收算法各有取舍:复制、标记-清除、标记-整理
标记-清除(Mark-Sweep):先标记出所有需要回收的对象,然后统一回收。最大的问题是碎片化——回收后内存空间不连续,后续分配较大对象时因为没有足够连续空间不得不提前触发GC。
标记-整理(Mark-Compact):标记完成后将存活对象往一端移动,直接清掉边界以外的内存。没有碎片,但移动对象成本高,需要Stop The World(STW)——应用线程暂停。
复制算法(Copying):把内存分成两块,每次只用一块,GC时把存活对象一次性复制到另一块,然后清掉原来的那一整块。实现简单、运行高效,但内存利用率低。现代商用虚拟机在新生代采用的不是“对半开”,而是把Eden区和一个Survivor区作为已用空间,另一个Survivor区作为复制目标,这样只有约10%的内存被浪费。
4.3 新生代和老年代,为什么要分代
分代收集的理论基础是“弱分代假说”:绝大多数对象的生命周期极短,活过几次GC的对象越来越少。于是堆被分成新生代和老年代。
新生代里,Eden区存放新创建对象,两个Survivor区(From和To)存放经历过一次Minor GC后仍然存活的对象。每次Minor GC时,把Eden区和From区里存活的对象复制到To区,同时对象年龄加1;达到阈值(默认15)后被移到老年代。对象进入老年代还有一些动态判定规则:比如同龄对象总和超过Survivor空间一半时,大于或等于该年龄的对象直接进入老年代——这就是“动态年龄判定”,不能死记硬背15这个数字。
老年代的对象存活率高,不适合复制算法,通常使用标记-清理或标记-整理算法。
4.4 常见收集器怎么选:Serial、ParNew、Parallel Scavenge、CMS、G1,以及新版ZGC
基础阶段需要掌握的收集器:
Serial:使用复制算法的单线程收集器,GC时必须暂停其他所有工作线程。适合单CPU、客户端模式的桌面应用,简单但效率不高。
ParNew:Serial的多线程版本,常用于服务端配合老年代的CMS收集器。
Parallel Scavenge:和ParNew类似,但更关注可控制的吞吐量——也就是运行用户代码时间占总时间的比例。适合后台计算密集型任务。
CMS(Concurrent Mark Sweep):第一款真正意义上的并发收集器,目标是低停顿。它基于标记-清除算法,在初始标记、并发标记、重新标记、并发清理四个阶段中,只有初始标记和重新标记需要STW。问题是会产生内存碎片,且并发阶段CPU竞争激烈。
G1(Garbage First):面向服务端的收集器,把堆划分为多个大小相等的Region,跟踪每个Region里垃圾堆积的价值,优先回收价值最大的Region。G1可以预测停顿时间,通过
-XX:MaxGCPauseMillis设置期望的GC停顿毫秒数。从JDK 9开始G1是默认收集器。ZGC:JDK 11引入的、面向大堆低延迟的收集器。核心特点是GC停顿时间几乎不随堆大小变化,可以控制在10毫秒以内。它在标记整理的过程中大量使用读屏障,处理对象搬移时对程序造成的感知极小。目前很多追求超低延时的金融、搜索场景已经切换到了ZGC。
选型时要结合业务:
| 场景 | 推荐收集器 | 理由 |
|---|---|---|
| 离线计算、批处理 | Parallel Scavenge + Parallel Old | 高吞吐量优先 |
| Web服务,响应延迟敏感 | G1 或 ZGC | 停顿可控,延迟低 |
| JDK 8默认组合 | Parallel Scavenge + Parallel Old | 无需特意改动,稳定至上 |
| JDK 9+ 默认组合 | G1 | 官方默认,适应大多数场景 |
4.5 Stop The World:为什么GC会让应用卡顿
任何Java线程在GC进行时,需要到达一个安全点(SafePoint)才能暂停。SafePoint是JVM确定的一份指令位置列表,一般选在循环跳转、方法返回等“不容易出现栈内存状态不一致”的位置。如果线程迟迟不进入SafePoint,就叫“长时间停顿自旋”。
GC的STW时间由两个维度组成:一是垃圾标记、清理本身需要的时间;二是所有Java线程进入SafePoint的等待时间。实际调优时,经常发现GC停顿不全是GC过程本身,而是应用线程Checksum计算太重、或大量线程在长循环里没有及时响应暂停信号导致的。排查这类问题,要结合线程转储(Thread Dump)观测哪个线程卡住了GC安全点。
5. 从理论到实战:JVM调优的正确姿势与常用工具
5.1 先判断要不要调优:调优不是拍脑袋改参数
很多人学完JVM理论后,第一件事就是把-Xmx调大,再开CMS或G1,结果并没有显著效果。你真正要做的第一步,永远是先弄清楚系统的瓶颈是不是在JVM。
观察指标:CPU使用率、堆内存占用曲线、GC频率、GC停顿时间、线程池活跃度、IO等待等。如果CPU飙高、线程争用严重,可能问题在锁或线程池配置;如果内存占用长期居高不下且GC频繁,才需要调整堆大小和收集器。
判断“需不需要调优”的心法:JVM默认配置经过大量实践验证,在没有明显异常指标时,别为了调优而调优。一个稳定跑了几年的服务,贸然改大-Xmx,反而可能因为Full GC耗时太长导致超时风暴。
5.2 必备工具链路:jps、jstat、jmap、jstack、jconsole、visualvm,以及更现代的Arthas
专业排查按顺序来:
jps:列出当前机器上所有Java进程。这是起点,拿到进程ID(PID)之后才能做后续操作。
jstat:实时监控虚机统计信息。最常用:
jstat -gcutil <pid> 1000每1000毫秒打印一次堆内存和GC情况。S0/S1表示Survivor区的使用百分比,E表示Eden区百分比,O表示老年代百分比,M表示元空间百分比,FGC是Full GC次数,FGCT是Full GC累计耗时。如果看到FGC数字不断上涨,恭喜,不用怀疑,一定有对象在往老年代泄漏或者被错误晋升。
jmap:导出堆内存快照。拿到堆转储文件后用MAT或者VisualVM分析大对象。
jstack:打印线程转储。查到线程当前状态、锁信息,定位死锁、线程阻塞。
jconsole:图形化监控CPU、内存、线程,适合快速看整体形态。
visualvm:更强力的图形化工具,可以装各种插件,Visual GC插件直接看堆里每个分区的动态变化。
Arthas:阿里开源的Java诊断工具,如果你能在生产服务器上用,调试体验比传统命令式幸福很多。常用命令:
dashboard:实时看线程、内存、GC概况。thread:定位线程状态,找出CPU飙高的线程。sc/sm:搜索类、查看方法信息。watch:观察方法入参和返回值——不需要重新部署代码就能知道某个方法被什么数据调用。trace:追踪一个方法内部各子调用耗时,做性能热点分析。
在这些工具里,我平时用得最频繁的是Arthas。尤其线上问题,没有本地监控权限、又不敢随意重启服务时,Arthas的watch和trace能在不破坏现场的情况下拿到关键信息。
5.3 常见调优参数,每项改动都要知道代价
| 参数 | 作用 | 改动须知 |
|---|---|---|
-Xms<size> | 初始堆大小 | 通常等于-Xmx,避免运行期堆扩容带来的性能损耗 |
-Xmx<size> | 最大堆大小 | 取决于物理内存,留足系统和其他进程的空间 |
-Xmn<size> | 新生代大小 | 过大会减少老年代空间,容易Full GC频繁;过小会让短命对象频繁Minor GC |
-XX:MaxMetaspaceSize=<size> | 元空间上限 | 防止动态类加载泄漏把机器内存打满 |
-XX:SurvivorRatio=8 | Eden和Survivor比例 | 默认8:1,一般不要动 |
-XX:MaxTenuringThreshold | 晋升老年代年龄阈值 | 默认15,对象存活期短可适当调小 |
-XX:MaxGCPauseMillis | G1期望停顿时间 | 设置太小可能导致GC过于频繁,吞吐量下降 |
-XX:+HeapDumpOnOutOfMemoryError | 堆OOM时自动导出转储文件 | 强烈建议开启,否则OOM现场丢失,排查无从下手 |
这里要重点说OOM参数。线上环境一定加两个参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump.hprof这样一旦发生堆OOM,自动生成堆转储文件,拿到文件后用MAT分析最大对象和引用链,找到是谁占着内存不放手。这比出了OOM再靠猜靠谱得多。
5.4 一次线上Full GC频繁的排查复盘
之前接手过一个广告投放服务,表现是每天下午Full GC次数从个位数变成几百次,接口延迟从30ms变成1.5秒。
先用jstat -gcutil观察,发现老年代使用率在每次Full GC后只下降10%左右,很快又涨回来。初步判断有对象在快速晋升老年代。然后jmap -dump导堆快照,用MAT分析,结果看到一个自定义的UserProfileCache对象数组占了老年代将近6GB空间。
查代码发现,这是一个每五分钟刷新一次的全局缓存,理论上应该只保留当前版本的快照,但实现时用了HashMap<Long, UserProfile>,且每次刷新往同一个Map里put,从不清理旧key。用户ID的数量一直在涨,老数据又不是强引用——但缓存对象本身在Map里被强引用,所以GC怎么回收都没用,内存只会越来越高。
修复方式很简单:改用ConcurrentHashMap加定时清理,或者直接换Guava Cache/Caffeine,设置过期策略。改完后观察了一周,Full GC基本消失,老年代曲线平稳。
这个案例里,前面的理论——可达性分析、分代晋升、GC Roots——全部用上了。你只有理解了对象是怎么被引用的,才能在面对GC异常时快速定位根因。
6. 内存模型与并发:JMM不只是面试题
6.1 JMM定义了什么:可见性、原子性、有序性
Java内存模型(Java Memory Model,JMM)规定了一个线程对共享变量的写入,何时对另一个线程可见。它脱胎于缓存一致性问题的抽象:CPU有多级缓存,Java线程工作内存(线程私有的高速缓存抽象)与主内存之间天然存在不一致的可能。
JMM的核心约束:
原子性:一个操作或多个操作要么全部执行且不被中断,要么全不执行。Java中
synchronized、Lock以及java.util.concurrent.atomic包里的原子类都是用来保证原子性的手段。可见性:一个线程修改了共享变量,其他线程能立刻看到新值。
volatile关键字保证可见性,同时禁止指令重排序,但仍不保证复合操作的原子性。volatile int count; count++就是非原子的——它包含“读取-加1-写回”三步,并发下会丢更新。有序性:指令重排序可能带来意想不到的执行顺序问题。JMM通过
synchronized、volatile以及java.util.concurrent下的显式锁来提供有序性保证。
6.2 volatile与synchronized的正确打开方式
volatile的典型适合场景是“一个线程写、多线程读”的状态标志,比如开关量:
volatile boolean running = true; // 线程A void stop() { running = false; } // 线程B void run() { while (running) { // 执行任务 } }这里volatile保证线程B能立刻看到running的变化,不死循环。如果换成普通变量,线程B可能一直读到自己本地缓存里的旧值,循环永远退不出去。
synchronized更重但也更全面,它同时保证原子性、可见性和有序性。synchronized还可以选择锁的粒度:方法锁、代码块锁、对象锁、Class锁。加锁范围越小,竞争的线程越少,吞吐量越高。但过度缩小锁范围有时反而增加代码复杂度,权衡需要经验。
6.3 面试之外的实战经验:并发调优先看锁竞争
JMM的很多理论,面试题里会问,实际排查中也有对应场景。比如:线上CPU飙到90%以上,你用jstack看线程堆栈,发现大量线程处于BLOCKED或WAITING状态,等某一把锁——那问题就是锁竞争。
常见优化手段:用ReentrantReadWriteLock做读写分离;用ConcurrentHashMap代替HashMap;用LongAdder替代AtomicLong在高并发计数场景下的CAS自旋。但所有这些优化都建立在“你监控到了真实瓶颈”的基础上,靠猜是没意义的。
7. GC调优和监控指标的独门经验:学会看数字,别靠感觉
7.1 吞吐量、延迟、内存占用,调优不可能三角
任何GC调优,都是在“高吞吐量、低延迟、小内存占用”这三者之间做取舍。把-Xmx调很大,可以减少GC频率、提升吞吐量,但一旦Full GC发生,停顿时间可能长得吓人;把G1的MaxGCPauseMillis设成极小值,GC频率上升,系统吞吐量下降。所以调优的本质不是“找到最佳参数”,而是“找到业务可接受的最优平衡”。
一般服务型系统优先级:延迟 > 吞吐量 > 内存占用。因为用户直接感受的是接口响应时间。后台批处理则相反:吞吐量 > 内存占用 > 延迟。
7.2 借助GC日志找出停顿的真凶
GC日志是调优的基础。JDK 8需要显式打印GC日志,建议如下参数:
-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStampsJDK 9及以上,GC日志参数统一到了-Xlog,比如:
-Xlog:gc*:file=/data/logs/gc.log拿到日志后,看几个重点字段:
Young GC(Minor GC):频率和耗时。对象朝生夕灭,Minor GC应该很频繁但每次耗时极短(几十毫秒级别)。如果Minor GC单次耗时几十毫秒甚至上百毫秒,检查
-Xmn大小是否合适、Eden区是否被超大对象瞬间塞满。Full GC:这是红线指标,出现一次就要警惕。Full GC频率增长往往伴随内存泄漏或对象晋升异常。
'promotion failed':晋升失败,说明老年代连续空间不足,需要调大老年代或调整晋升阈值。
'concurrent mode failure':CMS特有的并发失败,说明并发清理阶段空间不够用,导致退化为Serial Old单线程Full GC。碰到这个,一般要增大老年代或者调整CMS触发阈值。
我有一次排查线上卡顿,GC日志显示每次Minor GC间隔越来越短,Eden区回收后存活对象大小每次都超出To Survivor空间,导致对象直接被晋升到老年代,老年代迅速被堆满,触发Full GC。解决办法是增大Survivor区比例,把-XX:SurvivorRatio从8改成4,并适当调大新生代。那之后GC曲线就平稳了。
7.3 用线程转储定位“伪GC问题”
还有一种情况:GC日志正常,但接口还是慢。这时问题可能不在JVM,而在业务线程阻塞。用jstack拿线程快照,查BLOCKED状态的线程等在哪把锁上、WAITING状态的线程等在哪个条件上,就能看到真相。
曾经有一个案例,现象是“每次GC之后应用卡顿十几秒”。GC日志显示STW只有几百毫秒,不符合现象。查线程快照,发现GC安全点触发后,大量线程在等一把DB连接池的锁,锁的持有者又因为慢SQL没返回,线程迟迟不释放连接,连接池空转,应用整体假死。那把DB连接池的锁等到了GC结束,但依然释放不了,所以卡顿远超GC本身。这说明:调优JVM之前,先把应用和依赖层面的瓶颈排查干净,否则你改了GC参数,问题还在那里。
8. 调优之外的进阶意识:何时改用ZGC,何时重新审视代码
8.1 ZGC适合什么样的业务
ZGC从JDK 11开始孵化,到JDK 16、17已经相当成熟。它的优势是GC停顿时间几乎与堆大小无关,特别适合以下场景:
- 堆内存16GB以上、甚至数百GB服务。
- 业务对延迟极其敏感,接口P99不能因为GC抖动。
- 长期运行的服务进程,不希望Full GC造成突发性卡顿。
如果你的服务堆内存小于4GB,G1完全够用,没必要追新。ZGC为低延迟付出的是CPU占用和内存占用更高的代价,小堆上没有明显收益。
8.2 有些“调优”其实该写代码
最后说点扎心话:很多调优需求的出现,是因为代码写得不好。
线程被频繁创建销毁,于是你用线程池;对象在循环里大量创建,于是出现GC压力;频繁字符串拼接,产生了大量临时String对象。如果你的业务代码能从根上减少不必要的对象创建,那比调所有GC参数都有效。你写代码时多想想:这个方法每秒被调用多少次?每次创建的对象生命周期多长?值不值得缓存复用?
这些意识,比记住任何一条JVM参数都值钱。这也是为什么我始终觉得,JVM不只是一门“调优技术”,更是一种理解程序运行方式的方法论。学会用它,你排查任何线上问题,都会比“来回重启”高一个段位。