深入理解 JVM:内存模型 + GC 算法 + 调优实战

10 阅读10分钟

一、JVM 内存模型:程序运行的地基

1.1 运行时数据区域

JVM 在运行时会将内存划分为不同的区域,每个区域有不同的用途:

堆(Heap)

  • 作用:存储对象实例,是 JVM 中最大的一块内存区域
  • 特点:被所有线程共享,垃圾收集器的主要工作区域
  • 分代设计:新生代(Young Generation)+ 老年代(Old Generation)
  • 配置参数-Xms(初始大小)、-Xmx(最大大小)
// 对象在堆中创建
User user = new User();  // user 引用在栈,User 对象在堆

方法区(Method Area)

  • 作用:存储类信息、常量、静态变量、即时编译后的代码
  • 特点:线程共享,逻辑上是堆的一部分
  • HotSpot 实现:JDK 7 叫永久代(PermGen),JDK 8+ 改为元空间(Metaspace)
  • 配置参数-XX:MetaspaceSize-XX:MaxMetaspaceSize
public class Config {
    // 静态变量存储在方法区
    private static final String VERSION = "1.0.0";
}

程序计数器(Program Counter Register)

  • 作用:记录当前线程执行的字节码指令地址
  • 特点:线程私有,唯一不会 OOM 的区域
  • 为什么需要:多线程环境下,线程切换后能恢复到正确的执行位置

虚拟机栈(VM Stack)

  • 作用:存储方法调用的栈帧(局部变量表、操作数栈、动态链接、返回地址)
  • 特点:线程私有,生命周期与线程相同
  • 常见异常StackOverflowError(递归过深)、OutOfMemoryError(无法分配新栈)
public void methodA() {
    int a = 1;          // 局部变量在栈帧中
    Object obj = null;  // 引用在栈,对象在堆
    methodB();          // 新栈帧入栈
}

public void methodB() {
    // 方法执行完毕,栈帧出栈
}

本地方法栈(Native Method Stack)

  • 作用:为 Native 方法服务
  • 特点:与虚拟机栈类似,但服务于 native 方法

1.2 内存模型与线程安全

JMM(Java Memory Model)定义了线程和主内存之间的抽象关系:

线程 ←→ 工作内存 ←→ 主内存

三大特性

  1. 原子性:操作不可分割

    1. synchronizedLock、原子类保证
  2. 可见性:一个线程的修改对其他线程可见

    1. volatilesynchronizedfinal 保证
  3. 有序性:指令重排序问题

    1. volatile 禁止重排、happens-before 原则
// volatile 保证可见性,禁止指令重排
private volatile boolean running = true;

public void stop() {
    running = false;  // 对其他线程立即可见
}

二、垃圾回收:自动内存管理的艺术

2.1 对象存活判定

引用计数法

  • 原理:对象被引用时计数 +1,引用失效时 -1,计数为 0 时回收
  • 缺点:无法解决循环引用问题
  • 代表:Python、早期的 JVM

可达性分析(Reachability Analysis)

  • 原理:从 GC Roots 出发,搜索走过的路径(引用链),不在链上的对象即为不可达

  • GC Roots 包括

    • 虚拟机栈中的引用对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中 JNI 引用的对象
    • 同步锁持有的对象
    • JVM 内部引用(基本类型的 Class 对象、常驻异常对象等)
GC Roots → 对象A → 对象B
        → 对象C
        
对象D(无引用链)→ 可回收

2.2 垃圾回收算法

标记-清除(Mark-Sweep)

  • 过程:标记存活对象 → 清除未标记对象
  • 缺点:效率不高、产生内存碎片
  • 适用:老年代

标记-复制(Mark-Copy)

  • 过程:将内存分为两块,存活对象复制到另一块,清空当前块
  • 优点:没有碎片、效率高
  • 缺点:内存利用率低(可用 50%)
  • 优化:Eden:Survivor = 8:1(Appel 式回收)
  • 适用:新生代

标记-整理(Mark-Compact)

  • 过程:标记存活对象 → 向一端移动 → 清理边界外内存
  • 优点:无碎片、内存利用率高
  • 缺点:移动成本高
  • 适用:老年代

分代收集(Generational Collection)

  • 核心思想:根据对象存活周期将内存分代,不同代使用不同算法
  • 新生代:对象朝生夕死,用复制算法
  • 老年代:对象存活时间长,用标记-清除或标记-整理

2.3 垃圾收集器详解

Serial 收集器

  • 特点:单线程、简单高效、需要 STW(Stop The World)
  • 适用:客户端模式、小内存应用
  • 参数-XX:+UseSerialGC
[GC pause (young)] → 单线程执行

