JVM 的底层机制始终是我们构建高性能、高可用系统的基石。本文将从内存布局、垃圾回收到类加载机制,系统性地拆解 JVM 的核心原理,帮助你从根本上理解 Java 程序的运行机制。

一、JVM 内存模型与运行时数据区

很多初学者容易混淆 Java 内存模型 (JMM)JVM 运行时数据区 这两个概念。

  • Java 内存模型 (JMM):是一套并发编程的内存访问规范,定义了线程和主内存之间的抽象关系,解决了多线程下的可见性、原子性和有序性问题(如volatile的语义实现)。

  • JVM 运行时数据区:是 JVM 在执行 Java 程序时,将所管理的内存划分为若干个不同的数据区域,也就是我们常说的 "JVM 内存模型"。

1.1 内存区域总览

JVM 运行时数据区根据线程的共享性,主要分为两大类:线程私有区线程共享区

图 1: 不同 JDK 版本下的运行时数据区演变

区域类型

包含区域

核心特点

线程私有

程序计数器、虚拟机栈、本地方法栈

随线程创建而创建,线程隔离,无线程安全问题,OOM 概率低

线程共享

堆、方法区(元空间)

所有线程共用,GC 主要回收区域,OOM 高发区

其他

直接内存(堆外内存)

不属于 JVM 规范,由 NIO 使用,需手动管理

1.2 线程私有区域详解

程序计数器 (Program Counter Register)

这是一块极小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

  • 线程私有:每个线程都有独立的计数器,切换线程时能恢复到正确的执行位置。

  • 无 OOM:这是 JVM 规范中唯一一个没有规定任何 OutOfMemoryError 的区域。

  • Native 方法特殊值:如果线程正在执行的是一个 Native 方法,那这个计数器的值为空 (undefined)。

虚拟机栈 (VM Stack)

描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧 (Stack Frame)。

  • 栈帧结构

    • 局部变量表:存放了编译期可知的各种基本数据类型和对象引用。

    • 操作数栈:方法执行过程中的数据运算栈。

    • 动态链接:将符号引用转换为直接引用。

    • 方法返回地址:方法执行完后,要回到调用该方法的地方。

  • 常见异常

    • StackOverflowError:栈深度超过虚拟机限制,典型场景是无限递归。

    • OutOfMemoryError:如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存。

  • 参数配置-Xss1m 用于设置每个线程的栈大小。

本地方法栈 (Native Method Stack)

与虚拟机栈作用非常相似,区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

  • 在 HotSpot 虚拟机中,直接将本地方法栈和虚拟机栈合二为一,因此无需单独配置。

1.3 线程共享区域详解

堆 (Heap)

Java 堆是 JVM 所管理的最大的一块内存,所有的对象实例以及数组都要在堆上分配。

  • GC 的主战场:几乎所有的垃圾回收都围绕着堆来进行,因此也被叫做 "GC 堆"。

  • 分代划分:为了优化 GC 效率,堆被划分为:

    • 新生代 (Young Gen):存放新创建的对象,分为 Eden (80%)、From Survivor (10%)、To Survivor (10%)。

    • 老年代 (Old Gen):存放存活时间较长的对象,默认经历 15 次 GC 后晋升。

  • OOM 场景java.lang.OutOfMemoryError: Java heap space,通常由内存泄漏或堆大小设置过小导致。

  • 参数配置-Xms(初始堆)、-Xmx(最大堆)。

方法区 (Method Area)

用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  • JDK 版本的重大演变

    • JDK 7 及之前:称为 "永久代 (PermGen)",它是堆的一部分,有固定大小限制,容易溢出。

    • JDK 8 及之后:彻底移除了永久代,改用元空间 (Metaspace) 实现。元空间并不在虚拟机中,而是使用本地内存。

  • OOM 场景

    • JDK7: OOM: PermGen space

    • JDK8+: OOM: Metaspace,常见于动态生成大量类的场景(如 CGLIB、反射)。

  • 参数配置-XX:MaxMetaspaceSize 限制元空间大小。

运行时常量池 (Runtime Constant Pool)

它是方法区的一部分,Class 文件中的常量池表,在类加载后进入方法区的运行时常量池存放。

  • 动态性:运行期间也可以将新的常量放入池中,典型的就是String.intern()方法。

1.4 直接内存 (Direct Memory)

这并不是 JVM 运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

  • NIO 的优化:在 JDK1.4 中新加入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。

  • 优势:避免了在 Java 堆和 Native 堆中来回复制数据,提升了 IO 性能。

  • OOM 风险:虽然不受 - Xmx 控制,但本机的物理内存是有限的,也会导致 OOM: Direct buffer memory


二、垃圾回收 (GC) 机制

自动内存管理的核心就是垃圾回收 (GC)。它解决了手动管理内存的痛点,让我们无需手动free内存。

2.1 如何判定垃圾?

要回收垃圾,首先要判断哪些对象是 "活着的",哪些已经 "死了"。

引用计数算法 (Reference Counting)

