Java虚拟机(JVM)从入门到精通:内存管理、垃圾回收、性能调优实战

本文涵盖:JVM组成部分、堆与栈区别、垃圾回收算法(标记清除/复制/标记整理/分代)、OOM原因与处理、堆外内存、常量池、JIT即时编译、逃逸分析、AOT前瞻、垃圾收集器(Serial/Parallel/CMS/G1/ZGC/Shenandoah)、PermGen与Metaspace演进、三色标记算法、Young GC/Old GC/Full GC/Mixed GC详解、PLAB机制、新生代S0/S1/Eden区设计、G1/ZGC工作流程、GC调优目标与参数、分析工具(jstat/jmap/jstack/VisualVM/Arthas/MAT)、内存泄漏分析方法、Java执行流程完整解析。

前言:为什么每个Java开发者都必须学习JVM?

你写的new Object(),到底在内存里发生了什么?为什么程序跑着跑着就OutOfMemoryErrorSystem.gc()真的会立即回收垃圾吗?一个String对象到底占多少内存?如果你的简历上写着“熟悉Java”,面试官大概率会从JVM开始拷问。

JVM(Java Virtual Machine)是Java跨平台特性的基石,更是性能调优、故障排查的核心。不懂JVM,你就像个只会用方向盘却不懂引擎的司机——能开,但永远不知道如何发挥极致性能。

本文将通过大量代码示例、内存图解、实战案例,带你从零开始彻底掌握JVM。全文约2.5万字,建议收藏反复阅读。


第一章:Java执行流程 —— 从源码到机器码

在深入JVM内部之前,先搞清楚一个Java程序是怎么跑起来的。

1.1 完整的Java执行流程

.java 源文件 → javac 编译 → .class 字节码 → 类加载器 → JVM执行引擎 → 机器码

步骤拆解

  1. 编写源码Hello.java
  2. 编译javac Hello.java → 生成Hello.class(字节码,不是机器码)
  3. 类加载:JVM的类加载器读取.class文件,将其加载到内存
  4. 验证:确保字节码安全(格式正确、没有非法跳转等)
  5. 准备:为静态变量分配内存并设置默认初始值(不是代码中的赋值)
  6. 解析:将符号引用替换为直接引用(比如方法调用找到真实地址)
  7. 初始化:执行类构造器<clinit>(静态变量赋值、静态代码块)
  8. 执行:执行引擎(解释器 + JIT编译器)将字节码转换为机器码执行

1.2 演示:手动模拟编译执行过程

// Hello.java
public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello JVM!");
    }
}
# 编译
javac Hello.java

# 查看字节码
javap -c Hello.class

# 输出(部分):
public class Hello {
  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #3  // String Hello JVM!
       5: invokevirtual #4  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}

# 运行
java Hello

解释javap反汇编看到的ldcinvokevirtual等就是字节码指令。JVM执行引擎会逐条解释执行或编译后执行。

1.3 解释执行 vs JIT编译

  • 解释执行:每条字节码指令直接翻译成机器码执行,启动快,但运行慢。
  • JIT(Just-In-Time)编译:将热点代码(频繁执行的方法/循环)编译成本地机器码并缓存,后续直接执行机器码,整体性能高。

演示热点代码

public class JITDemo {
    public static void main(String[] args) {
        for (int i = 0; i < 10000; i++) { // 循环10000次,方法会被JIT编译
            add();
        }
    }
    private static int add() {
        int a = 1, b = 2;
        return a + b;
    }
}

加上JVM参数-XX:+PrintCompilation可以看到编译日志。


第二章:JVM组成部分 —— 一座工厂的流水线

JVM可以理解成一个完整的计算机系统,它的内存布局是理解GC和性能的核心。

2.1 JVM整体架构图(文字版)

+-------------------------------+
|        类加载子系统            |
|  (加载、链接、初始化.class)    |
+-------------------------------+
               ↓
+-------------------------------+
|          运行时数据区          |
|  +------+------+------+------+|
|  | 堆   | 栈   | 方法区| PC   ||
|  |(GC区)|(线程)|(元空间)|寄存器||
|  +------+------+------+------+|
|  +------------------+         |
|  |  本地方法栈      |         |
|  +------------------+         |
+-------------------------------+
               ↓
+-------------------------------+
|         执行引擎               |
|  (解释器 + JIT编译器 + GC)     |
+-------------------------------+

2.2 五大运行时数据区详解

区域 线程共享 存放内容 是否GC OOM风险
堆(Heap) 对象实例、数组 高(对象过多)
方法区(Metaspace) 类元信息、常量池、静态变量 是(卸载类) 中(加载过多类)
虚拟机栈(VM Stack) 否(每个线程私有) 栈帧(局部变量、操作数栈、方法出口) 中(递归过深)
本地方法栈 native方法信息
程序计数器(PC) 当前线程执行的字节码行号

重点理解

