JVM 详解:从内存结构到调优实战,Java 开发者必读

一、为什么要学 JVM

作为 Java 开发者,你可能写过无数业务代码,但遇到这些问题时是否感到无力:

  • 线上服务突然 OOM, dump 文件几十 G,不知道从哪下手
  • 接口响应越来越慢,CPU 飙高,线程 dump 看不出问题
  • 同样的代码,本地跑得好好的,线上就频繁 Full GC
  • 面试被问到 JVM 调优经验,只能说"调过堆内存大小"

理解 JVM 是成为高级 Java 工程师的必经之路。 它不只是面试八股文,更是解决线上问题的核心能力。


二、JVM 整体架构

scss 复制代码
┌─────────────────────────────────────────────────────────┐
│                      类加载器子系统                        │
│  Bootstrap → Extension → Application → 自定义类加载器      │
├─────────────────────────────────────────────────────────┤
│                      运行时数据区                          │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐   │
│  │  方法区   │ │  堆内存   │ │  虚拟机栈 │ │ 本地方法栈│   │
│  │ (元空间) │ │ (新生代/  │ │          │ │          │   │
│  │          │ │  老年代) │ │          │ │          │   │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘   │
│  ┌──────────┐                                           │
│  │ 程序计数器│                                           │
│  └──────────┘                                           │
├─────────────────────────────────────────────────────────┤
│                      执行引擎                             │
│  解释器 → JIT 编译器 → 垃圾回收器                          │
├─────────────────────────────────────────────────────────┤
│                      本地方法接口                          │
│                      JNI                                  │
└─────────────────────────────────────────────────────────┘

三、运行时数据区详解

3.1 堆内存(Heap)

堆是 JVM 管理的最大一块内存,所有对象实例和数组都在这里分配。

堆内存结构

scss 复制代码
┌─────────────────────────────────────────┐
│              老年代 (Old Generation)      │  ← 占用 2/3 堆空间
│    (存放长期存活的对象,Full GC 区域)      │
├─────────────────────────────────────────┤
│  Eden    │  Survivor0  │  Survivor1     │  ← 新生代,占用 1/3 堆空间
│  (8/10)  │   (1/10)    │   (1/10)       │
│          │   From      │   To           │
└─────────────────────────────────────────┘

新生代(Young Generation)

  • Eden 区:新对象首先分配在这里,占新生代 8⁄10
  • Survivor 区:两块相同大小的区域(From 和 To),占新生代各 1⁄10
  • 对象在 Eden 区经历 Minor GC 后存活,会移到 Survivor 区

老年代(Old Generation)

  • 存放长期存活的对象
  • 对象在 Survivor 区经历一定次数 GC 后晋升到老年代
  • 老年代满了会触发 Full GC(或 Major GC)

对象分配与晋升流程

scss 复制代码
新对象 → Eden 区
    ↓
Eden 满了 → Minor GC
    ↓
存活对象 → Survivor From 区 (年龄 +1)
    ↓
下次 Minor GC → Survivor To 区 (年龄 +1)
    ↓
年龄达到阈值 (默认 15) → 老年代
    ↓
老年代满了 → Full GC

代码示例:观察对象晋升

arduino 复制代码
public class HeapAllocation {
    
    // -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
    // 堆 20M,新生代 10M,Eden 8M,Survivor 各 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
    }
}

大对象直接进入老年代

arduino 复制代码
// -XX:PretenureSizeThreshold=3145728 (3MB)
// 大于 3MB 的对象直接在老年代分配

byte[] bigObject = new byte[4 * 1024 * 1024]; // 4MB,直接进入老年代

为什么要这样设计?

  • 避免大对象在 Eden 和 Survivor 之间来回复制,浪费性能
  • 大对象生命周期通常较长,直接放老年代更合理

3.2 方法区(Method Area)

存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等。

JDK 8 之前 vs JDK 8 之后

版本 实现 内存位置 垃圾回收
JDK 7 及之前 永久代(PermGen) JVM 内存 Full GC 回收
JDK 8 及之后 元空间(Metaspace) 本地内存 独立回收

元空间(Metaspace)

scss 复制代码
┌─────────────────────────────────────┐
│           元空间 (Metaspace)          │
│  ┌─────────┐ ┌─────────┐ ┌────────┐ │
│  │ 类元数据 │ │ 方法元数据│ │ 常量池 │ │
│  └─────────┘ └─────────┘ └────────┘ │
│  ┌─────────┐ ┌─────────┐             │
│  │ 字段元数据│ │ 静态变量 │             │
│  └─────────┘ └─────────┘             │
└─────────────────────────────────────┘

