引言

JVM是Java程序运行的基础,理解JVM内存模型和垃圾回收机制对于编写高性能Java应用至关重要。本文将深入解析JVM的核心原理,帮助你掌握JVM调优的关键技术。

一、JVM内存结构

1.1 运行时数据区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────┐
│ 方法区 (Method Area) │
│ (元空间 Metaspace in JDK8+) │
├─────────────────────────────────────┤
│ 堆 (Heap) │
│ ┌──────────────────────────┐ │
│ │ 新生代 (Young Gen) │ │
│ │ ┌────┬──────┬──────┐ │ │
│ │ │Eden│S0│S1 │ │ │
│ │ └────┴──────┴──────┘ │ │
│ ├──────────────────────────┤ │
│ │ 老年代 (Old Gen) │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────┘

线程私有:
- 程序计数器 (PC Register)
- Java虚拟机栈 (JVM Stack)
- 本地方法栈 (Native Method Stack)

1.2 各区域详解

1. 堆(Heap)

1
2
3
// 堆是所有线程共享的内存区域
// 用于存储对象实例
Object obj = new Object(); // 对象在堆中分配

堆的分代结构:

1
2
3
4
5
6
7
新生代 (Young Generation)
├── Eden区 (80%)
├── Survivor0 (10%)
└── Survivor1 (10%)

老年代 (Old Generation)
└── 存放长期存活的对象

2. 方法区(Method Area)

1
2
3
4
5
6
7
8
9
// 存储类信息、常量、静态变量等
public class MyClass {
private static final String CONSTANT = "常量"; // 方法区
private static int count = 0; // 方法区

public void method() {
// 方法字节码存储在方法区
}
}

3. 虚拟机栈(JVM Stack)

1
2
3
4
5
6
7
8
public void method() {
int a = 1; // 局部变量,栈中分配
int b = 2;
int c = a + b;
}

// 每个方法调用创建一个栈帧
// 栈帧包含:局部变量表、操作数栈、动态链接、返回地址

4. 程序计数器(PC Register)

1
2
// 记录当前线程执行的字节码行号
// 线程切换后能恢复到正确的执行位置

1.3 对象的创建过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Person p = new Person("Alice", 25);

// 1. 类加载检查
// 检查Person类是否已加载、解析、初始化

// 2. 分配内存
// 在堆中为对象分配内存空间
// 方式1:指针碰撞(内存规整时)
// 方式2:空闲列表(内存不规整时)

// 3. 初始化零值
// 将分配的内存初始化为零值

// 4. 设置对象头
// 存储对象的元数据信息

// 5. 执行<init>方法
// 调用构造函数初始化对象

1.4 对象的内存布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
对象在内存中的布局:

┌────────────────────┐
│ 对象头 (Header) │
│ ┌──────────────┐ │
│ │ Mark Word │ │ - 哈希码、GC年龄、锁信息
│ ├──────────────┤ │
│ │ Class Pointer│ │ - 指向类元数据
│ └──────────────┘ │
├────────────────────┤
│ 实例数据 (Data) │ - 对象的字段值
├────────────────────┤
│ 对齐填充 (Padding) │ - 补齐到8字节的倍数
└────────────────────┘

二、垃圾回收基础

2.1 如何判断对象可回收?

方法1:引用计数法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 每个对象有一个引用计数器
// 引用+1,去除-1,为0时回收

// 缺点:无法解决循环引用
class A {
B b;
}
class B {
A a;
}

A a = new A();
B b = new B();
a.b = b;
b.a = a;
// a和b互相引用,引用计数永远不为0

方法2:可达性分析算法(JVM采用)

1
2
3
4
5
6
7
8
// GC Roots作为起点,向下搜索
// 不可达的对象即为可回收对象

GC Roots包括:
- 虚拟机栈中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
1
2
3
4
5
6
7
        GC Roots
/ | \
A B C
/ \ |
D E F

G (不可达,可回收)

2.2 引用类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 强引用 (Strong Reference)
Object obj = new Object();
// 只要强引用存在,永不回收

// 2. 软引用 (Soft Reference)
SoftReference<Object> soft = new SoftReference<>(new Object());
// 内存不足时回收

// 3. 弱引用 (Weak Reference)
WeakReference<Object> weak = new WeakReference<>(new Object());
// 下次GC时必定回收

// 4. 虚引用 (Phantom Reference)
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), queue);
// 无法通过引用获取对象,用于对象回收跟踪

三、垃圾回收算法

3.1 标记-清除算法(Mark-Sweep)

1
2
3
4
5
6
7
8
9
10
11
12
13
标记阶段:标记所有需要回收的对象
清除阶段:回收被标记的对象

