JVM核心机制深度解析
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)
最基础的算法,分为 "标记" 和 "清除" 两个阶段。
-
标记:标记出所有需要回收的对象。
-
清除:统一回收掉所有被标记的对象。
-
缺点:
-
内存碎片:标记清除后会产生大量不连续的内存碎片,导致无法为大对象分配连续空间。
-
效率问题:如果存活对象多,标记和清除的效率都会下降。
-
复制算法 (Copying)
为了解决标记清除的碎片问题,复制算法出现了。
-
将内存分为大小相等的两块,一块是 From,一块是 To。
-
每次只使用其中一块,当这一块用完了,就将存活的对象复制到另一块上,然后把原来的内存一次性清理掉。
-
优点:没有碎片,分配内存时只需要移动指针即可,实现简单,运行高效。
-
缺点:内存利用率低,只能用一半。
-
新生代的优化:新生代的对象存活率极低,因此 HotSpot 默认把新生代分为 Eden:Survivor:Survivor = 8:1:1,这样利用率就达到了 90%。
标记 - 整理算法 (Mark-Compact)
针对老年代对象存活率高的特点,复制算法就不太适用了。因此老年代通常使用标记 - 整理算法。
-
标记:和标记清除一样,标记存活对象。
-
整理:让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
-
优点:没有内存碎片。
-
缺点:需要移动对象,成本较高。
分代收集算法 (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)。
-
加载:通过全限定名获取二进制字节流,将其转化为方法区的运行时数据结构,并在堆中生成一个
java.lang.Class对象。 -
验证:确保 Class 文件的字节流符合虚拟机的要求,防止恶意代码。
-
准备:为静态变量分配内存并设置默认初始值(如 int=0,Object=null)。注意:这时候还没执行代码,
static int a=100的 100 是在初始化阶段才赋值的。 -
解析:将符号引用(比如
Ljava/lang/String;)替换为直接引用(内存地址)。 -
初始化:执行静态代码块和静态变量的赋值代码,也就是执行
<clinit>()方法。
3.2 双亲委派模型
为了保证 Java 核心类库的安全,JVM 设计了双亲委派模型来组织类加载器之间的关系。

图 3: 双亲委派模型的工作流程
类加载器的层次
从下往上,类加载器分为四层:
-
启动类加载器 (Bootstrap ClassLoader):最顶层,C++ 实现,加载
JAVA_HOME/lib下的核心类库(如 rt.jar)。 -
扩展类加载器 (Extension ClassLoader):加载
JAVA_HOME/lib/ext下的扩展类。 -
应用程序类加载器 (Application ClassLoader):加载用户 classpath 下的类,也是默认的类加载器。
-
自定义类加载器:用户自己继承
ClassLoader实现的加载器。
工作原理
双亲委派的核心逻辑是:"向上委托,向下加载"。 当一个类加载器收到类加载请求时:
-
它首先会检查这个类是否已经被加载过了。
-
如果没有,它不会自己先尝试加载,而是把这个请求委派给父类加载器去完成。
-
每一层的加载器都是如此,直到请求最终到达顶层的启动类加载器。
-
如果父类加载器反馈无法加载这个类(它的加载路径下没有这个 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. 自定义类加载器
通过重写ClassLoader的loadClass()方法,我们可以不遵循双亲委派的逻辑,自己控制类的加载顺序。这通常用于热加载、加密 class 文件解密等场景。
总结
JVM 的这三大核心机制 ——内存布局、垃圾回收、类加载,构成了 Java 生态的基石。
-
理解内存布局,能帮你快速定位 OOM 问题,知道不同的 OOM 错误分别出现在哪里。
-
理解 GC,能帮你根据业务场景选择合适的收集器,是追求吞吐量还是低延迟,从而做出正确的 JVM 参数调优。
-
理解类加载和双亲委派,能帮你理解 Tomcat、Spring 等中间件的底层设计,理解为什么 SPI 能工作,以及热部署的原理。
在大规模高并发系统中,这些底层知识往往是解决性能瓶颈、线上故障的关键钥匙。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)