元空间大小设置:

ini 复制代码
# 初始元空间大小
-XX:MetaspaceSize=128m

# 最大元空间大小
-XX:MaxMetaspaceSize=256m

为什么从永久代改为元空间?

  1. 永久代有固定大小上限 ,容易 OOM(java.lang.OutOfMemoryError: PermGen space
  2. 元空间使用本地内存,大小只受限于物理内存
  3. 元空间有独立的垃圾回收机制,不需要等待 Full GC

运行时常量池

ini 复制代码
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()); // true,intern() 把堆中字符串放入常量池
    }
}

3.3 虚拟机栈(VM Stack)

每个线程私有的内存区域,存储栈帧(Stack Frame)。

栈帧结构

scss 复制代码
┌─────────────────────────┐
│        栈帧 (Stack Frame) │  ← 每个方法调用创建一个栈帧
├─────────────────────────┤
│  局部变量表 (Local Variables) │
│  - 基本数据类型           │
│  - 对象引用               │
│  - returnAddress          │
├─────────────────────────┤
│  操作数栈 (Operand Stack)   │
│  - 方法执行的工作区        │
├─────────────────────────┤
│  动态链接 (Dynamic Linking) │
│  - 指向运行时常量池的方法引用 │
├─────────────────────────┤
│  方法返回地址 (Return Address)│
│  - 方法执行完返回到哪里     │
└─────────────────────────┘

栈溢出示例

csharp 复制代码
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万+ 次就会溢出
        }
    }
}

设置栈大小:

bash 复制代码
# 设置线程栈大小为 1MB
-Xss1m

3.4 本地方法栈(Native Method Stack)

为虚拟机使用到的 Native 方法服务。

JDK 中有很多 Native 方法,比如:

java 复制代码
// 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 不用)

css 复制代码
对象 A 被引用次数 = 2
对象 B 被引用次数 = 1(被 A 引用)
对象 C 被引用次数 = 0 → 可回收

缺点:无法解决循环引用问题

css 复制代码
A.instance = B;
B.instance = A;
// A 和 B 互相引用,引用计数都不为 0,但应该被回收

可达性分析算法(Java 使用)

vbnet 复制代码
GC Roots
├── 虚拟机栈中引用的对象
├── 方法区中类静态属性引用的对象
├── 方法区中常量引用的对象
├── 本地方法栈中 JNI 引用的对象
├── 所有被同步锁持有的对象
└── JVM 内部的引用(基本数据类型对应的 Class 对象)

从 GC Roots 出发,沿着引用链搜索,不可达的对象就是垃圾

代码示例:

typescript 复制代码
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)

css 复制代码
阶段1:标记 - 从 GC Roots 遍历,标记所有可达对象
阶段2:清除 - 回收未被标记的对象

内存状态:
[存活][垃圾][存活][垃圾][垃圾][存活]
    ↓ 清除后
[存活][空闲][存活][空闲][空闲][存活]

缺点:产生内存碎片

复制算法(Copying)

css 复制代码
将内存分为两块:From 和 To

阶段1:标记存活对象
阶段2:将存活对象复制到 To 区
阶段3:清空 From 区
阶段4:交换 From 和 To

[存活][垃圾][存活][垃圾]  From
[空][空][空][空]         To
    ↓ 复制后
[空][空][空][空]         From(下次用)
[存活][存活][空][空]     To(下次变成 From)

优点:无内存碎片
缺点:内存利用率只有 50%

新生代使用复制算法,因为新生代对象存活率低,复制开销小。

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

css 复制代码
阶段1:标记存活对象
阶段2:将存活对象向一端移动
阶段3:清理边界外的内存

[存活][垃圾][存活][垃圾][垃圾][存活]
    ↓ 整理后
[存活][存活][存活][空闲][空闲][空闲]

优点:无内存碎片,内存利用率高
缺点:移动对象需要更新引用地址,开销较大

老年代使用标记-整理算法,因为老年代对象存活率高,复制算法代价太大。


4.3 垃圾收集器

新生代收集器

Serial(串行)

ruby 复制代码
单线程收集器,收集时暂停所有工作线程(Stop The World)

