Java基础(11) | JVM 基础:内存结构、类加载与垃圾回收

📚 本系列系统梳理了 Java 开发的详细知识点,从基础语法到工程实践层层递进,内容详实成体系,建议先收藏再慢慢阅读,方便日后随时回顾查阅。

前言

JVM 是 Java 程序运行的基石------理解它的内存结构、类加载机制和垃圾回收原理,不仅面试必考,更是排查线上 OOM、GC 停顿、类冲突等问题的前提。这篇文章把 JVM 最核心的三大块知识梳理清楚。

1. JVM 内存结构

JVM 把内存划分为几个区域,各司其职:
JVM 内存
线程共享
堆 (Heap)
新生代
Eden + S0 + S1
老年代
方法区 / 元空间
类信息、常量池、静态变量
线程私有
虚拟机栈
栈帧
程序计数器 (PC)
本地方法栈

1.1 堆(Heap)

存放所有对象实例和数组,是 GC 管理的主要区域。

java 复制代码
Object obj = new Object();   // obj 引用在栈上,Object 实例在堆上
int[] arr = new int[100];    // 数组对象也在堆上

堆分为两大区域:

  • 新生代(Young Generation):新创建的对象在这里分配。又细分为 Eden 区和两个 Survivor 区(S0、S1)。大部分对象"朝生夕死",在新生代就被回收。

  • 老年代(Old Generation):经过多次 GC 仍然存活的对象被晋升到这里。老年代的对象生命周期长,GC 频率低但耗时长。

    对象分配流程:
    new Object()
    → Eden 区分配
    → Eden 满了触发 Minor GC
    → 存活对象复制到 Survivor 区
    → 多次 GC 后仍存活(默认 15 次)
    → 晋升到老年代
    → 老年代满了触发 Major GC / Full GC

1.2 虚拟机栈(VM Stack)

每个线程一个栈,每调用一个方法就压入一个栈帧(Stack Frame)

java 复制代码
public void methodA() {
    int x = 10;          // x 在 methodA 的栈帧中
    methodB(x);
}

public void methodB(int y) {
    String s = "hello";  // y 和 s 在 methodB 的栈帧中
}

栈帧包含:

组成部分 内容
局部变量表 基本类型的值、对象引用
操作数栈 方法执行时的中间计算结果
动态链接 指向方法区中该方法的引用
返回地址 方法执行完后回到哪里继续执行

栈的两种异常:

java 复制代码
// StackOverflowError:栈深度超限(通常是无限递归)
public void infinite() {
    infinite();  // 每次调用压一个栈帧,最终溢出
}

// OutOfMemoryError:创建太多线程,每个线程都要分配栈空间

1.3 程序计数器(PC Register)

每个线程一个,记录当前正在执行的字节码指令地址。是 JVM 中唯一不会 OOM 的区域

1.4 方法区 / 元空间(Metaspace)

存储已加载的类信息、常量、静态变量、JIT 编译后的代码

复制代码
Java 7 及之前:方法区在 JVM 内存中(永久代 PermGen),大小有限,容易 OOM
Java 8 开始:改为元空间(Metaspace),使用本地内存(Native Memory),默认不限大小
java 复制代码
// 元空间溢出场景:动态生成大量类(如 CGLIB 代理、大量 JSP)
// 报错:java.lang.OutOfMemoryError: Metaspace
// 调优:-XX:MaxMetaspaceSize=256m

1.5 各区域 OOM 总结

区域 异常 常见原因
OutOfMemoryError: Java heap space 对象太多、内存泄漏
StackOverflowError 无限递归、方法调用太深
OutOfMemoryError 线程太多
元空间 OutOfMemoryError: Metaspace 动态生成大量类
直接内存 OutOfMemoryError: Direct buffer memory NIO ByteBuffer.allocateDirect 过多

1.6 JVM 内存区域总结

区域 线程私有/共享 存放内容 生命周期
堆(Heap) 共享 所有对象实例、数组 JVM 启动到关闭
虚拟机栈(VM Stack) 私有 局部变量(基本类型值、对象引用)、方法调用栈帧 随线程创建/销毁
程序计数器(PC Register) 私有 当前执行的字节码指令地址 随线程创建/销毁
方法区 / 元空间(Metaspace) 共享 类信息、常量、静态变量、JIT 编译代码 JVM 启动到关闭
本地方法栈(Native Stack) 私有 native 方法(如 C/C++ 实现的 JNI 方法)的调用栈 随线程创建/销毁

一个变量到底存在哪,取决于它是什么:

变量类型 存放位置 示例
局部变量(基本类型) int x = 10; 方法内
局部变量(引用) 栈上存引用,堆上存对象 String s = "hi"; 方法内
实例变量 堆(跟随对象) private String name;
静态变量 方法区 / 元空间 static int count;
常量 方法区的常量池 static final int MAX = 100;

2. 类加载机制

2.1 类的生命周期

一个 .class 文件从被加载到内存,到被卸载,经历以下阶段:

复制代码
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
      └─── 连接(Linking) ──┘
阶段 做什么 通俗理解
加载 读取 .class 文件字节码,在内存中生成 Class 对象 把简历读进来
验证 校验字节码是否合法,防止恶意代码 查验简历真假
准备 为 static 变量分配内存,赋默认值(0 / null / false) 先安排工位,名牌空着
解析 将符号引用替换为直接引用(内存地址) 把"人名"换成"工位号"
初始化 执行 static {} 和 static 变量的真正赋值 员工正式入职,开始干活

准备 vs 初始化是最容易混淆的,用一个例子说明:

java 复制代码
public class Demo {
    static int a = 10;         // 准备阶段:a = 0(默认值)
                                // 初始化阶段:a = 10(真正赋值)

    static final int B = 20;   // 特殊:编译期常量,准备阶段直接就是 20,不用等初始化

    static {
        System.out.println("类初始化了");  // 初始化阶段才执行
    }
}

时间线:

复制代码
准备阶段完成后:  a = 0,   B = 20
初始化阶段完成后:a = 10,  B = 20,  打印"类初始化了"

为什么要分两步? 因为 JVM 需要先给所有 static 变量分配好内存空间(准备),然后才能按代码顺序执行赋值和 static 代码块(初始化)。如果两步合一,可能出现 A 类的 static 变量引用 B 类,但 B 类还没分配内存的情况。

什么时候会触发类的初始化?

触发 不触发
new 创建对象 访问 static final 编译期常量(准备阶段就有了)
访问/修改 static 变量 子类访问父类 static 变量(只初始化父类)
调用 static 方法 Class.forName 传入 initialize=false
Class.forName("类名") 定义数组类型 Demo[] arr
main 方法所在的类
java 复制代码
// 不会触发 Demo 初始化(B 是编译期常量,准备阶段就有了)
System.out.println(Demo.B);  // 输出 20,不会打印"类初始化了"

// 会触发 Demo 初始化
System.out.println(Demo.a);  // 先打印"类初始化了",再输出 10

2.2 双亲委派模型

什么是类加载器? JVM 不会一次性把所有 .class 文件都加载进内存,而是用到哪个类才加载哪个。负责加载的组件就是类加载器(ClassLoader)

为什么有多个类加载器? 不同的类放在不同的位置,各司其职:

类加载器 加载什么 举例
Bootstrap ClassLoader Java 核心类库 StringArrayListHashMapjava.*
Extension ClassLoader JDK 扩展类库 jre/lib/ext 目录下的类
Application ClassLoader 你写的代码和第三方依赖 你项目里的类、Maven 引入的 jar 包
自定义 ClassLoader 特殊位置的类 从网络加载、热部署、插件系统

什么是双亲委派? 当需要加载一个类时,不是自己先加载,而是先问父类能不能加载。父类也先问父类的父类,一层层往上问,直到最顶层的 Bootstrap。谁能加载谁来,都不能加载才轮到自己。

text 复制代码
你的代码用到 String 类,触发加载:

Application ClassLoader 收到请求
  → "我先不加载,问问我父类"
  → Extension ClassLoader 收到请求
      → "我也先不加载,问问我父类"
      → Bootstrap ClassLoader 收到请求
          → "String 在 rt.jar 里,我能加载!"
          → 加载完成,返回

你的代码用到 com.company.MyService 类:

Application ClassLoader 收到请求
  → 委派给 Extension ClassLoader
      → 委派给 Bootstrap ClassLoader
          → "不在我管的范围,加载不了"
      → Extension ClassLoader: "也不在我这,加载不了"
  → Application ClassLoader: "在 classpath 里找到了,我来加载"

为什么要这么设计? 安全。假如有人写了一个恶意的 java.lang.String 类放在项目里,双亲委派会让 Bootstrap 优先加载 JDK 自带的 String,你写的假 String 永远不会被加载。保证了核心类不会被篡改。

java 复制代码
// 验证类加载器层级
System.out.println(String.class.getClassLoader());
// null(Bootstrap 是 C++ 实现的,Java 中显示为 null)

System.out.println(MyService.class.getClassLoader());
// AppClassLoader(你的代码由 Application ClassLoader 加载)

System.out.println(MyService.class.getClassLoader().getParent());
// ExtClassLoader(Application 的父加载器是 Extension)