Parallel Scavenge(并行清除)

  • 特点:多线程、关注吞吐量(运行用户代码时间 / 总时间)
  • 适用:后台计算、批处理场景
  • 参数-XX:+UseParallelGC-XX:MaxGCPauseMillis-XX:GCTimeRatio
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

CMS(Concurrent Mark Sweep)

  • 特点:并发收集、低停顿、基于标记-清除
  • 缺点:CPU 敏感、有内存碎片、浮动垃圾问题
  • 参数-XX:+UseConcMarkSweepGC

四个阶段

  1. 初始标记(STW)—— 标记 GC Roots 直接关联的对象
  2. 并发标记 —— 从 GC Roots 遍历整个对象图
  3. 重新标记(STW)—— 修正并发标记期间变动的对象
  4. 并发清除 —— 清除标记的对象

G1(Garbage First)

  • 特点:面向服务端、Region 化内存布局、可预测停顿

  • 创新点

    • 将堆划分为多个大小相等的 Region
    • 优先回收垃圾最多的 Region(Garbage First)
    • 整体看是标记-整理,局部(两个 Region 之间)是复制算法
  • 参数-XX:+UseG1GC-XX:MaxGCPauseMillis

堆内存布局:
[Region0][Region1][Region2][Region3]...[RegionN]
   E        S        O        H      ...
(Eden)(Survivor)(Old)(Humongous)

G1 工作模式

  • Young GC:Eden 区满时触发
  • Mixed GC:老年代占用达到阈值时触发
  • Full GC:回收速度跟不上分配速度时触发(应避免)

ZGC(JDK 11+)

  • 特点:低延迟(停顿 < 10ms)、支持大内存(TB 级)

  • 核心技术

    • 着色指针(Colored Pointers)
    • 读屏障(Load Barrier)
    • 并发整理
  • 参数-XX:+UseZGC-XX:ZCollectionInterval

Shenandoah(JDK 12+)

  • 特点:与 ZGC 类似,低延迟、并发整理
  • 区别:使用转发指针(Forwarding Pointer)而非着色指针
  • 参数-XX:+UseShenandoahGC

2.4 垃圾收集器选择指南

收集器特点适用场景JDK 版本
Serial简单单线程客户端、小内存所有
Parallel高吞吐量批处理、后台计算JDK 8 默认
CMS低延迟Web 服务、交互式应用JDK 9 废弃
G1平衡吞吐与延迟服务端通用JDK 9+ 默认
ZGC超低延迟大内存、对延迟敏感JDK 15+
Shenandoah超低延迟大内存、低延迟JDK 12+
# JDK 8 默认
-XX:+UseParallelGC

# JDK 9+ 默认
-XX:+UseG1GC

# 低延迟场景
-XX:+UseZGC -Xmx16g

三、JVM 调优实战:从理论到落地

3.1 调优目标

  1. 吞吐量优先:批处理、后台计算
  2. 延迟优先:Web 服务、实时交易
  3. 内存占用:容器化部署、资源受限环境

黄金法则:调优是权衡,没有完美方案


3.2 常用调优参数

内存配置

# 堆内存
-Xms4g                          # 初始堆大小
-Xmx4g                          # 最大堆大小(建议与 Xms 相等)

# 新生代
-Xmn2g                          # 新生代大小
-XX:NewRatio=2                  # 老年代/新生代 = 2
-XX:SurvivorRatio=8             # Eden/Survivor = 8

# 元空间
-XX:MetaspaceSize=256m          # 初始元空间大小
-XX:MaxMetaspaceSize=512m       # 最大元空间大小

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

垃圾收集器配置

# G1 收集器
-XX:+UseG1GC                    # 启用 G1
-XX:MaxGCPauseMillis=200        # 最大 GC 停顿时间目标
-XX:G1HeapRegionSize=16m        # Region 大小
-XX:InitiatingHeapOccupancyPercent=45  # 老年代占用触发阈值

# ZGC
-XX:+UseZGC                     # 启用 ZGC
-XX:ZCollectionInterval=5       # GC 间隔(秒)
-XX:ZAllocationSpikeTolerance=2 # 分配峰值容忍度

# Parallel
-XX:+UseParallelGC              # 新生代使用 Parallel
-XX:+UseParallelOldGC           # 老年代使用 Parallel Old
-XX:ParallelGCThreads=8         # GC 线程数

GC 日志配置

# JDK 8
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/path/to/gc.log

# JDK 9+(统一日志)
-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags

3.3 实战案例

案例 1:频繁 Full GC

现象

  • 服务响应缓慢
  • 日志中频繁出现 Full GC
  • CPU 使用率飙升