  • :垃圾回收的主战场,我们平时new的对象都在这。
  • :每个方法调用对应一个栈帧,方法结束栈帧销毁。局部变量存在栈上,对象引用在栈,对象本身在堆。

第三章:堆和栈的区别—— 从“快递仓库”和“便签本”理解

用一个比喻帮初学者彻底分清:

  • 堆(Heap):就像一个大仓库,你可以在里面放任何大小的货物(对象),但找东西慢,需要GC来打扫。仓库里的货物可以被多个工人(线程)共享。
  • 栈(Stack):就像你手边的一叠便签本,每个便签记录一个方法调用信息(局部变量、参数等)。写和撕掉都极快,但空间很小,用完即毁。每个工人(线程)有自己的便签本,不共享。

3.1 代码演示堆和栈的存储

public class HeapStackDemo {
    int instanceVar = 10;  // 实例变量,在堆上(随着对象存在)
    static int classVar = 20; // 静态变量,在方法区

    public void method() {
        int localVar = 30;   // 局部变量,在栈帧中
        String str = new String("hello"); // str引用在栈,String对象在堆
    }

    public static void main(String[] args) {
        HeapStackDemo obj = new HeapStackDemo(); // obj引用在栈,对象在堆
        obj.method();
    }
}

内存分配图

栈(线程私有)                堆(线程共享)
+--------------+           +-------------------+
| main栈帧      |           | HeapStackDemo对象  |
| 局部变量: obj | --------> | instanceVar=10    |
| (引用地址)    |           +-------------------+
+--------------+           | String对象        |
| method栈帧    |           | char[] "hello"    |
| 局部变量:     |           +-------------------+
| localVar=30  |           | 方法区(元空间)    |
| str引用      | ---------> | 类元信息          |
+--------------+           | static classVar=20|
                           | 常量池 "hello"    |
                           +-------------------+

3.2 栈溢出(StackOverflowError)和堆溢出(OutOfMemoryError)演示

// 栈溢出:递归无出口
public class StackOverflowDemo {
    public static void recursive() {
        recursive(); // 无限递归
    }
    public static void main(String[] args) {
        recursive();
    }
}
// 运行结果:Exception in thread "main" java.lang.StackOverflowError

// 堆溢出:无限创建对象
public class HeapOOMDemo {
    static class BigObject { byte[] arr = new byte[1024 * 1024]; } // 1MB
    public static void main(String[] args) {
        List<BigObject> list = new ArrayList<>();
        while (true) {
            list.add(new BigObject()); // 一直加直到堆满
        }
    }
}
// 运行需设置堆大小:java -Xms10m -Xmx10m HeapOOMDemo
// 结果:java.lang.OutOfMemoryError: Java heap space

第四章:常量池 —— 一块容易误解的区域

常量池分为三种:Class文件常量池运行时常量池字符串常量池

4.1 三种常量池的关系

  1. Class文件常量池:每个.class文件中有一个常量池表,存放字面量(文本字符串、final常量)和符号引用(类名、方法名)。
  2. 运行时常量池:类加载后,Class文件常量池进入方法区(JDK8后是Metaspace),变成运行时常量池。
  3. 字符串常量池:专门存放字符串对象(JDK7之后移到堆中)。它是全局的。

4.2 常见面试题:String创建几个对象?

public class StringPoolDemo {
    public static void main(String[] args) {
        String s1 = "hello";                // 直接量:在常量池中创建(如果不存在)
        String s2 = new String("hello");    // 堆中创建新对象,常量池中已有"hello"
        String s3 = "he" + "llo";           // 编译期常量折叠,还是常量池中的"hello"
        String s4 = new String("he") + new String("llo"); // 堆中创建,不会放入常量池(除非intern)

        System.out.println(s1 == s2);       // false
        System.out.println(s1 == s3);       // true
        System.out.println(s1 == s4.intern()); // true(intern把s4加入常量池并返回引用)
    }
}

内存图:JDK7+字符串常量池在堆中,存放引用或对象。"hello"字面量在类加载时放入常量池。new String("hello")会创建两个对象:一个在常量池(如果不存在),一个在堆。


第五章:堆外内存 —— 越过GC的“飞地”

堆外内存(Off-Heap Memory)是指直接通过UnsafeByteBuffer.allocateDirect()分配的内存,不受JVM堆管理,不参与GC。

5.1 为什么要用堆外内存?