System.out.println(MyService.class.getClassLoader().getParent().getParent());
// null(Extension 的父加载器是 Bootstrap,显示为 null)

2.3 为什么要双亲委派?

安全性 :防止用户自定义一个 java.lang.String 来替换核心类。无论谁请求加载 String,最终都会由 Bootstrap ClassLoader 加载 rt.jar 中的那个。

一致性 :保证同一个类在 JVM 中只被加载一次,所有代码用的是同一个 String.class

2.4 打破双亲委派

某些场景需要打破双亲委派:

java 复制代码
// 典型场景:
// 1. Tomcat:每个 Web 应用有自己的 ClassLoader,同名类互不影响
// 2. SPI 机制:Bootstrap ClassLoader 加载的接口需要加载 classpath 上的实现类
// 3. 热部署:抛弃旧 ClassLoader,创建新的来加载修改后的类

// 自定义 ClassLoader 示例
public class MyClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] bytes = loadClassBytes(name);  // 从自定义位置读取字节码
        return defineClass(name, bytes, 0, bytes.length);
    }
}

3. 垃圾回收(GC)

3.1 如何判断对象是否可回收?

GC 的核心问题:堆上有一堆对象,哪些还有用,哪些是垃圾?有两种判断方式。

方式一:引用计数法(JVM 不用,了解即可)

每个对象维护一个计数器,有人引用它就 +1,引用断了就 -1,减到 0 说明没人用了,可以回收:

java 复制代码
Object a = new Object();  // 对象 A 被 a 引用,计数 = 1
Object b = a;             // 对象 A 又被 b 引用,计数 = 2
a = null;                 // a 不再引用,计数 = 1
b = null;                 // b 不再引用,计数 = 0 → 可以回收

听起来很简单,但有一个致命问题------循环引用

java 复制代码
class Node {
    Node ref;  // 指向另一个节点
}

Node a = new Node();  // 对象 A 计数 = 1(被变量 a 引用)
Node b = new Node();  // 对象 B 计数 = 1(被变量 b 引用)
a.ref = b;            // 对象 B 计数 = 2(被变量 b + 对象 A 的 ref 引用)
b.ref = a;            // 对象 A 计数 = 2(被变量 a + 对象 B 的 ref 引用)

a = null;  // 对象 A 计数 = 1(还被对象 B 的 ref 引用着)
b = null;  // 对象 B 计数 = 1(还被对象 A 的 ref 引用着)

// 两个对象计数都不为 0,无法回收
// 但实际上已经没有任何变量能访问到它们了------这就是内存泄漏

所以 JVM 不用引用计数法,用下面这种。

方式二:可达性分析(JVM 实际使用)

思路很简单:从一组"肯定活着"的对象出发,沿着引用链往下找,能找到的就是活的,找不到的就是垃圾

这组"肯定活着"的起点叫 GC Roots 。哪些对象有资格当 GC Roots?就是那些你的代码正在直接使用的东西

GC Root 为什么肯定活着 举例
栈中的局部变量 方法正在执行,变量正在被用 void foo() { List list = new ArrayList(); } 中的 list
static 变量 类活着它就活着 static Map cache = new HashMap(); 中的 cache
常量引用 不会变 static final String NAME = "test"; 中的 "test"
synchronized 锁持有的对象 正在被锁着,不能回收 synchronized(obj) 中的 obj

用上面循环引用的例子走一遍可达性分析:

复制代码
a = null; b = null; 之后:

从 GC Roots 出发(栈中的局部变量 a 和 b 都是 null 了)
  → 没有任何 GC Root 指向对象 A 或对象 B
  → 对象 A 和对象 B 都不可达
  → 都是垃圾,可以回收 ✅

引用计数法搞不定的循环引用,可达性分析轻松解决

再看一个正常的例子:

java 复制代码
void foo() {
    List<String> list = new ArrayList<>();  // list 是 GC Root(栈中局部变量)
    list.add("hello");                       // "hello" 被 list 引用,可达

    Map<String, List<String>> map = new HashMap<>();
    map.put("key", list);                    // map 也是 GC Root
}
// foo() 执行完毕,list 和 map 从栈中弹出,不再是 GC Root
// → ArrayList、HashMap、"hello" 都不可达 → 全部可回收

3.2 四种引用类型

GC 判断"能不能回收"时,不是只看"有没有引用",还要看引用的强度。Java 有四种引用,强度从高到低:

强引用(Strong Reference):日常写的代码都是强引用