适用:单 CPU 环境,客户端模式
参数:-XX:+UseSerialGC

ParNew(并行)

ruby 复制代码
Serial 的多线程版本,多个线程并行收集

适用:多 CPU 环境,配合 CMS 使用
参数:-XX:+UseParNewGC

Parallel Scavenge(吞吐量优先)

ini 复制代码
目标是达到可控的吞吐量(用户代码时间 / 总时间)

参数:
-XX:+UseParallelGC
-XX:MaxGCPauseMillis=200  (最大停顿时间)
-XX:GCTimeRatio=99        (吞吐量 99%,即 GC 时间占 1%)

老年代收集器

Serial Old

arduino 复制代码
Serial 的老年代版本,单线程,标记-整理算法

Parallel Old

ruby 复制代码
Parallel Scavenge 的老年代版本,多线程,标记-整理算法

参数:-XX:+UseParallelOldGC

CMS(Concurrent Mark Sweep)

markdown 复制代码
目标:最短停顿时间
算法:标记-清除(会产生碎片)

执行过程:
1. 初始标记(STW,很快)
2. 并发标记(与用户线程并发执行)
3. 重新标记(STW,比初始标记长,但比并发标记短)
4. 并发清除(与用户线程并发执行)

参数:-XX:+UseConcMarkSweepGC

缺点:
- 对 CPU 资源敏感
- 无法处理浮动垃圾(并发清理时产生的新垃圾)
- 产生内存碎片

G1 收集器(Garbage First)

markdown 复制代码
JDK 9 后的默认收集器,兼顾吞吐量和停顿时间

设计思想:
- 将堆划分为多个 Region(1MB ~ 32MB)
- 每个 Region 可以是 Eden、Survivor 或 Old
- 优先回收垃圾最多的 Region(Garbage First)

执行过程:
1. 初始标记(STW)
2. 并发标记
3. 最终标记(STW)
4. 筛选回收(STW,并行执行)

参数:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200  (期望最大停顿时间)

优点:
- 可预测的停顿时间
- 无内存碎片(整体看是标记-整理,局部看是复制)
- 适用于大堆内存(6GB 以上)

ZGC 和 Shenandoah(低延迟)

diff 复制代码
目标:停顿时间不超过 10ms,且与堆大小无关

ZGC 参数:-XX:+UseZGC
Shenandoah 参数:-XX:+UseShenandoahGC

适用场景:
- 超大堆内存(TB 级别)
- 对延迟极度敏感的应用(金融交易、游戏服务器)

JDK 版本要求:
- ZGC:JDK 11+(生产可用),JDK 15+ 正式支持
- Shenandoah:JDK 12+(Red Hat 开发)

4.4 垃圾收集器选择建议

场景 推荐收集器 参数
单核/小内存 Serial + Serial Old -XX:+UseSerialGC
吞吐量为先(后台计算) Parallel Scavenge + Parallel Old -XX:+UseParallelGC
低延迟为先(Web 应用) CMS / G1 -XX:+UseG1GC
大堆内存(6G+) G1 -XX:+UseG1GC
超大堆/极致低延迟 ZGC / Shenandoah -XX:+UseZGC

五、JVM 参数与调优

5.1 内存参数

ini 复制代码
# 堆内存设置
-Xms4g          # 初始堆大小 4GB
-Xmx4g          # 最大堆大小 4GB(建议 Xms = Xmx,避免动态扩缩容)
-Xmn1g          # 新生代大小 1GB

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

# 栈大小
-Xss1m          # 每个线程栈大小 1MB

# 直接内存(NIO 使用)
-XX:MaxDirectMemorySize=1g

5.2 GC 参数

ruby 复制代码
# 使用 G1 收集器
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

# 打印 GC 日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log

# GC 日志文件轮转(JDK 9+)
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M

# 发生 OOM 时自动生成堆 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof

5.3 调优案例分析

案例一:频繁 Full GC

现象: 线上服务每隔几分钟就 Full GC,停顿时间 2-3 秒

排查:

scss 复制代码
# 查看 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

原因: 老年代空间太小,或者对象晋升太快

解决:

ini 复制代码
# 增大老年代空间
-Xms8g -Xmx8g -Xmn3g  # 老年代 = 8g - 3g = 5g

# 或者增大晋升阈值,让对象在新生代多待一会儿
-XX:MaxTenuringThreshold=15  # 默认 15,可以适当增大