优点:简单
缺点:产生内存碎片

┌─┬─┬─┬─┬─┬─┬─┬─┐
│█│ │█│ │ │█│ │ │ 标记
└─┴─┴─┴─┴─┴─┴─┴─┘

┌─┬─┬─┬─┬─┬─┬─┬─┐
│█│□│█│□│□│█│□│□│ 清除后(有碎片)
└─┴─┴─┴─┴─┴─┴─┴─┘

3.2 标记-整理算法(Mark-Compact)

1
2
3
4
5
6
7
8
9
10
11
12
13
标记阶段:标记存活对象
整理阶段:将存活对象移到一端

优点:无内存碎片
缺点:需要移动对象,效率较低

┌─┬─┬─┬─┬─┬─┬─┬─┐
│█│ │█│ │ │█│ │ │ 标记
└─┴─┴─┴─┴─┴─┴─┴─┘

┌─┬─┬─┬─┬─┬─┬─┬─┐
│█│█│█│□│□│□│□│□│ 整理后(无碎片)
└─┴─┴─┴─┴─┴─┴─┴─┘

3.3 复制算法(Copying)

1
2
3
4
5
6
7
8
9
10
将内存分为两块,每次只使用一块
GC时将存活对象复制到另一块,清空当前块

优点:无碎片,效率高
缺点:浪费一半内存

From区 To区
┌─┬─┬─┬─┐ ┌─┬─┬─┬─┐
│█│ │█│ │ ──复制──>│█│█│□│□│
└─┴─┴─┴─┘ └─┴─┴─┴─┘

3.4 分代收集算法

1
2
3
4
// 新生代:对象存活率低,使用复制算法
// Eden:Survivor0:Survivor1 = 8:1:1

// 老年代:对象存活率高,使用标记-清除或标记-整理算法

四、垃圾收集器

4.1 Serial收集器

1
2
3
4
5
# 单线程收集器,Stop-The-World
# 适用场景:客户端模式、单核CPU

# 启用参数
-XX:+UseSerialGC

4.2 ParNew收集器

1
2
3
4
5
# Serial的多线程版本
# 适用场景:多核CPU环境

# 启用参数
-XX:+UseParNewGC

4.3 Parallel Scavenge收集器

1
2
3
4
5
6
7
# 注重吞吐量的收集器
# 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC时间)

# 启用参数
-XX:+UseParallelGC
-XX:MaxGCPauseMillis=100 # 最大停顿时间
-XX:GCTimeRatio=99 # 吞吐量大小

4.4 CMS收集器(Concurrent Mark Sweep)

1
2
3
4
5
6
7
8
9
10
11
# 以获取最短停顿时间为目标
# 并发收集、低停顿

# 启用参数
-XX:+UseConcMarkSweepGC

# 工作流程:
# 1. 初始标记 (STW)
# 2. 并发标记
# 3. 重新标记 (STW)
# 4. 并发清除

CMS的缺点:

  • 产生内存碎片
  • 占用CPU资源
  • 无法处理浮动垃圾

4.5 G1收集器(Garbage First)

1
2
3
4
5
6
7
# JDK 9默认收集器
# 面向服务端应用

# 启用参数
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 期望停顿时间
-XX:G1HeapRegionSize=n # Region大小

G1特点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
堆被划分为多个Region (1-32MB)

┌────┬────┬────┬────┐
│Eden│Eden│Surv│Old │
├────┼────┼────┼────┤
│Old │Eden│Old │Eden│
├────┼────┼────┼────┤
│Huge│Surv│Old │Free│
└────┴────┴────┴────┘

- 并行与并发
- 分代收集
- 空间整合
- 可预测的停顿时间

4.6 ZGC收集器

1
2
3
4
5
# JDK 11引入,低延迟收集器
# 停顿时间不超过10ms

# 启用参数
-XX:+UseZGC

特点:

  • 染色指针技术
  • 并发标记、并发转移
  • Region动态创建销毁

五、GC日志分析

5.1 开启GC日志

1
2
3
4
5
6
7
# JDK 8
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:gc.log

# JDK 9+
-Xlog:gc*:file=gc.log:time,level,tags

5.2 GC日志示例

1
2
3
4
5
6
7
8
9
10
11
12
13
[GC (Allocation Failure) 
[PSYoungGen: 65536K->10752K(76288K)]
65536K->14856K(251392K),
0.0234567 secs]
[Times: user=0.05 sys=0.01, real=0.02 secs]

