JVM介绍
# JVM虚拟机介绍
# 1. JVM跨平台的本质
JVM(Java Virtual Machine)的跨平台能力本质上来源于“不同平台安装了不同的 JVM”。Java 代码并不是直接运行在操作系统上,而是运行在 JVM 上,而 JVM 会根据底层平台进行适配。这样,Java 程序只需要编译成一种统一的字节码格式(.class
文件),然后交由不同操作系统上的 JVM 来执行,就能够实现“一次编写,到处运行”。
# 2. 字节码的作用
字节码的存在主要是为了在解释执行和直接编译执行之间做一个平衡。
# 2.1为什么需要字节码?
如果直接执行 Java 源代码,那就变成了类似 Python 的解释型语言,每次运行都需要进行解析,执行效率会降低。而如果像 C/C++ 那样直接编译成机器码,就失去了跨平台的优势。因此,Java 选择了一种折中的方式:
- 先将 Java 源代码编译成字节码(
.class
文件),这一步由 Java 编译器(javac
)完成。 - 然后 JVM 负责执行字节码,具体方式包括解释执行和JIT(即时编译),其中 JIT 可以将热点代码编译为机器码,提高执行效率。
# 2.2 字节码的优势
- 跨平台:字节码不是特定 CPU 架构的指令,而是面向 JVM 的指令,因此可以在不同操作系统上运行。
- 提高效率:虽然字节码需要被解释执行,但 JIT 编译可以动态优化热点代码,使得 Java 代码的运行效率接近原生编译的程序。
- 安全性:字节码可以在执行前由 JVM 进行验证,避免恶意代码执行,提高 Java 运行的安全性。
- 便于优化:JVM 可以针对字节码进行运行时优化,如垃圾回收(GC)、内联展开、逃逸分析等,提高执行性能。
除了 Java,很多高级语言(如 Kotlin、Scala、C#)也采用类似的字节码机制,以实现跨平台或运行时优化的目的。
# 3. JVM 的整体结构
JVM 运行时需要处理字节码文件,将其加载到内存中,并通过各个子系统和数据区域来执行 Java 程序。JVM 的核心组件包括:
- 类加载子系统(Class Loader Subsystem):
负责将.class
字节码文件从磁盘加载到内存,并解析为 JVM 运行时可用的类信息。 - 运行时数据区(Runtime Data Areas):
- 方法区(Method Area)(也称为 元空间 Metaspace):
存储类的元数据(字段、方法、访问权限)、运行时常量池、静态变量、JIT 编译后的代码等。它是所有线程共享的区域。 - 堆(Heap):
JVM 内存中最大的一块区域,主要存放对象实例,所有线程共享。堆通常划分为 新生代(Young Generation) 和 老年代(Old Generation),并由垃圾回收器管理。 - Java 虚拟机栈(Java Stack):
每个线程私有的栈,存储方法调用的 栈帧(Stack Frame),包括 局部变量表、操作数栈、动态链接、方法返回地址 等。栈的大小可以通过-Xss
选项配置。 - 本地方法栈(Native Method Stack):
主要用于 JVM 调用本地方法(Native Methods),例如通过 JNI 调用 C/C++ 代码。其作用类似于 Java 虚拟机栈,但专门用于本地代码执行。 - 程序计数器(PC Register):
记录当前线程正在执行的字节码指令地址,用于多线程环境下的线程切换。
- 方法区(Method Area)(也称为 元空间 Metaspace):
- 执行引擎(Execution Engine):
- 解释器(Interpreter):
逐行读取字节码并执行,但执行速度较慢。 - JIT 编译器(Just-In-Time Compiler):
在运行时将 热点代码(高频调用的代码)编译为本地机器码,提高执行效率。JIT 编译器常见的优化策略包括 方法内联、逃逸分析、栈上分配 等。 - 垃圾回收器(Garbage Collector, GC):
负责管理堆内存,回收不再使用的对象。JVM 提供多种垃圾回收算法,例如 Serial GC、Parallel GC、G1 GC、ZGC、Shenandoah GC,可通过-XX:+UseG1GC
等参数指定。
- 解释器(Interpreter):
JVM 通过这些组件协同工作,实现高效的 Java 代码执行和自动内存管理。
# 4. 类加载子系统
# 类加载的 3 个大阶段
Java 类的加载过程分为 加载(Loading)、连接(Linking)、初始化(Initialization) 三个主要阶段:
**加载(Loading)**通常情况下,我们所提到的加载是类加载机制的三个阶段的总称,而这里的加载指的是类加载机制中的第一阶段。在这个阶段,虚拟机需要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中共生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据结构的访问入口。
这里需要注意的是,虚拟机规范并没有严格的规定从哪里,以怎样的形式获取字节流。也就是说,只要最后获取到的二进制字节流是符合JVM规范的,就都是合法的。用户可以通过自定义类加载器,重写ClassLoader类中的findClass()方法,来自定义获取字节流的方式,就可以实现个性化的类加载方案。
连接(Linking) 连接过程包括 验证、准备、解析:
- 验证(Verification):确保字节码符合 JVM 规范,防止恶意代码执行。
- 准备(Preparation):为类的
static
变量分配内存,并赋默认值(如int
变量赋0
,对象赋null
)。 - 解析(Resolution) 是 JVM 类加载过程中的阶段之一,负责将 符号引用(Symbolic Reference) 转换为 直接引用(Direct Reference),提高运行时访问效率。
- 符号引用:字节码中的间接表示,如
"java/lang/String"
、方法length()I
,存储在 常量池 中。 - 直接引用:运行时的实际内存地址,如
String.class
在方法区的地址、对象字段的偏移量、方法的执行地址。
- 符号引用:字节码中的间接表示,如
初始化(Initialization)
- 按照类的静态变量和静态代码块的顺序,执行初始化。
# 4.1 类加载器的分类
JVM 采用 双亲委派(Parent Delegation) 机制,默认提供了 3 个类加载器:
类加载器 | 作用 | 默认加载目录 |
---|---|---|
Bootstrap 类加载器 | 加载 Java 核心类库(rt.jar ,java.base ) | $JAVA_HOME/lib |
Extension 类加载器 | 加载扩展类库(JCE、JSSE 等) | $JAVA_HOME/lib/ext |
App(System)类加载器 | 加载用户应用的类(classpath 下的类) | CLASSPATH 目录 |
# 4.2 双亲委派机制
双亲委派机制的核心逻辑:
- 先让 父类加载器 加载类,层层向上委托,一直到最顶层启动类加载器。
- 如果 启动类加载器 还无法加载(找不到类),则由 下层加载器 进行加载。
作用:避免类的重复加载,防止核心API被篡改。这样可以避免自定义类覆盖 Java 核心类(如 java.lang.String
),增强安全性。
在 ClassLoader
的 loadClass()
方法中:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name); // 1. 先检查类是否已加载
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false); // 2. 委派给父类加载器
} else {
c = findBootstrapClassOrNull(name); // 3. 交给 Bootstrap 类加载器
}
} catch (ClassNotFoundException e) {
// 父类加载器未找到,则由当前类加载器加载
}
if (c == null) {
c = findClass(name); // 4. 最后当前类加载器尝试加载
}
}
return c;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
防止安全漏洞:如果允许用户定义
java.lang.String
并替换系统类,可能会被黑客利用,因此String.class.getClassLoader()
为空时表示它由 Bootstrap 加载器加载,而不是用户代码加载。
# 如何打破 JVM 的双亲委派机制?
核心思路:不委托父类加载器,自己先尝试加载。自定义 ClassLoader,覆盖 loadClass()
public class MyClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1️⃣ 先尝试自己加载
if (name.startsWith("com.mycompany")) {
return findClass(name);
}
// 2️⃣ 其余情况,按双亲委派机制加载
return super.loadClass(name, resolve);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 自定义加载逻辑,比如从字节码文件加载类
byte[] classData = loadClassData(name);
return defineClass(name, classData, 0, classData.length);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
🔹 原理:
- 先尝试自己加载(
findClass(name)
),如果找不到,再调用super.loadClass(name)
。 - 这样可以优先加载自定义类,而不是由父类加载器加载。
# 4.3 自定义类加载器
# 为什么 Tomcat 需要自定义类加载器?
Tomcat是一个Web服务器 需要运行多个 Web 应用,而这些应用可能包含相同的类(如 com.example.User
)。如果直接使用 AppClassLoader
,所有 Web 应用的类都会混在一起,导致:
- 类冲突:不同应用的同名类相互影响。
- 隔离问题:一个应用的类可能访问到另一个应用的类。
# Tomcat 的类加载器模型
Tomcat 采用 自定义类加载器 机制,为每个 Web 应用分配独立的类加载器,从而实现隔离。
类加载器 | 作用 |
---|---|
Bootstrap | 加载 JDK 类库 |
System(AppClassLoader) | 加载 Tomcat 自身的类 |
Common | 加载 Tomcat 共享的类 |
WebappClassLoader | 为每个 Web 应用提供独立的类加载器 |
如果 Tomcat 直接使用
AppClassLoader
,那么不同 Web 应用的类会相互影响,导致隔离失败。
# 类加载隔离的最终效果
- 不同 Web 应用的
WebappClassLoader
互不干扰。 - Web 应用可以使用自己版本的
jar
,而不会影响其他应用。 - Tomcat 核心类(如 Servlet API)被共享,不受应用影响。
# 4.4 总结
- JVM 通过类加载器加载
.class
字节码到内存,完成验证、解析、初始化。 - JVM 采用双亲委派机制,防止用户定义核心类,提高安全性。
- Tomcat 通过自定义
WebappClassLoader
解决类隔离问题,避免类冲突。
# 5. 运行时数据区(Runtime Data Areas)
JVM 在运行时将内存划分为不同的区域,以支持 Java 程序的执行。这些区域包括 方法区(Method Area)、堆(Heap)、Java 方法栈(Java Virtual Machine Stack)、本地方法栈(Native Method Stack) 和 程序计数器(Program Counter Register)。其中,方法区和堆是线程共享的,而 Java 方法栈、本地方法栈和程序计数器是线程私有的。
# 5.1 方法区(Method Area)
方法区是 JVM 运行时数据区的一部分,用于存储已加载的类信息、运行时常量池、方法代码、静态变量、JIT 编译后的代码等。
存储内容:
- 类元数据(Class Metadata)
- 类的完整结构(字段、方法、访问修饰符等)
- 类的加载信息(父类、接口等)
- 运行时常量池(Runtime Constant Pool)
- 字面量(字符串、数字等)
- 符号引用(方法引用、字段引用、类引用)
- 方法信息
- 方法字节码
- JIT 编译后的本地代码
- 静态变量(Static Variables)
- 存储
static
修饰的类变量
- 存储
方法区的实现
- JDK 8 之前(HotSpot):方法区在 永久代(PermGen),内存上限由
-XX:MaxPermSize
限制。 - JDK 8 及以后:永久代被移除,方法区改为 元空间(Metaspace),使用 本地内存,可通过
-XX:MaxMetaspaceSize
限制。
常见问题
OutOfMemoryError: Metaspace
:当 Metaspace 空间不足时抛出。
# 5.2 堆(Heap)
堆是 JVM 中最大的一块内存区域,用于存储对象实例和数组。所有对象都必须存放在堆中,并由垃圾回收(GC)管理。
堆的结构:
- 新生代(Young Generation)
- Eden 区(大部分新对象分配在这里)
- Survivor 0(S0)
- Survivor 1(S1)
- 默认比例 8:1:1(可通过
-XX:SurvivorRatio
调整) - 新生代 GC 频繁,使用 Minor GC(复制算法)
- 老年代(Old Generation)
- 存活时间长、经历多次 GC 的对象进入老年代
- 发生 Full GC(标记-清除/整理)
-XX:MaxTenuringThreshold=15
指定对象最多存活多少次 GC 进入老年代
特殊情况:
- 大对象直接进入老年代(如大数组)
- 晋升老年代:如果对象在 Eden 满了后,无法放入 S0/S1,会直接进入老年代
- OOM(OutOfMemoryError):堆空间不足可通过
-Xms
(最小堆大小)和-Xmx
(最大堆大小)调整
# 5.3 Java 方法栈(Java Virtual Machine Stack)
每个线程私有的,存储方法调用信息(栈帧)。方法执行完 栈帧出栈,无需垃圾回收。
栈帧结构:
- 局部变量表(Local Variable Table)
- 存储方法内的局部变量,使用 slot(槽位) 分配空间
- 操作数栈(Operand Stack)
- 存放方法执行过程中计算的操作数
- 动态链接(Dynamic Linking)
- 解析方法调用的符号引用
- 方法返回地址
- 方法执行完后返回的地址
常见错误:
StackOverflowError
:方法调用深度过大,导致栈溢出(如递归无终止条件)OutOfMemoryError
:栈空间不足(可用-Xss
调整栈大小)
# 5.4 本地方法栈(Native Method Stack)
存储 native
关键字修饰的方法 调用的执行状态。
示例
public class NativeDemo {
static {
System.loadLibrary("nativeLib"); // 加载 C/C++ 实现的库
}
public native void nativeMethod(); // 声明本地方法
}
2
3
4
5
6
常见错误
StackOverflowError
:本地方法调用层级过深OutOfMemoryError
:本地方法栈空间耗尽
# 5.5 程序计数器(PC Register)
JVM 中的 程序计数器(PC Register) 是一块极小的内存空间,用于存储当前线程所执行的 字节码指令地址。PC 计数器的概念来源于 CPU 的寄存器(Register),在计算机体系结构中,寄存器用于存储当前 CPU 正在执行的指令地址。JVM 通过模拟这一机制,为每个线程提供了一个独立的 PC 计数器,以便控制 Java 字节码的执行流程。
特点
- 线程私有(Thread-Private):每个线程都有自己独立的 PC 计数器,生命周期与线程一致。
- 存储指令地址:当线程正在执行 Java 方法 时,PC 计数器存储 当前正在执行的字节码指令地址;如果线程执行的是 Native 方法,PC 计数器的值为空(
Undefined
)。 - 字节码解释器依赖:JVM 的字节码解释器会不断读取 PC 计数器的值,以确定下一条要执行的字节码指令。
- 控制程序流:PC 计数器在 循环、分支、跳转、异常处理、线程恢复 等过程中起着至关重要的作用。
- 不会发生 OutOfMemoryError:JVM 规范规定,PC 计数器是唯一一个在 JVM 运行时不会发生 内存溢出(OutOfMemoryError) 的区域。
# 5.6 总结
运行时数据区 | 线程共享/私有 | 作用 |
---|---|---|
方法区 | 共享 | 存放类信息、常量池、方法信息、JIT 编译代码 |
堆 | 共享 | 存放对象实例和数组 |
Java 方法栈 | 私有 | 存放方法调用信息(栈帧) |
本地方法栈 | 私有 | 存储 native 方法执行状态 |
程序计数器 | 私有 | 记录当前字节码指令地址 |
# 6. 垃圾回收(GC)机制
# 6.1 为什么需要垃圾回收?
Java的垃圾回收(Garbage Collection)解决了内存管理的问题,自动识别并清除不再使用的对象,避免内存泄漏和OOM(OutOfMemory)错误。
# 6.2 垃圾回收的阶段
垃圾回收主要分为两个阶段:
- 垃圾标记:识别哪些对象是垃圾
- 垃圾清除:回收垃圾对象占用的内存空间
# 6.3 垃圾标记算法
# 引用计数法
- 原理:每个对象维护一个引用计数器,当引用增加时计数+1,引用断开时计数-1,计数为0则视为垃圾
- 优点:实现简单,判定效率高
- 缺点:无法解决循环引用问题(A引用B,B引用A,但它们都不再被其他对象引用)
# 可达性分析法
- 原理:以GC Roots为起点,沿着引用链递归搜索,被搜索到的对象标记为存活,未被搜索到的对象标记为垃圾
- GC Roots包括:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中的静态属性引用的对象
- 方法区中的常量引用的对象
# 6.4 垃圾回收算法
# 标记-清除算法
- 过程:先标记要回收的对象,然后清除这些对象
- 缺点:产生内存碎片,需要两个阶段操作
# 复制算法
- 原理:将内存分为两块,每次只使用一块。回收时,将存活对象复制到另一块,然后清除当前内存块
- 特点:通常用于新生代,是典型的"空间换时间"策略
- 优点:效率高,不产生碎片
- 缺点:需要额外的内存空间
# 标记-整理算法
- 过程:先标记存活对象,然后将存活对象移动到内存一端,最后清理边界外的空间
- 优点:不产生内存碎片,不需要额外内存空间
- 缺点:效率低,需要修改对象引用地址
# 三种算法对比
算法 | 速度 | 空间开销 | 是否移动对象 |
---|---|---|---|
标记-清除 | 中等 | 少(有碎片) | 否 |
标记-整理 | 最慢 | 少(无碎片) | 是 |
复制 | 最快 | 最多 | 是 |
对象的生命周期各不相同,针对不同生命周期的对象采用不同的收集算法可以提高效率。堆内存分为:
- 新生代:存活时间短的对象,适合使用复制算法
- 老年代:存活时间长的对象,适合使用标记-清除或标记-整理算法
# 7. 常见垃圾回收器
在 JVM 中,垃圾回收器(Garbage Collector, GC)负责自动管理堆内存,回收不再使用的对象。当前主流的 GC 主要分为 两大类:
- 分代收集器(Generational GC):基于 新生代(Young Generation) 和 老年代(Old Generation) 进行回收,不同对象生命周期不同,使用不同的回收策略。
- 代表:Serial GC、ParNew GC、Parallel GC、CMS GC。
- 分区收集器(Region-Based GC):将堆划分为多个动态 Region,不再严格区分新生代和老年代,按需回收,优化 GC 停顿时间。
- 代表:G1 GC、ZGC。
# 7.1 分代收集器(Generational GC)
# Serial GC(串行垃圾回收器)
工作原理
- 适用于 单核 CPU,使用 单线程 进行垃圾回收。
- 新生代 采用 复制算法(Copying GC),将存活对象从 Eden + 一个 Survivor 复制到另一个 Survivor。
- 老年代 采用 标记-整理算法(Mark-Compact),先标记存活对象,再整理碎片化内存。
回收过程
- 新生代 GC(Minor GC):
- 标记存活对象。
- 复制到 Survivor,未存活的对象直接回收。
- Survivor 空间满时,存活对象晋升到老年代。
- 老年代 GC(Full GC):
- 标记存活对象并整理内存,回收无用对象。
📌 适用场景
- 适用于 单核 CPU、小型 Java 应用(如 GUI 桌面应用)。
- Server 端不推荐,因为 STW(Stop-The-World)时间长,影响性能。
❌ 缺点
- 单线程执行 GC,效率低。
- GC 停顿时间长,不适用于多线程高吞吐量应用。
# ParNew GC(多线程版 Serial GC,适用于 CMS)
工作原理
- ParNew GC 是 Serial GC 的并行版本,多线程执行 GC,提高回收效率。
- 新生代 使用 并行复制算法(Parallel Copying GC),老年代搭配 CMS GC。
** 回收过程**
- 新生代 GC:和 Serial GC 类似,但采用 多线程 并行执行 GC。
- 老年代 GC:只能搭配 CMS GC,不能用于 Parallel Old GC。
📌 适用场景
- 适用于 多核 CPU,配合 CMS GC,减少 GC 停顿时间。
- CMS GC 唯一支持的多线程新生代 GC,必须使用 ParNew。
❌ 缺点
- 比 Parallel GC 效率低,因为要与 CMS GC 配合,无法独立使用。
# Parallel GC(吞吐量优先 GC,适用于大数据应用)
工作原理
- 又称 吞吐量 GC(Throughput GC),采用 多线程 并行回收垃圾,适用于 高吞吐量应用。
- 新生代 采用 并行复制算法(Parallel Copying GC)。
- 老年代 采用 Parallel Old GC(标记-整理算法)。
回收过程
- 新生代 GC(Parallel Scavenge):并行复制对象,提高吞吐量。
- 老年代 GC(Parallel Old GC):并行标记-整理,减少碎片化。
📌 适用场景
- 适用于 高吞吐量的批处理任务、大数据计算(如 Spark、Hadoop)。
❌ 缺点
- 无法精准控制 GC 停顿时间,适用于吞吐量优先,而非低延迟应用。
# CMS GC(低延迟 GC)
工作原理
- 适用于 低延迟应用,目标是减少 Full GC 停顿时间。
- 新生代 采用 ParNew GC。
- 老年代 采用 并发标记-清除算法(Concurrent Mark-Sweep),并发回收,减少 STW 时间。
🔄 回收过程
- 初始标记(短暂停顿,标记直接可达对象)。
- 并发标记(不影响应用,标记存活对象,容易出现漏标多标)。
- 重新标记(短暂停顿,标记遗漏的存活对象)。
- 并发清除(不影响应用,回收垃圾对象)。
📌 适用场景
- 适用于 低延迟、高响应速度的 Web 服务器。
❌ 缺点
- 产生 内存碎片,容易触发 Full GC(标记-整理)。
# 7.2 分区收集器(Region-Based GC)
# G1 GC(面向大堆的低延迟 GC)
工作原理
- 堆内存划分为多个 Region,回收时优先选择垃圾最多的区域(Garbage First),我们可以通过设置最大SWT时间,垃圾回收器内部。
- 并发标记、增量回收,控制 GC 停顿时间。
📌 适用场景
- 适用于 大内存应用(>4GB)、低延迟需求(如在线交易系统)。
# ZGC(超低延迟 GC,适用于超大堆)
工作原理
- 停顿时间 ≤10ms,不受堆大小影响(适用于超大堆,如 100GB+)。
- 采用 着色指针(Colored Pointers)+ 读屏障(Load Barriers) 进行并发回收。
📌 适用场景
- 超大堆(100GB+)应用,如金融交易、云计算、高并发系统。