Java虚拟机(JVM)从入门到精通:内存管理、垃圾回收、性能调优实战
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(),到底在内存里发生了什么?为什么程序跑着跑着就OutOfMemoryError?System.gc()真的会立即回收垃圾吗?一个String对象到底占多少内存?如果你的简历上写着“熟悉Java”,面试官大概率会从JVM开始拷问。
JVM(Java Virtual Machine)是Java跨平台特性的基石,更是性能调优、故障排查的核心。不懂JVM,你就像个只会用方向盘却不懂引擎的司机——能开,但永远不知道如何发挥极致性能。
本文将通过大量代码示例、内存图解、实战案例,带你从零开始彻底掌握JVM。全文约2.5万字,建议收藏反复阅读。
第一章:Java执行流程 —— 从源码到机器码
在深入JVM内部之前,先搞清楚一个Java程序是怎么跑起来的。
1.1 完整的Java执行流程
.java 源文件 → javac 编译 → .class 字节码 → 类加载器 → JVM执行引擎 → 机器码
步骤拆解:
- 编写源码:
Hello.java - 编译:
javac Hello.java→ 生成Hello.class(字节码,不是机器码) - 类加载:JVM的类加载器读取
.class文件,将其加载到内存 - 验证:确保字节码安全(格式正确、没有非法跳转等)
- 准备:为静态变量分配内存并设置默认初始值(不是代码中的赋值)
- 解析:将符号引用替换为直接引用(比如方法调用找到真实地址)
- 初始化:执行类构造器
<clinit>(静态变量赋值、静态代码块) - 执行:执行引擎(解释器 + 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反汇编看到的ldc、invokevirtual等就是字节码指令。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 三种常量池的关系
- Class文件常量池:每个
.class文件中有一个常量池表,存放字面量(文本字符串、final常量)和符号引用(类名、方法名)。 - 运行时常量池:类加载后,Class文件常量池进入方法区(JDK8后是Metaspace),变成运行时常量池。
- 字符串常量池:专门存放字符串对象(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)是指直接通过Unsafe或ByteBuffer.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里存活对象要复制时,没地方放。两块交替角色,保证每次总有一块是空的。
工作流程:
- 新对象分配在Eden(以及一块Survivor,初始为空)
- Minor GC:Eden和S0存活对象 → 复制到S1,年龄+1
- 清空Eden和S0,交换角色:S1变成下一个周期的S0
- 重复步骤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的常见场景
- System.gc():建议触发Full GC(可通过
-XX:+DisableExplicitGC禁用)。 - 老年代空间不足:Minor GC后晋升对象超过老年代剩余。
- 永久代/Metaspace不足:加载过多类(如动态代理、JSP)。
- CMS GC时出现Concurrent Mode Failure:并发收集时又有新对象晋升,触发Full GC。
- 大对象直接分配在老年代(超过
-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的四个阶段
- 初始标记(STW):标记GC Roots直接关联的对象,速度极快。
- 并发标记:从这些对象开始遍历整个对象图,与用户线程并发执行。
- 重新标记(STW):修正并发标记期间用户线程导致变动的对象,稍长但仍可控。
- 并发清除:清除标记为垃圾的对象,并发。
缺点:
- 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的四个关键设计
- Region分区:每个Region可以是Eden、Survivor、Old、Humongous。
- 停顿预测模型:根据历史数据,可设定目标停顿时间(
-XX:MaxGCPauseMillis=200),G1会自动选择一组Region回收,保证耗时不超过目标。 - RSet(Remembered Set):每个Region记录外部指向本Region的引用,避免全堆扫描。
- 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关键技术
- 染色指针(Colored Pointers):在64位指针中用高几位存储状态(Finalizable、Remapped、Marked0、Marked1),GC时通过修改指针而非对象头来记录信息。
- 读屏障(Load Barrier):每次读取对象引用时,检查指针状态,若发现指针被标记为需要修正,则执行修正逻辑。
- 并发整理:将存活对象重定位到新页面,使用“转发表”处理旧引用。
12.2 ZGC流程(全是并发,只有三个STW极短的阶段)
- 初始标记:STW但只标记GC Roots,极短(<1ms)
- 并发标记:遍历对象图
- 再标记:STW修正,很短暂
- 并发预备重分配:统计需要移动的页面
- 初始重分配:STW极短
- 并发重分配:移动对象,更新引用(依赖读屏障)
- 重映射:修正所有引用(后续并发完成)
优势:几乎所有工作并发,停顿极低。
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 基于逃逸分析的优化
- 栈上分配:不逃逸的对象直接在栈上分配,方法结束自动销毁,无需GC。
- 标量替换:将对象字段拆分为多个局部变量(标量),不再创建对象。
- 同步消除:对只有单线程访问的锁进行消除。
代码示例:
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调优的三个目标(不可能三角)
- 低延迟:GC停顿时间短(如<100ms)
- 高吞吐量:应用程序时间 / (应用程序时间 + GC时间) > 99%
- 小内存占用:堆内存小(降本)
通常只能满足其中两个。
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 调优思路
- 确定目标:延迟优先还是吞吐量优先?
- 监控现状:使用工具看GC频率、停顿时间、各代使用情况。
- 调整内存分配:老年代增长过快要加堆;Survivor太小导致过早晋升。
- 选择收集器:响应时间优先用G1/ZGC,吞吐优先用Parallel。
- 微调参数:目标停顿时间、晋升阈值、并发线程数。
- 验证:压力测试对比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);
}
}
}
分析步骤:
-
观察现象:
jstat -gc <pid> 2000 # 看O区(老年代)持续增长,FGC后不降 -
导出堆dump:
jmap -dump:live,format=b,file=leak.hprof <pid> -
使用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是静态变量。
-
修复:将静态变量改为局部变量或使用弱引用/软引用。
20.3 常见内存泄漏场景
- 静态集合类:
static List<Object>不断添加不删除。 - 未关闭的资源:
Connection、InputStream没有finally关闭。 - 监听器/回调:注册后未注销。
- ThreadLocal:未调用
remove(),且线程长期存活(如线程池中的线程)。 - 自定义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 |
更多推荐
所有评论(0)