java 复制代码
Object obj = new Object();  // obj 就是强引用
// 只要 obj 还指着这个对象,GC 绝对不会回收它
// 哪怕内存不够了,宁可抛 OOM 也不回收强引用对象
obj = null;  // 断开引用后才能被回收

软引用(Soft Reference):内存够就留着,不够就回收

java 复制代码
// 场景:图片缓存。图片占内存大,缓存着能加速,但内存不够时宁可丢掉缓存也别 OOM
SoftReference<byte[]> cache = new SoftReference<>(new byte[10 * 1024 * 1024]);

byte[] data = cache.get();  // 尝试获取
if (data != null) {
    // 内存充足,缓存还在,直接用
} else {
    // 内存不足时 GC 回收了它,返回 null,需要重新加载
    data = loadFromDisk();
}

弱引用(Weak Reference):不管内存够不够,下次 GC 就回收

java 复制代码
// 场景:WeakHashMap,key 被 GC 回收后,对应的 entry 自动删除,防止内存泄漏
WeakReference<Object> weak = new WeakReference<>(new Object());

weak.get();      // 能拿到(GC 还没来)
System.gc();     // 触发 GC
weak.get();      // null(GC 一来就被回收了)

虚引用(Phantom Reference):最弱,get() 永远返回 null

java 复制代码
// 场景:跟踪对象什么时候被回收了,做清理工作(比如释放堆外内存)
// 日常开发基本用不到,了解即可

总结:

引用类型 类比 GC 态度 典型场景
强引用 亲儿子 打死也不回收,宁可 OOM 所有普通变量
软引用 家里亲戚 家里宽裕就住着,住不下了请你走 内存敏感的缓存
弱引用 临时访客 打扫卫生(GC)就清走 WeakHashMap
虚引用 监控探头 随时清走,只是通知你一声 跟踪回收状态

日常开发 99% 都是强引用,偶尔用软引用做缓存,弱引用和虚引用在框架源码里才会见到。

3.3 GC 算法

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

优点:简单
缺点:产生内存碎片
标记-复制(Copying)
复制代码
将内存分为两半,每次只用一半
GC 时把存活对象复制到另一半,清空当前这半

优点:无碎片,分配快(指针碰撞)
缺点:可用内存减半

新生代使用此算法(Eden + S0 + S1 的设计就是优化版的复制算法)
标记-整理(Mark-Compact)
复制代码
标记阶段:同标记-清除
整理阶段:将存活对象向一端移动,清空边界以外的内存

优点:无碎片
缺点:移动对象开销大

老年代使用此算法

3.4 分代回收策略

为什么要分代? 研究发现大部分对象"朝生夕死"(比如方法里的局部变量,方法执行完就没用了),少部分对象长期存活(比如缓存、连接池)。把它们分开管理,用不同的策略回收,效率更高。

堆被分成两大区域

区域 占比 存放什么 GC 频率
新生代(Young) 约 1/3 新创建的对象 频繁,但每次很快
老年代(Old) 约 2/3 长期存活的对象 很少,但每次很慢

新生代内部又分三块

复制代码
新生代(Young Generation)
┌──────────────┬───────┬───────┐
│    Eden      │  S0   │  S1   │
│   (80%)      │ (10%) │ (10%) │
└──────────────┴───────┴───────┘
  新对象在这里诞生   两个 Survivor 区轮流使用

用搬家来理解整个流程

把 Eden 想象成一个临时宿舍,S0 和 S1 是两个小隔间,老年代是正式住所:

复制代码
第一步:新对象在 Eden 出生
  new Object() → 分配到 Eden 区

第二步:Eden 满了,触发 Minor GC
  GC 检查 Eden 里所有对象:
    还有人引用的(存活)→ 搬到 S0,年龄标记为 1
    没人引用的(垃圾)→ 直接清除
  Eden 清空

第三步:Eden 又满了,再次 Minor GC
  GC 同时检查 Eden 和 S0:
    存活的 → 全部搬到 S1,年龄 +1
    垃圾 → 清除
  Eden 和 S0 清空

第四步:Eden 又满了,再次 Minor GC
  GC 同时检查 Eden 和 S1:
    存活的 → 全部搬到 S0,年龄 +1
    垃圾 → 清除
  Eden 和 S1 清空

  (S0 和 S1 就这样轮流交替,始终有一个是空的)

第五步:某个对象年龄达到 15(默认阈值)
  说明这个对象经历了 15 次 GC 都没死 → 搬到老年代(正式住下)

第六步:老年代也满了
  触发 Major GC / Full GC → 整个堆大扫除,耗时很长(程序会卡顿)