分析步骤

  1. 查看 GC 日志,确认 Full GC 频率和原因
  2. 使用 jstat -gcutil <pid> 监控内存使用
  3. 使用 jmap -histo <pid> 查看对象分布
  4. Dump 堆内存分析:jmap -dump:format=b,file=heap.hprof <pid>

可能原因与解决方案

原因解决方案
老年代空间不足增大堆内存或调整新生代比例
永久代/元空间不足增大元空间
大对象直接进老年代调整大对象阈值 -XX:PretenureSizeThreshold
内存泄漏分析堆转储,定位泄漏对象
# 示例配置
-Xms8g -Xmx8g -Xmn4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200

案例 2:内存泄漏排查

场景:服务运行一段时间后 OOM

排查工具

  1. jvisualvm / JConsole:实时监控
  2. MAT(Memory Analyzer Tool) :分析堆转储
  3. jhat:命令行堆分析工具

常见泄漏场景

// 1. 静态集合类
public class Cache {
    private static final Map<String, Object> cache = new HashMap<>();
    // 忘记清理,无限增长
}

// 2. 监听器未注销
public class EventManager {
    private List<Listener> listeners = new ArrayList<>();
    // 添加后未移除
}

// 3. ThreadLocal 未清理
public class UserContext {
    private static ThreadLocal<User> userHolder = new ThreadLocal<>();
    // 线程池场景下,线程复用导致泄漏
}

解决方案

  • 使用弱引用:WeakHashMapWeakReference
  • 及时清理:ThreadLocal.remove()、监听器注销
  • 设置容量上限:Guava Cache、Caffeine

案例 3:大内存服务调优

场景:32GB 内存的服务,使用 G1 或 ZGC

G1 配置

-Xms28g -Xmx28g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:G1HeapRegionSize=32m \
-XX:InitiatingHeapOccupancyPercent=40 \
-XX:G1ReservePercent=15

ZGC 配置(JDK 17+):

-Xms28g -Xmx28g \
-XX:+UseZGC \
-XX:ZCollectionInterval=2 \
-XX:ZAllocationSpikeTolerance=4 \
-XX:+UnlockDiagnosticVMOptions \
-XX:+ZProactive

3.4 调优工具箱

命令行工具

# 查看 Java 进程
jps -l

# 查看堆内存使用
jstat -gc <pid> 1000  # 每秒打印一次

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

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

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

可视化工具

工具功能适用场景
JConsole实时监控快速查看
VisualVM监控 + 分析综合诊断
JProfiler深度分析商业级调优
Arthas在线诊断生产环境
GCViewerGC 日志分析分析 GC 模式

Arthas 实战

# 安装
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

# 常用命令
dashboard          # 实时面板
thread             # 线程信息
thread -n 5        # CPU 占用最高的 5 个线程
heapdump           # 堆转储
jad com.xxx.Class  # 反编译类
watch com.xxx.method returnObj  # 观察方法返回值

四、总结与最佳实践

4.1 调优原则

  1. 不要过度调优:JVM 默认配置已经很好
  2. 先监控后调优:用数据说话
  3. 单变量实验:一次只改一个参数
  4. 生产验证:灰度发布、AB 测试

4.2 常见配置模板

微服务通用配置

-Xms2g -Xmx2g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/logs/heap.hprof \
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags

高并发 Web 服务

-Xms8g -Xmx8g -Xmn4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1ReservePercent=15

批处理任务

-Xms16g -Xmx16g \
-XX:+UseParallelGC \
-XX:ParallelGCThreads=8 \
-XX:GCTimeRatio=19   # 目标吞吐量 95%

4.3 面试高频问题

  1. JVM 内存区域有哪些?哪些会 OOM?

    1. 堆、方法区、虚拟机栈、本地方法栈、程序计数器
    2. 除程序计数器外都可能 OOM
  2. Minor GC 和 Full GC 的区别?

    1. Minor GC:新生代 GC,频率高、速度快
    2. Full GC:整个堆 + 方法区,频率低、速度慢、需要优化
  3. 如何排查 OOM?

    1. 查看错误日志
    2. 分析堆转储(MAT)
    3. 定位大对象或内存泄漏
  4. G1 和 CMS 的区别?

    1. G1:Region 化、可预测停顿、无碎片
    2. CMS:并发收集、有碎片、已废弃
  5. 什么时候该调优?

    1. 频繁 Full GC
    2. 响应时间不稳定
    3. 内存使用异常

写在最后

JVM 调优是一门实践艺术,理论只是基础。真正的高手是在无数次排查问题、分析日志、优化配置中成长起来的。

记住

  • 调优前先监控
  • 调优要有目标
  • 调优要有验证

希望这篇文章能帮你建立 JVM 知识体系,在实际工作中游刃有余!