一、为什么要学 JVM作为 Java 开发者你可能写过无数业务代码但遇到这些问题时是否感到无力线上服务突然 OOM dump 文件几十 G不知道从哪下手接口响应越来越慢CPU 飙高线程 dump 看不出问题同样的代码本地跑得好好的线上就频繁 Full GC面试被问到 JVM 调优经验只能说”调过堆内存大小”理解 JVM 是成为高级 Java 工程师的必经之路。它不只是面试八股文更是解决线上问题的核心能力。二、JVM 整体架构┌─────────────────────────────────────────────────────────┐ │ 类加载器子系统 │ │ Bootstrap → Extension → Application → 自定义类加载器 │ ├─────────────────────────────────────────────────────────┤ │ 运行时数据区 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 方法区 │ │ 堆内存 │ │ 虚拟机栈 │ │ 本地方法栈│ │ │ │ (元空间) │ │ (新生代/ │ │ │ │ │ │ │ │ │ │ 老年代) │ │ │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ ┌──────────┐ │ │ │ 程序计数器│ │ │ └──────────┘ │ ├─────────────────────────────────────────────────────────┤ │ 执行引擎 │ │ 解释器 → JIT 编译器 → 垃圾回收器 │ ├─────────────────────────────────────────────────────────┤ │ 本地方法接口 │ │ JNI │ └─────────────────────────────────────────────────────────┘三、运行时数据区详解3.1 堆内存Heap堆是 JVM 管理的最大一块内存所有对象实例和数组都在这里分配。堆内存结构┌─────────────────────────────────────────┐ │ 老年代 (Old Generation) │ ← 占用 2/3 堆空间 │ (存放长期存活的对象Full GC 区域) │ ├─────────────────────────────────────────┤ │ Eden │ Survivor0 │ Survivor1 │ ← 新生代占用 1/3 堆空间 │ (8/10) │ (1/10) │ (1/10) │ │ │ From │ To │ └─────────────────────────────────────────┘新生代Young GenerationEden 区新对象首先分配在这里占新生代 8⁄10Survivor 区两块相同大小的区域From 和 To占新生代各 1⁄10对象在 Eden 区经历 Minor GC 后存活会移到 Survivor 区老年代Old Generation存放长期存活的对象对象在 Survivor 区经历一定次数 GC 后晋升到老年代老年代满了会触发 Full GC或 Major GC对象分配与晋升流程新对象 → Eden 区 ↓ Eden 满了 → Minor GC ↓ 存活对象 → Survivor From 区 (年龄 1) ↓ 下次 Minor GC → Survivor To 区 (年龄 1) ↓ 年龄达到阈值 (默认 15) → 老年代 ↓ 老年代满了 → Full GC代码示例观察对象晋升public class HeapAllocation { // -Xms20m -Xmx20m -Xmn10m -XX:PrintGCDetails -XX:SurvivorRatio8 // 堆 20M新生代 10MEden 8MSurvivor 各 1M private static final int _1MB 1024 * 1024; public static void main(String[] args) { byte[] allocation1 new byte[2 * _1MB]; // Eden 区 byte[] allocation2 new byte[2 * _1MB]; // Eden 区 byte[] allocation3 new byte[2 * _1MB]; // Eden 区 // 触发 Minor GC前面三个对象晋升到老年代 byte[] allocation4 new byte[4 * _1MB]; // Eden 区放不下触发 GC } }大对象直接进入老年代// -XX:PretenureSizeThreshold3145728 (3MB) // 大于 3MB 的对象直接在老年代分配 byte[] bigObject new byte[4 * 1024 * 1024]; // 4MB直接进入老年代为什么要这样设计避免大对象在 Eden 和 Survivor 之间来回复制浪费性能大对象生命周期通常较长直接放老年代更合理3.2 方法区Method Area存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等。JDK 8 之前 vs JDK 8 之后版本实现内存位置垃圾回收JDK 7 及之前永久代PermGenJVM 内存Full GC 回收JDK 8 及之后元空间Metaspace本地内存独立回收元空间Metaspace┌─────────────────────────────────────┐ │ 元空间 (Metaspace) │ │ ┌─────────┐ ┌─────────┐ ┌────────┐ │ │ │ 类元数据 │ │ 方法元数据│ │ 常量池 │ │ │ └─────────┘ └─────────┘ └────────┘ │ │ ┌─────────┐ ┌─────────┐ │ │ │ 字段元数据│ │ 静态变量 │ │ │ └─────────┘ └─────────┘ │ └─────────────────────────────────────┘元空间大小设置# 初始元空间大小 -XX:MetaspaceSize128m # 最大元空间大小 -XX:MaxMetaspaceSize256m为什么从永久代改为元空间永久代有固定大小上限容易 OOMjava.lang.OutOfMemoryError: PermGen space元空间使用本地内存大小只受限于物理内存元空间有独立的垃圾回收机制不需要等待 Full GC运行时常量池public class ConstantPoolExample { public static void main(String[] args) { String s1 hello; // 常量池 String s2 hello; // 常量池s1 s2 String s3 new String(hello); // 堆中新建对象 System.out.println(s1 s2); // true System.out.println(s1 s3); // false System.out.println(s1 s3.intern()); // trueintern() 把堆中字符串放入常量池 } }3.3 虚拟机栈VM Stack每个线程私有的内存区域存储栈帧Stack Frame。栈帧结构┌─────────────────────────┐ │ 栈帧 (Stack Frame) │ ← 每个方法调用创建一个栈帧 ├─────────────────────────┤ │ 局部变量表 (Local Variables) │ │ - 基本数据类型 │ │ - 对象引用 │ │ - returnAddress │ ├─────────────────────────┤ │ 操作数栈 (Operand Stack) │ │ - 方法执行的工作区 │ ├─────────────────────────┤ │ 动态链接 (Dynamic Linking) │ │ - 指向运行时常量池的方法引用 │ ├─────────────────────────┤ │ 方法返回地址 (Return Address)│ │ - 方法执行完返回到哪里 │ └─────────────────────────┘栈溢出示例public class StackOverflowExample { private int stackDepth 0; public void recursiveMethod() { stackDepth; recursiveMethod(); // 无限递归 } public static void main(String[] args) { try { new StackOverflowExample().recursiveMethod(); } catch (StackOverflowError e) { System.out.println(Stack depth: stackDepth); // 默认栈大小下大约递归 1万 次就会溢出 } } }设置栈大小# 设置线程栈大小为 1MB -Xss1m3.4 本地方法栈Native Method Stack为虚拟机使用到的 Native 方法服务。JDK 中有很多 Native 方法比如// java.lang.Object 中的 native 方法 public native int hashCode(); public native Object clone(); // java.lang.Thread 中的 native 方法 public static native void yield(); public static native void sleep(long millis); // java.lang.System 中的 native 方法 public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);HotSpot 虚拟机中本地方法栈和虚拟机栈合二为一。3.5 程序计数器Program Counter Register当前线程所执行的字节码的行号指示器。线程私有每个线程独立占用内存很小不会发生 OOM如果执行的是 Native 方法计数器值为空Undefined为什么需要程序计数器Java 是多线程的线程切换后需要知道从哪里继续执行。程序计数器记录了当前线程执行的位置。四、垃圾回收机制4.1 判断对象是否存活引用计数法Python 使用Java 不用对象 A 被引用次数 2 对象 B 被引用次数 1被 A 引用 对象 C 被引用次数 0 → 可回收缺点无法解决循环引用问题A.instance B; B.instance A; // A 和 B 互相引用引用计数都不为 0但应该被回收可达性分析算法Java 使用GC Roots ├── 虚拟机栈中引用的对象 ├── 方法区中类静态属性引用的对象 ├── 方法区中常量引用的对象 ├── 本地方法栈中 JNI 引用的对象 ├── 所有被同步锁持有的对象 └── JVM 内部的引用基本数据类型对应的 Class 对象 从 GC Roots 出发沿着引用链搜索不可达的对象就是垃圾代码示例public class GcRootsExample { private static Object staticObject new Object(); // GC Root静态变量 public void method() { Object localObject new Object(); // GC Root局部变量 // method 执行完localObject 不再被引用可以被回收 // staticObject 一直存在直到类卸载 } }4.2 垃圾回收算法标记-清除算法Mark-Sweep阶段1标记 - 从 GC Roots 遍历标记所有可达对象 阶段2清除 - 回收未被标记的对象 内存状态 [存活][垃圾][存活][垃圾][垃圾][存活] ↓ 清除后 [存活][空闲][存活][空闲][空闲][存活] 缺点产生内存碎片复制算法Copying将内存分为两块From 和 To 阶段1标记存活对象 阶段2将存活对象复制到 To 区 阶段3清空 From 区 阶段4交换 From 和 To [存活][垃圾][存活][垃圾] From [空][空][空][空] To ↓ 复制后 [空][空][空][空] From下次用 [存活][存活][空][空] To下次变成 From 优点无内存碎片 缺点内存利用率只有 50%新生代使用复制算法因为新生代对象存活率低复制开销小。标记-整理算法Mark-Compact阶段1标记存活对象 阶段2将存活对象向一端移动 阶段3清理边界外的内存 [存活][垃圾][存活][垃圾][垃圾][存活] ↓ 整理后 [存活][存活][存活][空闲][空闲][空闲] 优点无内存碎片内存利用率高 缺点移动对象需要更新引用地址开销较大老年代使用标记-整理算法因为老年代对象存活率高复制算法代价太大。4.3 垃圾收集器新生代收集器Serial串行单线程收集器收集时暂停所有工作线程Stop The World 适用单 CPU 环境客户端模式 参数-XX:UseSerialGCParNew并行Serial 的多线程版本多个线程并行收集 适用多 CPU 环境配合 CMS 使用 参数-XX:UseParNewGCParallel Scavenge吞吐量优先目标是达到可控的吞吐量用户代码时间 / 总时间 参数 -XX:UseParallelGC -XX:MaxGCPauseMillis200 最大停顿时间 -XX:GCTimeRatio99 吞吐量 99%即 GC 时间占 1%老年代收集器Serial OldSerial 的老年代版本单线程标记-整理算法Parallel OldParallel Scavenge 的老年代版本多线程标记-整理算法 参数-XX:UseParallelOldGCCMSConcurrent Mark Sweep目标最短停顿时间 算法标记-清除会产生碎片 执行过程 1. 初始标记STW很快 2. 并发标记与用户线程并发执行 3. 重新标记STW比初始标记长但比并发标记短 4. 并发清除与用户线程并发执行 参数-XX:UseConcMarkSweepGC 缺点 - 对 CPU 资源敏感 - 无法处理浮动垃圾并发清理时产生的新垃圾 - 产生内存碎片G1 收集器Garbage FirstJDK 9 后的默认收集器兼顾吞吐量和停顿时间 设计思想 - 将堆划分为多个 Region1MB ~ 32MB - 每个 Region 可以是 Eden、Survivor 或 Old - 优先回收垃圾最多的 RegionGarbage First 执行过程 1. 初始标记STW 2. 并发标记 3. 最终标记STW 4. 筛选回收STW并行执行 参数 -XX:UseG1GC -XX:MaxGCPauseMillis200 期望最大停顿时间 优点 - 可预测的停顿时间 - 无内存碎片整体看是标记-整理局部看是复制 - 适用于大堆内存6GB 以上ZGC 和 Shenandoah低延迟目标停顿时间不超过 10ms且与堆大小无关 ZGC 参数-XX:UseZGC Shenandoah 参数-XX:UseShenandoahGC 适用场景 - 超大堆内存TB 级别 - 对延迟极度敏感的应用金融交易、游戏服务器 JDK 版本要求 - ZGCJDK 11生产可用JDK 15 正式支持 - ShenandoahJDK 12Red Hat 开发4.4 垃圾收集器选择建议场景推荐收集器参数单核/小内存Serial Serial Old-XX:UseSerialGC吞吐量为先后台计算Parallel Scavenge Parallel Old-XX:UseParallelGC低延迟为先Web 应用CMS / G1-XX:UseG1GC大堆内存6GG1-XX:UseG1GC超大堆/极致低延迟ZGC / Shenandoah-XX:UseZGC五、JVM 参数与调优5.1 内存参数# 堆内存设置 -Xms4g # 初始堆大小 4GB -Xmx4g # 最大堆大小 4GB建议 Xms Xmx避免动态扩缩容 -Xmn1g # 新生代大小 1GB # 元空间设置 -XX:MetaspaceSize128m -XX:MaxMetaspaceSize256m # 栈大小 -Xss1m # 每个线程栈大小 1MB # 直接内存NIO 使用 -XX:MaxDirectMemorySize1g5.2 GC 参数# 使用 G1 收集器 -XX:UseG1GC -XX:MaxGCPauseMillis200 # 打印 GC 日志 -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/var/log/app/gc.log # GC 日志文件轮转JDK 9 -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles10 -XX:GCLogFileSize100M # 发生 OOM 时自动生成堆 dump -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/var/log/app/heapdump.hprof5.3 调优案例分析案例一频繁 Full GC现象线上服务每隔几分钟就 Full GC停顿时间 2-3 秒排查# 查看 GC 日志 [Full GC (Allocation Failure) [PSYoungGen: 153600K-0K(179200K)] [ParOldGen: 409600K-412300K(409600K)] 563200K-412300K(588800K), [Metaspace: 67890K-67890K(110592K)], 2.3456789 secs]分析老年代 409600K → 412300K回收前后几乎没变化说明老年代对象都是存活的没有可回收的垃圾老年代满了新对象晋升不进来触发 Full GC原因老年代空间太小或者对象晋升太快解决# 增大老年代空间 -Xms8g -Xmx8g -Xmn3g # 老年代 8g - 3g 5g # 或者增大晋升阈值让对象在新生代多待一会儿 -XX:MaxTenuringThreshold15 # 默认 15可以适当增大案例二Metaspace OOM现象服务运行一段时间后 OOM错误信息java.lang.OutOfMemoryError: Metaspace原因动态生成类过多如 CGLIB 代理、反射、动态脚本元空间被占满解决# 增大元空间上限 -XX:MaxMetaspaceSize512m # 或者检查代码是否有类加载泄漏 # 常见原因动态代理类没有正确卸载、OSGi 热部署等案例三Young GC 频繁现象每秒都有 Young GC每次停顿 50ms排查# GC 日志显示 [GC (Allocation Failure) [PSYoungGen: 153600K-2048K(179200K)] 153600K-2048K(588800K), 0.0501234 secs]分析新生代 179200K每次 GC 后从 153600K 降到 2048K说明新生代空间太小对象很快填满频繁触发 GC解决# 增大新生代空间 -Xmn2g # 原来是 1g改为 2g # 或者调整 Eden 和 Survivor 比例 -XX:SurvivorRatio6 # Eden : Survivor 6 : 1 : 1六、JVM 监控与诊断工具6.1 命令行工具jps查看 Java 进程$ jps -lvm 12345 com.example.Application -Xms4g -Xmx4g 12346 sun.tools.jps.Jps -lvmjstat查看 GC 统计# 每隔 1 秒打印一次 GC 统计共打印 10 次 $ jstat -gcutil 12345 1000 10 S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 99.99 45.23 67.89 98.12 95.67 1234 12.345 12 34.567 46.912字段说明S0/S1Survivor 0/1 区使用率EEden 区使用率OOld 区使用率MMetaspace 使用率YGC/YGCTYoung GC 次数/总耗时FGC/FGCTFull GC 次数/总耗时jmap生成堆 dump# 生成堆 dump 文件 $ jmap -dump:formatb,fileheap.hprof 12345 # 查看堆中对象统计 $ jmap -histo 12345 | head -20 num #instances #bytes class name ---------------------------------------------- 1: 1234567 98765360 [B 2: 234567 56245680 java.lang.String 3: 123456 29629440 java.util.HashMap$Nodejstack查看线程栈# 打印线程 dump $ jstack 12345 thread_dump.txt # 查看死锁 $ jstack -l 12345 | grep -A 50 Found one Java-level deadlock6.2 可视化工具VisualVMJDK 自带$ jvisualvm功能监控 CPU、内存、GC生成和分析堆 dump线程分析采样分析热点方法MATMemory Analyzer Tool专门分析堆 dump 的工具功能强大# 打开 heap.hprof 文件 # 自动分析内存泄漏嫌疑 # 查看 Dominator Tree、Histogram、Leak SuspectsArthas阿里开源# attach 到目标进程 $ java -jar arthas-boot.jar # 常用命令 top # 查看线程 CPU 占用 heapdump # 生成堆 dump jad com.example.Service # 反编译类 watch com.example.Service getOrder {params,returnObj} # 方法入参和返回值监控七、总结理解 JVM 是 Java 开发者的必修课。本文从内存结构、垃圾回收、调优实践三个层面进行了系统讲解运行时数据区堆、方法区、栈、程序计数器各司其职垃圾回收可达性分析、三种算法、多种收集器的选择调优实战参数设置、案例分析、监控工具的使用掌握这些知识面对线上 OOM、GC 问题时就能做到心中有数、手中有招。