为什么 Survivor 要两个轮流用? 这是标记-复制算法的核心------每次 GC 把存活对象复制到另一个 Survivor,然后把原来那个整块清空。这样不会产生内存碎片(不像标记-清除会留下"洞"),而且新生代大部分对象都是垃圾,真正需要复制的很少,所以速度很快。

用具体数字感受一下

复制代码
假设 Eden 每次 GC 有 100 个对象:
  → 大约 95 个是垃圾,直接清掉
  → 只有 5 个存活,复制到 Survivor
  → 复制 5 个比整理 100 个快得多

这也是为什么 Eden 占 80%、Survivor 各占 10% ------ 因为大部分对象活不过第一次 GC,Survivor 不需要太大。

3.5 主流垃圾回收器

回收器 算法 区域 特点
Serial 复制 / 标记-整理 新 / 老 单线程,STW,适合客户端
Parallel (默认) 复制 / 标记-整理 新 / 老 多线程并行,吞吐量优先
CMS 标记-清除 低延迟,已废弃(Java 14 移除)
G1(Java 9 默认) 分区 + 复制 + 整理 全堆 可预测停顿,兼顾吞吐和延迟
ZGC(Java 15+) 着色指针 + 读屏障 全堆 停顿 < 1ms,适合大堆
Shenandoah Brooks 指针 全堆 类似 ZGC,Red Hat 主导
G1 核心思想

4. JVM 常用参数

4.1 内存设置

bash 复制代码
# 堆大小
-Xms512m          # 初始堆大小(建议和 Xmx 设为一样,避免动态扩缩)
-Xmx2g            # 最大堆大小

# 新生代
-Xmn512m          # 新生代大小
-XX:NewRatio=2    # 老年代 : 新生代 = 2 : 1(默认)
-XX:SurvivorRatio=8  # Eden : S0 : S1 = 8 : 1 : 1(默认)

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

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

4.2 GC 选择

bash 复制代码
-XX:+UseG1GC                # 使用 G1(Java 9+ 默认)
-XX:+UseZGC                 # 使用 ZGC(Java 15+)
-XX:MaxGCPauseMillis=200    # G1 期望最大停顿时间

4.3 GC 日志

bash 复制代码
# Java 9+ 统一日志(推荐)
-Xlog:gc*:file=gc.log:time,uptime,level,tags

# 打印更详细的信息
-Xlog:gc+heap=debug:file=gc.log

4.4 排查工具

bash 复制代码
# 查看 JVM 进程
jps -l

# 查看堆内存使用
jmap -heap <pid>

# 导出堆快照(分析内存泄漏)
jmap -dump:format=b,file=heap.hprof <pid>

# 查看线程状态(排查死锁、CPU 飙高)
jstack <pid>

# 实时监控 GC 情况(每秒刷新)
jstat -gcutil <pid> 1000

# 可视化工具
# jconsole / jvisualvm(JDK 自带)
# Arthas(阿里开源,线上诊断神器)

5. 常见 OOM 排查思路

复制代码
OutOfMemoryError: Java heap space
  → jmap -dump 导出堆快照
  → 用 MAT 或 VisualVM 分析
  → 找到占用内存最大的对象
  → 检查是否有内存泄漏(长生命周期对象持有短生命周期对象的引用)
  → 常见原因:
     - 集合不断添加不清理(Map 做缓存没有淘汰策略)
     - 大查询一次性加载全部数据(应该分页)
     - 静态集合持有大量对象

OutOfMemoryError: Metaspace
  → 检查是否动态生成大量类(CGLIB 代理、反射、脚本引擎)
  → 增大 -XX:MaxMetaspaceSize

StackOverflowError
  → 检查递归是否有终止条件
  → 考虑将递归改为迭代
  → 增大 -Xss(治标不治本)

6. 小结

主题 关键要点
所有对象实例在这里分配;分新生代(Eden + S0 + S1)和老年代
线程私有,每个方法调用一个栈帧;存局部变量和引用
方法区/元空间 类信息、常量池、静态变量;Java 8 改为本地内存
类加载 加载 → 验证 → 准备 → 解析 → 初始化
双亲委派 先委派父加载器,保证核心类安全和唯一
可达性分析 从 GC Roots 出发,不可达即为垃圾
GC 算法 标记-清除(碎片)、标记-复制(新生代)、标记-整理(老年代)
GC 回收器 G1 是默认选择,ZGC 适合大堆低延迟场景
调优工具 jmap(内存)、jstack(线程)、jstat(GC)、Arthas(线上诊断)

下一篇预告:注解与反射------动态类信息获取与运行时行为修改


🎯 如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连!关注我,让你在 Java 学习的道路上不迷路,持续为你带来成体系的 Java 干货~