给每个对象添加一个引用计数器,每当有一个地方引用它,计数器就加 1;引用失效就减 1。

  • 优点:实现简单,判定高效。

  • 缺点:无法解决循环引用问题。因此,主流的 Java 虚拟机都没有使用这个算法。

可达性分析算法 (Reachability Analysis)

这个算法的基本思路是:通过一系列的称为 "GC Roots" 的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可用的。

  • GC Roots 包括

    • 虚拟机栈(栈帧中的局部变量表)中引用的对象。

    • 本地方法栈中 Native 方法引用的对象。

    • 方法区中类静态属性引用的对象。

    • 方法区中常量引用的对象。

    • 同步锁(synchronized)持有的对象。

2.2 垃圾回收算法

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

最基础的算法,分为 "标记" 和 "清除" 两个阶段。

  1. 标记:标记出所有需要回收的对象。

  2. 清除:统一回收掉所有被标记的对象。

  • 缺点

    • 内存碎片:标记清除后会产生大量不连续的内存碎片,导致无法为大对象分配连续空间。

    • 效率问题:如果存活对象多,标记和清除的效率都会下降。

复制算法 (Copying)

为了解决标记清除的碎片问题,复制算法出现了。

  1. 将内存分为大小相等的两块,一块是 From,一块是 To。

  2. 每次只使用其中一块,当这一块用完了,就将存活的对象复制到另一块上,然后把原来的内存一次性清理掉。

  • 优点:没有碎片,分配内存时只需要移动指针即可,实现简单,运行高效。

  • 缺点:内存利用率低,只能用一半。

  • 新生代的优化:新生代的对象存活率极低,因此 HotSpot 默认把新生代分为 Eden:Survivor:Survivor = 8:1:1,这样利用率就达到了 90%。

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

针对老年代对象存活率高的特点,复制算法就不太适用了。因此老年代通常使用标记 - 整理算法。

  1. 标记:和标记清除一样,标记存活对象。

  2. 整理:让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

  • 优点:没有内存碎片。

  • 缺点:需要移动对象,成本较高。

分代收集算法 (Generational Collection)

这不是一个新的算法,而是当前商业虚拟机的主流策略,它根据对象的存活周期将内存划分为几块。

  • 新生代:对象朝生夕灭,存活率低,用复制算法

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

2.3 垃圾收集器演进

收集器是算法的具体实现。随着 JDK 的发展,收集器也在不断演进,从最早的串行收集器,到现在的低延迟并发收集器。

图 2: 从 Serial 到 ZGC 的收集器演进历程

收集器

适用代

算法

核心特点

适用场景

JDK 状态

Serial

新生代

复制

单线程收集,STW

客户端 / 单核 CPU

基础

ParNew

新生代

复制

Serial 的多线程版

多 CPU,配合 CMS

JDK9 废弃

Parallel Scavenge

新生代

复制

吞吐量优先

后台批处理

JDK8 默认

Serial Old

老年代

标记 - 整理

单线程

客户端 / CMS 降级

基础

Parallel Old

老年代

标记 - 整理

Parallel 的老年代版

吞吐量优先

JDK1.6+

CMS

老年代

标记 - 清除

并发标记,低延迟

互联网应用

JDK9 废弃

G1 (Garbage-First)

全代

标记 - 整理 + 复制

分区化,可预测停顿

大堆 (4G+),均衡场景

JDK9 默认

ZGC

全代

着色指针 + 读屏障

亚毫秒级停顿,TB 级堆

超低延迟,云原生

JDK11+

Shenandoah

全代

转发指针 + 写屏障

并发压缩,低延迟

低延迟,开源

JDK12+

经典收集器详解
CMS (Concurrent Mark Sweep)

CMS 是第一款并发收集器,它第一次实现了让垃圾收集线程与用户线程基本上同时工作。

  • 运作过程

    • 初始标记 (Initial Mark):STW,只标记 GC Roots 能直接关联到的对象,很快。

    • 并发标记 (Concurrent Mark):用户线程并发执行,遍历对象图。

    • 重新标记 (Remark):STW,修正并发标记期间的变动。

    • 并发清除 (Concurrent Sweep):用户线程并发执行,清除垃圾。

  • 缺点

    • 对 CPU 资源敏感,会占用部分线程。

    • 无法处理浮动垃圾。

    • 标记 - 清除导致内存碎片。

G1 (Garbage-First)

G1 是一个里程碑式的收集器,它打破了分代的物理界限。

  • Region 分区:它将整个堆划分为约 2048 个大小相等的 Region,每个 Region 可以充当 Eden、Survivor 或 Old。

  • 可预测的停顿模型:你可以指定-XX:MaxGCPauseMillis,G1 会优先回收垃圾最多的 Region。

  • 标记 - 整理:整体上是标记 - 整理算法,局部是复制算法,几乎没有内存碎片。

ZGC (The Z Garbage Collector)