  • 减少GC压力:大文件、网络缓冲区如果放堆内,GC会频繁扫描,堆外内存不归GC管。
  • 零拷贝:数据可以直接从内核空间到网卡,跳过Java堆和操作系统缓冲区之间的拷贝。
  • 长期存活的大对象:避免进入老年代。

5.2 使用示例

import java.nio.ByteBuffer;

public class DirectBufferDemo {
    public static void main(String[] args) {
        // 分配1MB堆外内存
        ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024);
        // 查看是否堆外
        System.out.println(directBuffer.isDirect()); // true
        // 手动释放(等待GC不可控,最好手动)
        // sun.misc.Cleaner cleaner = ((DirectBuffer) directBuffer).cleaner();
        // if (cleaner != null) cleaner.clean();
        
        // 直接内存也是会OOM的!设置 -XX:MaxDirectMemorySize=10m
    }
}

注意:堆外内存的释放依赖Cleaner机制(虚引用),不一定及时,大量使用可能OutOfMemoryError: Direct buffer memory


第六章:垃圾回收算法 —— 给堆做“大扫除”

GC要解决三个问题:哪些内存要回收?什么时候回收?如何回收?

6.1 判断对象是否“死亡”的两种方法

引用计数法(Java不用)

每个对象维护一个计数器,有引用就+1,引用失效-1。缺点:无法解决循环引用。

// 循环引用示例
class Ref { Ref ref; }
Ref a = new Ref();
Ref b = new Ref();
a.ref = b;
b.ref = a;
// 引用计数永远不为0,内存泄漏
可达性分析算法(Java使用)

从GC Roots(栈中的局部变量、静态变量、JNI引用等)向下搜索,搜索路径称为引用链。不在链上的对象就是不可达,可回收。

GC Roots包括

  • 虚拟机栈中引用的对象
  • 方法区中静态变量引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用
  • 活跃线程

6.2 四种垃圾回收算法

算法 原理 优点 缺点 适用
标记-清除 标记不可达对象,然后清除 简单 产生内存碎片,效率不高 老年代(搭配CMS)
复制算法 将内存等分两块,只使用一块,存活对象复制到另一块 无碎片,高效 内存利用率50% 新生代(S0/S1)
标记-整理 标记存活对象,然后向一端移动,清理边界外 无碎片 移动对象开销大 老年代
分代收集 新生代复制算法,老年代标记整理/标记清除 高效 需要分代设计 大部分JVM

6.3 详细图解:复制算法如何工作(新生代)

初始状态:Eden区和两个Survivor区,比例8:1:1
+----------+------+------+
|  Eden    | S0   | S1   |
| (80%)    |(10%) |(10%) |
+----------+------+------+

GC后:
Eden和S0中存活的对象 → 复制到S1
清空Eden和S0,交换S0和S1角色

代码模拟(概念):

// 假设创建大量短生命周期对象
for (int i = 0; i < 10000; i++) {
    byte[] b = new byte[1024]; // 很快变成垃圾
    if (i % 1000 == 0) {
        Thread.sleep(10);
    }
}
// 查看GC日志:-XX:+PrintGCDetails 可以看到新生代频繁GC,且每次存活对象很少

第七章:新生代的内存布局 —— Eden、S0、S1之谜

7.1 为什么要有Survivor区?

如果没有Survivor,每次Minor GC后,存活对象直接进入老年代,老年代会很快填满,导致频繁Full GC。Survivor作为“缓冲”:让对象在新生代多活几轮,如果熬过一定年龄(默认15)才晋升老年代。

7.2 为什么需要两块Survivor(S0和S1)?

复制算法需要一块空闲区域存放存活对象。如果只有一块,当Eden和S0里存活对象要复制时,没地方放。两块交替角色,保证每次总有一块是空的。

工作流程

  1. 新对象分配在Eden(以及一块Survivor,初始为空)
  2. Minor GC:Eden和S0存活对象 → 复制到S1,年龄+1
  3. 清空Eden和S0,交换角色:S1变成下一个周期的S0
  4. 重复步骤2-3,直到对象年龄达到阈值(-XX:MaxTenuringThreshold),晋升到老年代

7.3 参数调整