案例二:Metaspace OOM

现象: 服务运行一段时间后 OOM,错误信息 java.lang.OutOfMemoryError: Metaspace

原因: 动态生成类过多(如 CGLIB 代理、反射、动态脚本),元空间被占满

解决:

ini 复制代码
# 增大元空间上限
-XX:MaxMetaspaceSize=512m

# 或者检查代码,是否有类加载泄漏
# 常见原因:动态代理类没有正确卸载、OSGi 热部署等

案例三:Young GC 频繁

现象: 每秒都有 Young GC,每次停顿 50ms

排查:

scss 复制代码
# GC 日志显示
[GC (Allocation Failure) [PSYoungGen: 153600K->2048K(179200K)] 
 153600K->2048K(588800K), 0.0501234 secs]

分析:

  • 新生代 179200K,每次 GC 后从 153600K 降到 2048K
  • 说明新生代空间太小,对象很快填满,频繁触发 GC

解决:

ini 复制代码
# 增大新生代空间
-Xmn2g  # 原来是 1g,改为 2g

# 或者调整 Eden 和 Survivor 比例
-XX:SurvivorRatio=6  # Eden : Survivor = 6 : 1 : 1

六、JVM 监控与诊断工具

6.1 命令行工具

jps:查看 Java 进程

ruby 复制代码
$ jps -lvm
12345 com.example.Application -Xms4g -Xmx4g
12346 sun.tools.jps.Jps -lvm

jstat:查看 GC 统计

yaml 复制代码
# 每隔 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/S1:Survivor 0/1 区使用率
  • E:Eden 区使用率
  • O:Old 区使用率
  • M:Metaspace 使用率
  • YGC/YGCT:Young GC 次数/总耗时
  • FGC/FGCT:Full GC 次数/总耗时

jmap:生成堆 dump

perl 复制代码
# 生成堆 dump 文件
$ jmap -dump:format=b,file=heap.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$Node

jstack:查看线程栈

shell 复制代码
# 打印线程 dump
$ jstack 12345 > thread_dump.txt

# 查看死锁
$ jstack -l 12345 | grep -A 50 "Found one Java-level deadlock"

6.2 可视化工具

VisualVM(JDK 自带)

ruby 复制代码
$ jvisualvm

功能:

  • 监控 CPU、内存、GC
  • 生成和分析堆 dump
  • 线程分析
  • 采样分析热点方法

MAT(Memory Analyzer Tool)

专门分析堆 dump 的工具,功能强大:

bash 复制代码
# 打开 heap.hprof 文件
# 自动分析内存泄漏嫌疑
# 查看 Dominator Tree、Histogram、Leak Suspects

Arthas(阿里开源)

ruby 复制代码
#  attach 到目标进程
$ java -jar arthas-boot.jar

# 常用命令
top    # 查看线程 CPU 占用
heapdump  # 生成堆 dump
jad com.example.Service  # 反编译类
watch com.example.Service getOrder '{params,returnObj}'  # 方法入参和返回值监控

七、总结

理解 JVM 是 Java 开发者的必修课。本文从内存结构、垃圾回收、调优实践三个层面进行了系统讲解:

  1. 运行时数据区:堆、方法区、栈、程序计数器各司其职
  2. 垃圾回收:可达性分析、三种算法、多种收集器的选择
  3. 调优实战:参数设置、案例分析、监控工具的使用

掌握这些知识,面对线上 OOM、GC 问题时,就能做到心中有数、手中有招。

相关推荐
小瓦码J码2 小时前
如何手动部署一个向量模型服务
人工智能·后端
Carsene2 小时前
Spring Boot 包扫描新姿势:AutoScan vs @Import vs @ComponentScan 深度对比
spring boot·后端
Gopher_HBo2 小时前
ReentrantReadWriteLock源码讲解
java·后端
文浩AI2 小时前
Claude Code 创始人 Boris Cherny 的并行工作流最佳实践
后端
武子康2 小时前
大数据-267 实时数仓-架构演进:Lambda与Kappa架构实战指南
大数据·后端
苏三说技术2 小时前
Java程序员必看的RAG入门教程
后端
yongyoudayee2 小时前
2026中国企业出海CRM:五大平台技术能力对比
后端·python·flask
古法安卓3 小时前
Android-LowmemoryKiller机制
android·后端·android studio
MgArcher3 小时前
Python高级特性:Map和Reduce函数完全指南
后端