解释:
- PSYoungGen: 新生代收集器
- 65536K->10752K: 新生代从65MB减少到10MB
- (76288K): 新生代总大小
- 65536K->14856K: 整个堆从65MB减少到14MB
- (251392K): 堆总大小
- 0.0234567 secs: GC耗时

六、JVM调优实战

6.1 常用JVM参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 堆大小设置
-Xms2g # 初始堆大小
-Xmx2g # 最大堆大小
-Xmn512m # 新生代大小
-XX:MetaspaceSize=128m # 元空间初始大小
-XX:MaxMetaspaceSize=256m # 元空间最大大小

# 垃圾收集器
-XX:+UseG1GC # 使用G1收集器
-XX:MaxGCPauseMillis=200 # 最大停顿时间

# GC日志
-Xlog:gc*:file=gc.log:time

# 堆dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump

# 栈大小
-Xss256k # 每个线程的栈大小

6.2 调优案例:解决Full GC频繁

问题现象:

1
应用响应变慢,Full GC频繁发生

分析步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 查看GC日志
[Full GC (Allocation Failure)
2048M->2047M(2048M), 3.5 secs]

// 发现:堆内存不足,Full GC后仍无法释放内存

// 2. 使用jmap导出堆dump
jmap -dump:live,format=b,file=heap.hprof <pid>

// 3. 使用MAT分析dump文件
// 发现:某个缓存Map占用1.5GB内存

// 4. 解决方案
// - 增大堆内存:-Xmx4g
// - 优化缓存:添加淘汰策略
// - 使用弱引用缓存

6.3 调优案例:降低GC停顿时间

1
2
3
4
5
6
7
8
9
10
11
12
13
# 问题:CMS收集器停顿时间过长

# 优化前
-XX:+UseConcMarkSweepGC
-Xms4g -Xmx4g
# 平均停顿时间:500ms

# 优化后:切换到G1
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xms4g -Xmx4g
-XX:G1HeapRegionSize=16m
# 平均停顿时间:150ms

6.4 内存泄漏排查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 常见内存泄漏场景

// 1. 静态集合持有对象引用
public class MemoryLeak {
private static List<Object> list = new ArrayList<>();

public void add() {
list.add(new Object()); // 对象永不释放
}
}

// 2. 监听器未移除
button.addListener(new Listener() {
public void onClick() {
// ...
}
});
// 应该在不需要时移除: button.removeListener(listener)

// 3. 连接未关闭
Connection conn = getConnection();
// 应该在finally中关闭
conn.close();

// 4. ThreadLocal未清理
ThreadLocal<Object> local = new ThreadLocal<>();
local.set(new Object());
// 应该在使用完后: local.remove()

七、性能监控工具

7.1 命令行工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1. jps - 查看Java进程
jps -l

# 2. jstat - 查看GC统计信息
jstat -gc <pid> 1000 # 每秒输出一次

# 3. jinfo - 查看JVM参数
jinfo -flags <pid>

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

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

# 6. jcmd - 多功能诊断工具
jcmd <pid> GC.heap_info

7.2 可视化工具

1
2
3
4
5
6
7
8
9
10
11
# 1. JConsole - JDK自带监控工具
jconsole

# 2. VisualVM - 性能分析工具
jvisualvm

# 3. MAT (Memory Analyzer Tool) - 内存分析
# 分析堆dump文件,找出内存泄漏

# 4. GCEasy - 在线GC日志分析
# https://gceasy.io/

八、面试高频问题

8.1 JVM内存结构?

答:JVM内存分为以下几个区域:

  1. 堆:存储对象实例,分为新生代和老年代
  2. 方法区:存储类信息、常量、静态变量
  3. 虚拟机栈:存储局部变量、方法调用
  4. 本地方法栈:本地方法调用
  5. 程序计数器:记录字节码行号

8.2 什么时候会发生Full GC?

答:以下情况会触发Full GC:

  1. 老年代空间不足
  2. 方法区空间不足
  3. CMS收集器的并发模式失败
  4. 显式调用System.gc()
  5. 新生代晋升到老年代时老年代空间不足

8.3 如何选择垃圾收集器?

1
2
3
吞吐量优先:Parallel Scavenge + Parallel Old
响应时间优先:CMS 或 G1
低延迟:ZGC 或 Shenandoah

总结

JVM调优的核心要点:

  1. 理解内存结构:堆、栈、方法区
  2. 掌握GC算法:标记-清除、标记-整理、复制
  3. 熟悉垃圾收集器:Serial、Parallel、CMS、G1、ZGC
  4. 会分析GC日志:找出性能瓶颈
  5. 掌握调优工具:jstat、jmap、MAT等
  6. 实战经验:解决实际问题

参考资料