# 新生代大小
-Xmn512m
# Eden与Survivor比例(默认8:1:1)
-XX:SurvivorRatio=8
# 晋升年龄阈值
-XX:MaxTenuringThreshold=15

演示

// 设置JVM参数:-Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails
public class SurvivorDemo {
    public static void main(String[] args) {
        byte[] b1 = new byte[2 * 1024 * 1024];
        byte[] b2 = new byte[2 * 1024 * 1024];
        byte[] b3 = new byte[2 * 1024 * 1024];
        byte[] b4 = new byte[2 * 1024 * 1024]; // 触发Minor GC
    }
}
// 日志中可以看到 survivor space 区域的使用情况

第八章:分代垃圾回收 —— Young GC、Old GC、Full GC、Mixed GC

8.1 概念区分

GC类型 发生区域 触发条件 影响
Young GC / Minor GC 新生代 Eden区满 速度快,STW短(通常几毫秒)
Old GC 老年代 CMS时老年代满 仅CMS有此概念,会STW
Full GC 整个堆 + 方法区 老年代满、System.gc()、Metaspace满、晋升失败 STW时间长(秒级)
Mixed GC 新生代 + 部分老年代 G1特有(当老年代占堆比例超过阈值) 时间可控

8.2 触发Full GC的常见场景

  1. System.gc():建议触发Full GC(可通过-XX:+DisableExplicitGC禁用)。
  2. 老年代空间不足:Minor GC后晋升对象超过老年代剩余。
  3. 永久代/Metaspace不足:加载过多类(如动态代理、JSP)。
  4. CMS GC时出现Concurrent Mode Failure:并发收集时又有新对象晋升,触发Full GC。
  5. 大对象直接分配在老年代(超过-XX:PretenureSizeThreshold)。

8.3 代码演示Young GC和Full GC

// 运行参数:-Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails
public class GCDemo {
    public static void main(String[] args) {
        // 分配3个2MB数组,Eden区大约8MB
        byte[] arr1 = new byte[2 * 1024 * 1024];
        byte[] arr2 = new byte[2 * 1024 * 1024];
        byte[] arr3 = new byte[2 * 1024 * 1024];
        // 此时Eden可能满或接近满
        
        byte[] arr4 = new byte[4 * 1024 * 1024]; // 触发Young GC
        // 如果Young GC后仍不够,尝试分配老年代,老年代也不够则Full GC
    }
}

第九章:垃圾收集器详解 —— 从Serial到ZGC

9.1 垃圾收集器家族谱(JDK8-21)

新生代收集器                老年代收集器
Serial (复制)    ←→    Serial Old (标记整理)
Parallel Scavenge ←→   Parallel Old (标记整理)
ParNew           ←→    CMS (标记清除,并发)
          G1 (逻辑分代,物理分区,整体标记整理局部复制)
          ZGC (并发,分页,染色指针)
          Shenandoah

9.2 各收集器特点及适用场景

收集器 算法 并发 STW特点 适用场景
Serial 复制 单线程,STW长 客户端模式、单CPU
ParNew 复制 多线程并行,STW 配合CMS,服务端
Parallel Scavenge 复制 多线程并行,可控制吞吐量 后台运算、不关注延迟
Serial Old 标记整理 单线程 后备方案
Parallel Old 标记整理 多线程 吞吐量优先
CMS 标记清除 两次短暂STW 互联网、响应时间优先(已被G1替代)
G1 局部复制+整体标记整理 可预测停顿 大堆(>4GB),JDK9+默认
ZGC 并发,染色指针 亚毫秒级 超大堆(百GB-TB),低延迟
Shenandoah 并发转发 亚毫秒级 RedHat贡献,同ZGC竞争

9.3 实战:切换垃圾收集器

# 使用Parallel Scavenge + Parallel Old(JDK8默认)
java -XX:+UseParallelGC -jar app.jar

# 使用G1(JDK9+默认)
java -XX:+UseG1GC -jar app.jar

# 使用ZGC(需要解锁实验性)
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -jar app.jar

# 使用CMS(已废弃,在JDK14中移除)
java -XX:+UseConcMarkSweepGC -jar app.jar

第十章:CMS垃圾回收流程 —— 以“并发”减少停顿

CMS(Concurrent Mark Sweep)是第一款真正意义上的并发收集器,目标是降低老年代GC停顿。

10.1 CMS的四个阶段

  1. 初始标记(STW):标记GC Roots直接关联的对象,速度极快。
  2. 并发标记:从这些对象开始遍历整个对象图,与用户线程并发执行。
  3. 重新标记(STW):修正并发标记期间用户线程导致变动的对象,稍长但仍可控。
  4. 并发清除:清除标记为垃圾的对象,并发。