JDK11 引入的新一代收集器,目标是处理 TB 级堆,且停顿时间不超过 10ms

  • 着色指针 (Colored Pointers):利用 64 位指针的剩余位来标记对象的状态,无需额外的标记位图。

  • 读屏障 (Load Barrier):在并发移动对象时,通过读屏障来修正引用,实现了并发压缩。

  • NUMA 感知:支持非统一内存访问,在多核服务器上性能更好。

  • 无分代:目前 ZGC 不分代,因为它的并发能力足够强,能处理所有对象。


三、类加载机制与双亲委派

Java 程序是动态加载的,JVM 在运行期才会将 class 文件加载到内存中。这个过程就是类加载。

3.1 类加载的生命周期

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括: 加载 (Loading) → 验证 (Verification) → 准备 (Preparation) → 解析 (Resolution) → 初始化 (Initialization) → 使用 (Using) → 卸载 (Unloading)

其中,验证、准备、解析三个部分统称为连接 (Linking)

  1. 加载:通过全限定名获取二进制字节流,将其转化为方法区的运行时数据结构,并在堆中生成一个java.lang.Class对象。

  2. 验证:确保 Class 文件的字节流符合虚拟机的要求,防止恶意代码。

  3. 准备:为静态变量分配内存并设置默认初始值(如 int=0,Object=null)。注意:这时候还没执行代码,static int a=100的 100 是在初始化阶段才赋值的。

  4. 解析:将符号引用(比如Ljava/lang/String;)替换为直接引用(内存地址)。

  5. 初始化:执行静态代码块和静态变量的赋值代码,也就是执行<clinit>()方法。

3.2 双亲委派模型

为了保证 Java 核心类库的安全,JVM 设计了双亲委派模型来组织类加载器之间的关系。

图 3: 双亲委派模型的工作流程

类加载器的层次

从下往上,类加载器分为四层:

  1. 启动类加载器 (Bootstrap ClassLoader):最顶层,C++ 实现,加载JAVA_HOME/lib下的核心类库(如 rt.jar)。

  2. 扩展类加载器 (Extension ClassLoader):加载JAVA_HOME/lib/ext下的扩展类。

  3. 应用程序类加载器 (Application ClassLoader):加载用户 classpath 下的类,也是默认的类加载器。

  4. 自定义类加载器:用户自己继承ClassLoader实现的加载器。

工作原理

双亲委派的核心逻辑是:"向上委托,向下加载"。 当一个类加载器收到类加载请求时:

  1. 它首先会检查这个类是否已经被加载过了。

  2. 如果没有,它不会自己先尝试加载,而是把这个请求委派给父类加载器去完成。

  3. 每一层的加载器都是如此,直到请求最终到达顶层的启动类加载器。

  4. 如果父类加载器反馈无法加载这个类(它的加载路径下没有这个 class),子类加载器才会尝试自己去加载。

双亲委派的好处
  • 沙箱安全:防止核心类被篡改。比如你自己写了一个java.lang.String类,由于双亲委派,这个请求会一直委派给 Bootstrap 加载器,它加载了 JDK 自带的 String,从而避免了恶意代码替换核心类。

  • 类的唯一性:保证了全限定名相同的类,在不同的类加载器下也能保证是同一个 Class 对象。

3.3 打破双亲委派

双亲委派不是强制的,在某些场景下,我们需要打破它。

1. SPI 机制 (Service Provider Interface)

这是最典型的打破场景。比如 JDBC 的java.sql.Driver接口,这个接口是启动类加载器加载的。但是它的实现类(比如 MySQL 的com.mysql.cj.jdbc.Driver)是在用户的 classpath 下的,启动类加载器根本找不到。

  • 解决方案:JDK 通过线程上下文类加载器 (Thread Context ClassLoader),让父类加载器可以调用子类加载器来加载类,从而打破了双亲委派的模型。

2. 热部署与模块化
  • Tomcat:Tomcat 为了让同一个服务器上的多个 Web 应用互相隔离,每个 Web 应用都有自己的类加载器,它们可以加载自己的类,而不会互相影响,这也打破了双亲委派。

  • OSGi:OSGi 的模块化热部署,它的类加载器是网状结构,不再是双亲的树状结构,实现了模块的动态安装和卸载。

3. 自定义类加载器

通过重写ClassLoaderloadClass()方法,我们可以不遵循双亲委派的逻辑,自己控制类的加载顺序。这通常用于热加载、加密 class 文件解密等场景。


总结

JVM 的这三大核心机制 ——内存布局、垃圾回收、类加载,构成了 Java 生态的基石。

  • 理解内存布局,能帮你快速定位 OOM 问题,知道不同的 OOM 错误分别出现在哪里。

  • 理解 GC,能帮你根据业务场景选择合适的收集器,是追求吞吐量还是低延迟,从而做出正确的 JVM 参数调优。

  • 理解类加载和双亲委派,能帮你理解 Tomcat、Spring 等中间件的底层设计,理解为什么 SPI 能工作,以及热部署的原理。

在大规模高并发系统中,这些底层知识往往是解决性能瓶颈、线上故障的关键钥匙。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