缺点

  • CPU敏感,并发阶段占用CPU
  • 无法处理浮动垃圾(并发标记阶段新产生的垃圾只能下一次清理)
  • 内存碎片问题(标记清除不整理,可用-XX:+UseCMSCompactAtFullCollection
  • 可能因晋升失败触发Serial Old Full GC

10.2 图解CMS流程(文字)

用户线程 ─────────────────────```
GC线程:初始标记(stw) → 并发标记 → 重新标记(stw) → 并发清除
          (短)          (长)       (稍长)          (并发)

第十一章:G1垃圾回收器 —— 分区+预测停顿

G1(Garbage First)将堆划分成多个大小相等的Region(1-32MB),不再分代物理隔离,但逻辑上仍有Eden、Survivor、Old、Humongous(大对象)Region。

11.1 G1的四个关键设计

  1. Region分区:每个Region可以是Eden、Survivor、Old、Humongous。
  2. 停顿预测模型:根据历史数据,可设定目标停顿时间(-XX:MaxGCPauseMillis=200),G1会自动选择一组Region回收,保证耗时不超过目标。
  3. RSet(Remembered Set):每个Region记录外部指向本Region的引用,避免全堆扫描。
  4. SATB(Snapshot-At-The-Beginning):并发标记算法基于快照,确保正确性。

11.2 G1的垃圾回收流程

Young GC
  • 当Eden Region满,触发Young GC(并行,STW)
  • 回收所有Eden Region和Survivor Region,存活对象复制到新的Survivor或晋升Old
  • 耗时较短,与Parallel Scavenger类似
Mixed GC
  • 当老年代占堆比例达到阈值(-XX:InitiatingHeapOccupancyPercent=45),开始并发标记
  • 标记结束后,进行Mixed GC:回收所有Young Region + 部分高收益(垃圾多)的Old Region
  • 目标是在停顿时间内最大化回收垃圾
Full GC(最糟糕)
  • 当Mixed GC来不及回收,或RSet占内存过大,会触发Serial Old Full GC(单线程,很慢)
  • 应避免

11.3 G1核心参数

# 启用G1
-XX:+UseG1GC
# 目标停顿时间(默认200ms)
-XX:MaxGCPauseMillis=100
# Region大小(1-32MB,2的幂)
-XX:G1HeapRegionSize=16m
# 并发标记阈值
-XX:InitiatingHeapOccupancyPercent=45
# 并行线程数
-XX:ParallelGCThreads=8
# 并发线程数
-XX:ConcGCThreads=2

11.4 为什么G1不需要全堆扫描?

因为**RSet(Remembered Set)**的存在。每个Region都有RSet,里面存储了"有哪些外部Region引用了本Region内的对象"。这样在垃圾回收时,GC只需要扫描GC Roots + 所有Region的RSet,而不必扫描整个堆的所有对象。

类比:大学里要知道哪些外校学生来听过课,不用查全校学生的档案,只需在教室门口记录外来访客名单(RSet)。


第十二章:ZGC —— 亚毫秒级延迟的野心

ZGC(Z Garbage Collector)设计目标是:无论堆多大(TB级),GC停顿不超过10ms。

12.1 ZGC关键技术

  1. 染色指针(Colored Pointers):在64位指针中用高几位存储状态(Finalizable、Remapped、Marked0、Marked1),GC时通过修改指针而非对象头来记录信息。
  2. 读屏障(Load Barrier):每次读取对象引用时,检查指针状态,若发现指针被标记为需要修正,则执行修正逻辑。
  3. 并发整理:将存活对象重定位到新页面,使用“转发表”处理旧引用。

12.2 ZGC流程(全是并发,只有三个STW极短的阶段)

  1. 初始标记:STW但只标记GC Roots,极短(<1ms)
  2. 并发标记:遍历对象图
  3. 再标记:STW修正,很短暂
  4. 并发预备重分配:统计需要移动的页面
  5. 初始重分配:STW极短
  6. 并发重分配:移动对象,更新引用(依赖读屏障)
  7. 重映射:修正所有引用(后续并发完成)

优势:几乎所有工作并发,停顿极低。

12.3 ZGC的缺点与现状

  • 内存开销:染色指针导致不支持32位指针,且每个对象可能多占用内存。
  • 吞吐量略低于G1(在低延迟场景下可接受)。
  • 对操作系统依赖(支持多映射内存)。
  • JDK15正式商用,JDK17已集成,JDK21支持分代ZGC。

第十三章:三色标记算法 —— 并发GC的理论基础

为了解决并发标记过程中用户线程修改引用导致“漏标”问题,JVM采用三色标记法。

13.1 三色定义

  • 白色:尚未被GC扫描到的对象(初始状态)。扫描结束后白色即为垃圾。
  • 灰色:自身已被扫描,但其引用字段尚未全部扫描完成。
  • 黑色:自身和所有引用字段均已扫描。

13.2 并发标记的问题:漏标和错标

  • 漏标(致命):GC标记时,黑色对象引用了一个白色对象,同时灰色对象丢掉对白色对象的引用。白色对象最终被当作垃圾回收,但实际应该存活 → 程序崩溃。
  • 错标(无害):白色对象被标记为存活,实际上已是垃圾 → 浮动垃圾,下次清理。

13.3 解决方案

CMS使用增量更新(Incremental Update):当黑色对象新引用白色对象时,记录下这个黑色对象,在重新标记阶段扫描其引用链。

G1使用SATB(Snapshot-At-The-Beginning):在并发标记开始时,记录一个对象图的快照。标记过程中,如果某个对象的引用被修改,将原引用记录的旧对象保存下来(标记为灰色),确保所有在快照中存活的对象都会被标记。

为什么G1用SATB? 因为SATB更适合Region的RSet维护,且可以避免增量更新复杂的数据结构。


第十四章:PLAB —— 多线程GC的加速器

PLAB(Promotion Local Allocation Buffers):在线程本地分配缓冲区(TLAB)基础上,GC晋升对象时也使用本地缓冲区。

14.1 作用

在新生代GC(Minor GC)时,多个线程需要将存活对象复制到Survivor或老年代。如果多个线程直接竞争同一块内存,同步开销大。因此,每个GC线程预先申请一块PLAB,在自己缓冲区内分配,满了再申请新的,减少锁竞争。

14.2 相关参数

# 开启PLAB(默认true)
-XX:+UseTLAB
-XX:+ResizePLAB
# PLAB大小
-XX:ParallelGCBufferWastePct

第十五章:为什么移除PermGen引入Metaspace?

15.1 PermGen(永久代)的痛点

JDK7及之前,方法区实现为PermGen(永久代),由JVM堆管理,大小固定-XX:PermSize-XX:MaxPermSize。常见问题:

  • 内存泄漏:动态加载类(如JSP、OSGi、反射),无法卸载类,导致PermGen OOM。
  • 大小难调:太小OOM,太大浪费堆空间。
  • GC不友好:PermGen GC效率低,Full GC才回收。

15.2 Metaspace的改进(JDK8+)

  • 移出堆,使用本地内存:Metaspace不再由JVM堆管理,而是使用操作系统直接内存(C Heap)。
  • 默认无上限(受物理内存限制):-XX:MaxMetaspaceSize可设置上限防止内存攻击。
  • 自动增长:根据需求动态扩大,减少OOM。
  • 类数据共享:支持CDS(Class Data Sharing)。
  • 更干净的GC:Metaspace满了会触发Full GC或并发标记,卸载不可达的类加载器及对应的类元数据。

15.3 演示Metaspace OOM

// 借助cglib不断生成新类
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;

public class MetaspaceOOM {
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(Object.class);
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
            enhancer.create(); // 每次创建都会生成一个新类,加载到Metaspace
        }
    }
}
// 设置 -XX:MaxMetaspaceSize=20m 很快OOM: Metaspace

第十六章:逃逸分析和栈上分配

16.1 逃逸分析

逃逸分析是JIT编译器的一项优化技术,分析对象动态作用域:

  • 不逃逸:对象只在方法内部使用,不会被外部访问。
  • 方法逃逸:作为参数传递给其他方法或作为返回值。
  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量。

16.2 基于逃逸分析的优化

  1. 栈上分配:不逃逸的对象直接在栈上分配,方法结束自动销毁,无需GC。
  2. 标量替换:将对象字段拆分为多个局部变量(标量),不再创建对象。
  3. 同步消除:对只有单线程访问的锁进行消除。

代码示例

public class EscapeAnalysisDemo {
    public void test() {
        Point p = new Point(1, 2); // p不会逃逸,可能栈上分配甚至标量替换
        System.out.println(p.x + p.y);
    }
    class Point { int x, y; Point(int x, int y) { this.x=x; this.y=y; } }
}

16.3 验证栈上分配

# 关闭逃逸分析
-XX:-DoEscapeAnalysis -XX:-EliminateAllocations -XX:+PrintGC
# 开启(默认开启)

大量分配小对象时,开启逃逸分析能显著减少GC次数。


第十七章:AOT编译 —— 提前编译

17.1 JIT vs AOT

  • JIT:运行时编译热点代码,启动慢但峰值性能高。
  • AOT(Ahead-Of-Time):事先编译成机器码,启动快,但无法做运行时优化(如根据CPU特性)。

17.2 JDK的AOT支持

  • GraalVM Native Image:将Java应用编译成原生可执行文件。
  • JDK9引入jaotc:使用Graal编译器将字节码编译成共享库,但已标记为实验性。

示例(使用GraalVM):

native-image -cp . HelloWorld
./helloworld

适用场景

  • 微服务、Serverless(冷启动要求快)
  • 不需要动态类加载的应用

缺点

  • 反射、动态代理、JNI需要配置例外
  • 不支持某些动态特性

第十八章:垃圾回收调优目标与常用参数

18.1 GC调优的三个目标(不可能三角)

  1. 低延迟:GC停顿时间短(如<100ms)
  2. 高吞吐量:应用程序时间 / (应用程序时间 + GC时间) > 99%
  3. 小内存占用:堆内存小(降本)

通常只能满足其中两个。

18.2 常用GC参数大全

# 堆内存
-Xms4g            # 初始堆大小
-Xmx4g            # 最大堆大小
-Xmn2g            # 新生代大小(or -XX:NewSize / -XX:MaxNewSize)
-XX:SurvivorRatio=8  # Eden:Survivor比例

# 老年代比例
-XX:NewRatio=2    # 老年代:新生代=2:1

# 元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=256m

# 直接内存
-XX:MaxDirectMemorySize=1g

# GC日志(推荐统一格式)
-Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
# JDK9+统一日志
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=50m

# OOM时输出堆dump
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path

# 选择GC
-XX:+UseG1GC
-XX:+UseParallelGC
-XX:+UseZGC

# G1
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=45
-XX:G1MixedGCLiveThresholdPercent=85
-XX:G1NewSizePercent=5

# Parallel
-XX:ParallelGCThreads=8
-XX:GCTimeRatio=99   # 吞吐量目标=1/(1+99)=1% GC时间

# CMS(已废弃)
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=70
-XX:+UseCMSCompactAtFullCollection

# ZGC
-XX:ConcGCThreads=2
-XX:ZCollectionInterval=  # 触发间隔

18.3 调优思路

  1. 确定目标:延迟优先还是吞吐量优先?
  2. 监控现状:使用工具看GC频率、停顿时间、各代使用情况。
  3. 调整内存分配:老年代增长过快要加堆;Survivor太小导致过早晋升。
  4. 选择收集器:响应时间优先用G1/ZGC,吞吐优先用Parallel。
  5. 微调参数:目标停顿时间、晋升阈值、并发线程数。
  6. 验证:压力测试对比GC日志。

第十九章:常用分析工具 —— 诊断JVM的显微镜

19.1 命令行工具(JDK自带)

# 查看进程ID
jps -l

# 查看堆使用概况
jstat -gc <pid> 1000 10   # 每秒打印一次,共10次

# jstat输出指标:
# S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT

# 查看堆内存使用情况(类似jmap -heap)
jmap -heap <pid>

# 导出堆dump
jmap -dump:live,format=b,file=heap.hprof <pid>

# 查看线程栈
jstack <pid> > thread.txt

# 监控实时内存分配(JDK8u40+)
jcmd <pid> GC.heap_info

# 查看类加载器统计
jmap -clstats <pid>

19.2 可视化工具

  • VisualVM:JDK自带,可查看CPU、内存、线程,支持插件(如VisualGC)。
  • JConsole:较老,适合简单监控。
  • JMC(Java Mission Control):飞行记录器,低开销分析。

19.3 第三方工具(生产环境利器)

  • Arthas(阿里开源):在线诊断,不重启即可查看类加载器、方法调用、动态修改代码。

    # 下载arthas
    java -jar arthas-boot.jar
    # 常用命令
    dashboard        # 实时面板
    thread           # 查看线程
    jvm              # JVM信息
    heapdump         # dump堆
    trace demo.Main run  # 跟踪方法耗时
    
  • MAT(Memory Analyzer Tool):分析heap dump,查找内存泄漏的“葛优躺”式报告。

  • GCEasy:上传GC日志在线分析,推荐。

  • Prometheus + Grafana:监控GC指标。


第二十章:如何进行内存泄漏分析

20.1 内存泄漏的常见表现

  • 老年代持续增长,Full GC频繁,但每次GC后内存不降。
  • 系统运行一段时间后卡死或OOM。
  • GC后堆使用率仍然很高,且缓慢上升。

20.2 实战排查步骤

案例:模拟内存泄漏

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class MemoryLeakDemo {
    static List<byte[]> leakList = new ArrayList<>(); // 静态集合持有对象,无法释放

    public static void main(String[] args) throws InterruptedException {
        Random random = new Random();
        while (true) {
            byte[] bytes = new byte[1024 * 1024]; // 1MB
            leakList.add(bytes);          // 持续增长
            Thread.sleep(10);
        }
    }
}

分析步骤

  1. 观察现象

    jstat -gc <pid> 2000  # 看O区(老年代)持续增长,FGC后不降
    
  2. 导出堆dump

    jmap -dump:live,format=b,file=leak.hprof <pid>
    
  3. 使用MAT分析

    • 打开dump,Mat会自动生成“Leak Suspects Report”。
    • 查找最大的对象:点击Histogram,按Shallow Heap排序,发现byte[]占用最大。
    • 查看谁引用了这些byte[]:右键 → List objects -> with incoming references
    • 追溯到GC Roots:Merge Shortest Paths to GC Roots
    • 发现leakList(ArrayList)持有所有byte[],且leakList是静态变量。
  4. 修复:将静态变量改为局部变量或使用弱引用/软引用。

20.3 常见内存泄漏场景

  1. 静态集合类static List<Object>不断添加不删除。
  2. 未关闭的资源ConnectionInputStream没有finally关闭。
  3. 监听器/回调:注册后未注销。
  4. ThreadLocal:未调用remove(),且线程长期存活(如线程池中的线程)。
  5. 自定义ClassLoader:动态加载类后未卸载。

第二十一章:JVM执行流程完整串联

把前面所有知识点串成一个完整的Java程序执行流程:

Hello.java
   ↓ javac
Hello.class (字节码)
   ↓ 类加载子系统
- 加载:从文件/网络/动态代理生成 -> 元空间存储类元数据,堆中生成Class对象
- 链接:验证(字节码安全) -> 准备(静态变量默认值) -> 解析(符号引用转直接引用)
- 初始化:执行静态赋值语句和静态代码块
   ↓ main()方法开始执行
JVM创建主线程,分配栈帧:
    - PC寄存器指向下一条指令
    - 栈帧中存放局部变量、操作数栈、动态链接、返回地址
   ↓ 遇到 new 指令
在堆中分配对象内存(TLAB优先),栈中分配引用
   ↓ 执行方法
可能触发JIT编译(热点代码)
   ↓ 垃圾回收
- Minor GC:Eden区满,使用复制算法
- 对象晋升:年龄到阈值,移到老年代
- Full GC:老年代满或Metaspace满,标记整理或G1的Mixed GC
   ↓ 方法结束
栈帧弹出,对象变为不可达,等待GC
   ↓ 程序终止
JVM退出,操作系统回收进程资源

结束语

恭喜你,看完了这篇长文,已经超越了90%的Java开发者对JVM的理解。但真正的精通在于实践:多分析GC日志,多dump堆,多使用Arthas排查线上问题。下次遇到OutOfMemoryError,不要慌张,按本文的步骤冷静分析。

最后送上一份检查清单

  • 能说出JVM五大运行时区域及各自作用
  • 能画出GC Roots的种类
  • 能在生产环境中用jstat/jmap/jstack初步诊断
  • 知道怎么选择垃圾收集器并说出理由
  • 能用MAT分析内存泄漏
  • 理解G1和CMS的区别
  • 知道为什么要从PermGen换成Metaspace

如果你看到这里,不妨打开IDEA,动手实验本文的每个代码片段。JVM的世界,行动起来才能真正掌握!


附:常用参数速查表

参数 含义 示例
-Xms 初始堆大小 -Xms2g
-Xmx 最大堆大小 -Xmx2g
-Xmn 新生代大小 -Xmn1g
-XX:MetaspaceSize 元空间初始 -XX:MetaspaceSize=128m
-XX:+PrintGCDetails 打印GC详情 配合-Xloggc
-XX:+HeapDumpOnOutOfMemoryError OOM时dump
-XX:+UseG1GC 使用G1
-XX:MaxGCPauseMillis G1目标停顿 -XX:MaxGCPauseMillis=100

更多推荐