JVM梳理(逻辑清晰)
JVM总览
JVM
是什么,作用是什么
JVM
(Java Virtual Machine
,Java
虚拟机)是一个可以运行 Java
字节码的虚拟计算机
核心作用:实现跨平台(“一次编写,到处运行”),JVM
是平台相关的(Linux、Windows、macOS 都有对应实现),所以只要有 JVM
,Java
程序就能运行在任何平台上。
JVM
与 JDK
、JRE
的关系
- JVM(Java Virtual Machine):
Java 虚拟机,负责运行.class
字节码文件,是 Java 跨平台的关键。 - JRE(Java Runtime Environment):
Java 运行环境,包含 JVM + Java 标准类库,是运行 Java 程序所必需的环境,但不包含编译器。 - JDK(Java Development Kit):
Java 开发工具包,包含 JRE + 开发工具(如javac
编译器),用于开发和运行 Java 程序。
Java
程序的编译与执行过程
JVM
的架构
在Java
虚拟机的架构中有三个主要的子系统
- 类加载子系统
- 运行时数据区域
- 执行引擎
下面将依次介绍
类加载子系统
类加载的过程主要分为以下五点:加载、验证、准备、解析、初始化
加载(Loading)
通过类加载器读取 .class
文件字节流,并生成 Class
对象,每个加载器+每个类对应一个唯一的Class
对象
Class
对象是什么?
Class
对象是 类的元数据 的载体,记录了一系列的元数据,大致了解一下:
分类 | 里面装了什么? | 能拿来干嘛?(最常见) |
---|---|---|
身份 |
| 判断是不是 public ,做安全/模块检查 |
结构 |
| 反射调用方法、拿字段注解做依赖注入 |
关系 |
| 判断继承、生成动态代理、泛型擦除后还原真实类型 |
常量池 |
| 运行时解析方法调用、构造枚举/注解默认值 |
运行时状态 |
| 检查类是否已初始化、拿到或修改静态字段值 |
为什么需要Class
对象
让 运行中的 Java 程序也能“看见、检查、操作”自己。
类加载的时机
- 创建类的实例:
new MyClass()
- 访问类的静态成员变量(非常量),如果有
static final int CONST = 3
,访问CONST
不会触发 - 调用类的静态方法
- 使用反射调用
Class.forName("全限定名")
动态加载类 - 子类被加载时,父类也被加载
- JVM 启动时,含
main()
方法的类
类的加载是一个递归的过程,当一个类被加载后,它依赖的其他类也会被加载。
类加载器
类加载器类型 | 中文名称 | 职责/加载内容 |
---|---|---|
BootstrapClassLoader | 引导类加载器 | 加载 JDK 核心类,如 java.lang.* |
ExtClassLoader | 扩展类加载器 | 加载 JDK 扩展类,如 jre/lib/ext 下的类 |
AppClassLoader | 应用类加载器 | 加载 classpath 下的用户类 |
User-Defined ClassLoader | 自定义类加载器 | 用户自定义加载规则,如插件、热部署等 |
为什么需要自定义类加载器?
举一个常用的场景:热部署:服务器正在跑,代码改了,想“无缝”更新到最新逻辑,而 不停止 JVM。
在正常情况下,AppClassLoader
已缓存旧类,无法再加载同名新字节码。下面简单代码演示:
// 自定义类加载器public class ClassReloader extends ClassLoader {private final Path classesDir;public ClassReloader(Path classesDir) {super(ClassReloader.class.getClassLoader()); // 父加载器 = Appthis.classesDir = classesDir;}/** 对业务包 com.example.* 不委派,其他包照旧双亲委派 */@Overrideprotected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {if (name.startsWith("com.example.")) {// 1) 先看自己是否已加载Class<?> c = findLoadedClass(name);if (c == null) c = findClass(name); // 2) 读取最新字节码if (resolve) resolveClass(c);return c;}// 公共库 → 仍走父加载器return super.loadClass(name, resolve);}/** 读取字节码 → defineClass */@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {try {Path file = classesDir.resolve(name.replace('.', '/') + ".class");byte[] bytes = Files.readAllBytes(file);return defineClass(name, bytes, 0, bytes.length);} catch (Exception e) {throw new ClassNotFoundException(name, e);}}
}//主程序
public class ReloaderDemo {public static void main(String[] args) throws Exception {Path classesDir = Path.of("classes"); // 监听此目录while (true) {// ① 创建新的加载器实例ClassLoader loader = new ClassReloader(classesDir);// ② 反射调用最新业务类Class<?> helloClz = loader.loadClass("com.example.Hello");Method say = helloClz.getMethod("say");Object hello = helloClz.getDeclaredConstructor().newInstance();say.invoke(hello); // 打印版本号// ③ 放掉引用,等待 GC 卸载旧加载器 + 旧类hello = null; helloClz = null; say = null; loader = null;System.gc();Thread.sleep(3000); // 3 秒一次}}
}
总结:自定义类加载器,指定哪些包下面的类不走双亲委派机制,其余类仍然走双亲委派机制;监听指定包下的类,一旦发生变化就新建类加载器实例重新加载该目录下的类(new ClassReloader(classesDir)
)。
双亲委派机制
核心思想:一个类加载请求会先交给父加载器处理,只有父加载器找不到该类时,子加载器才会尝试自己加载。
流程:
子加载器收到加载请求↓
委托给父加载器加载↓
父加载器继续向上委托,直到启动类加载器↓
如果父加载器能加载,则返回成功↓
父加载器找不到才由当前加载器自己去加载
作用(为什么需要双亲委派机制):
- 避免类重复加载,保证类唯一性:避免不同的类加载器加载同一个核心类(如
java.lang.String
)产生冲突。 - 保护核心
Java
类不被篡改,保证安全性:防止用户自定义类覆盖核心类,保障 JVM 基础功能的安全。 - 提高类加载效率:父加载器已经加载的类,子加载器不用重复加载,减少资源消耗
总结:双亲委派机制保证了核心 Java
类的唯一性和安全性,同时避免重复加载,提高加载效率,使得类加载更加规范和稳定。
连接(Linking
)
- 验证:校验
.class
文件是否合法 - 准备:为静态变量分配内存,并设置默认值(0/false/null)
- 解析:将常量池中的符号引用转成直接引用
- 符号引用是
.class
文件常量池中的一种表示方式,用字符串或符号来描述方法名、字段名、类名等,例如"java/lang/String"
、"length"
等。 - 直接引用是JVM运行时可以直接使用的指针、内存地址或者偏移量。
- 符号引用是
初始化(Initialization
)
执行 <clinit>
方法完成初始化
<clinit>
是编译器把所有 静态赋值 与 static {} 代码块 拼接成的 隐藏方法,JVM 在“初始化阶段”自动调用它来完成类的一次性初始化。
一个类(或接口)对应 一个 <clinit>
;初始化的顺序和源码完全一致;如果类里什么静态语句都没有,就根本不会生成 <clinit>
方法。
运行时数据区域
组成部分
图片来自:https://blog.csdn.net/qq_63218110/article/details/130601425
线程私有区:
(1)虚拟机栈:线程每次调用方法都会在虚拟机栈中产生一个栈帧,方法执行完毕后释放栈帧
区域 | 作用 |
---|---|
局部变量表 | 保存方法体中声明的局部变量、返回值、方法参数 |
操作数栈 | 字节码指令的“工作栈”,执行 push/pop 做计算 |
动态链接 | 指向运行时常量池的符号引用,用于解析字段/方法 |
方法返回地址 | 调用完成后,告诉 JVM 跳回到哪条字节码 |
额外信息 | 对象头、锁记录(同步方法)、异常处理表 |
(2)本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机执行 Native 方法服务。 Native
方法是用 C/C++ 等非 Java 语言实现的方法,比如 JNI(Java Native Interface)
调用的底层代码。
(3)程序计数器:保存当前线程所正在执行的字节码指令的地址(行号),方便线程切回后能继续执行代码
线程共享区:
(4)堆区:Jvm进行垃圾回收的主要区域,存放对象实例,分为新生代和老年代
参数 | 默认值 | 说明 |
---|---|---|
新生代大小(NewSize ) | 约占堆的1/3 | 包含 Eden 区和两个 Survivor 区 |
老年代大小 | 约占堆的2/3 | 存放长期存活的对象 |
Survivor 区比例(SurvivorRatio ) | 8(默认) | Eden 和两个 Survivor 区比例是 8:1:1 |
堆内存分配流程
- 对象创建优先在新生代
Eden
区分配 Eden
区满触发Minor GC
,存活对象移至Survivor
区- 多次
Minor GC
存活后晋升到老年代 - 老年代满触发
Full GC
,清理更复杂,回收更彻底
(5)方法区:存储已被虚拟机加载的 类信息、字段信息、方法信息、类的运行时常量池、静态变量、即时编译器编译后的代码缓存等数据。
内容类别 | 说明 |
---|---|
类的运行时常量池 | 存放类编译期间生成的各种字面量和符号引用 |
字段和方法数据 | 类的字段、方法信息,包括方法字节码和修饰符 |
静态变量 | 类的静态成员变量 |
即时编译后的代码 | JIT 编译后的本地机器码 |
JDK1.8之前
用永久代实现,JDK1.8及以后
用元空间实现,元空间使用的是本地内存,而非在JVM内存结构中
垃圾回收机制
GC如何判断对象可以被回收?
引用计数法:为每个对象添加引用计数器,引用为0时判定可以回收,会有两个对象相互引用无法回收的问题
可达性分析法:通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,凡被访问到的对象即标记为“可达”,遍历结束后,未被标记的对象即“垃圾”
哪些对象可以作为 GC Roots 呢?
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类的静态变量引用的对象
- 方法区中常量池引用的对象
- 被
synchronized
关键字锁住的对象 - 正在执行的类加载器实例
为什么这些对象可以被作为GC Roots?
- 它们都是当前程序运行时直接使用中的对象
- 它们是 Java 程序运行的基础,比如类加载器
- 无法再“找引用”定位它们,所以必须作为起点从它们出发找其他引用
四大引用
-
强引用:
new
出来的对象。哪怕内存溢出也不会回收 -
软引用:通过
softreference
类实现,只有内存不足时才会回收SoftReference<Object> softRef = new SoftReference<>(new Object());
-
弱引用:通过
weakreference
类实现,每次垃圾回收都会回收WeakReference<Object> weakRef = new WeakReference<>(new Object());
-
虚引用:不能单独使用,必须配合引用队列使用,一般用于用于监控对象被回收的时机;
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);
垃圾收集算法
标记-清除算法(老年代)
标记-清除(Mark-and-Sweep
)算法分为“标记(Mark
)”和“清除(Sweep
)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象
缺点:标记清除后会产生大量不连续的内存碎片
复制算法(新生代)
为了解决标记-清除算法的内存碎片问题,它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉
缺点:可以使用的内存减少了,以及不适合老年代:存活对象数量比较大,复制性能会变得很差
新生代使用复制算法的流程:
步骤 | 描述 |
---|---|
对象分配 | 新对象优先分配在 Eden 区。Eden 空间不足时触发 Minor GC。 |
触发 GC(STW) | JVM 暂停所有用户线程(Stop-The-World),开始进行 Minor GC,扫描 Eden 区和当前使用的 Survivor 区(如 S0)。 |
复制存活对象 | 将 Eden 区和 S0 区的存活对象复制到 S1 区。若 S1 不够放,部分对象则直接晋升到老年代。 |
清空 Eden 和 S0 | 复制完后,Eden 和 S0 清空,下次 GC 时 S0 与 S1 角色互换,即下次从 Eden + S1 复制到 S0)。 |
对象年龄增长 | 每次存活被复制后,对象年龄 +1。达到阈值(默认 15),对象会晋升到老年代。 |
标记-整理算法(老年代)
标记-整理(Mark-and-Compact
)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,之后将所有存活对象向内存的一端移动,移动完成后,直接清理掉边界以外的内存
垃圾收集器
Serial
收集器:适用于新生代,使用复制算法,单线程执行,需要STW(Stop-The-World)
,适合单核 CPU 或小型应用
STW(Stop-The-World)是指垃圾回收时暂停所有应用线程
Serial Old
收集器:Serial
的老年代版本,与 Serial
配合使用,使用标记-整理算法
ParNew
收集器:适用于新生代,是Serial
收集器的多线程版本,使用复制算法,常与 CMS
搭配
Parallel Scavenge
收集器:适用于新生代,多线程,使用复制算法,以吞吐量为目标
吞吐量 = 应用运行时间 ÷(应用运行时间 + 垃圾回收时间)
吞吐量越高,应用运行时间占比越大
Parallel Old
收集器:Parallel Scavenge
的老年代版本,使用“标记-整理”算法。
重点讲解下面两种收集器:
CMS
收集器(Concurrent Mark Sweep
)
-
老年代收集器,并发收集,使用标记-清除算法
-
以获取最短回收停顿时间为目标
低停顿时间 = 更频繁、更小批次的 GC → 更加打断式,应用响应更快,但GC 总时间可能变多 → 吞吐量下降。 高吞吐量 = 更少、更大批次的 GC → 更加集中式,GC 总时间少,但每次 GC 停顿时间长,响应差。
-
可以让垃圾收集线程与用户线程(基本上)同时工作
CMS 垃圾收集器的工作可以分为以下四个主要阶段:
- 初始标记(
Initial Mark
):标记所有直接可达的对象(即从GC Roots
可直接引用的对象)。此阶段需要STW(Stop-The-World)
,暂停所有应用线程。
- 并发标记(
Concurrent Mark
):从初始标记的对象开始,进行全堆扫描,标记所有可达对象(存活对象)。不会暂停应用线程,与应用程序线程并发执行
- 重新标记(
Remark
):修正并发标记阶段中,由于应用线程继续运行而导致的对象引用变化(新增或移除引用)。此阶段需要 STW。使用增量更新(Incremental Update
)
- 并发清除(
Concurrent Sweep
):回收不可达对象占用的内存空间;不会暂停应用线程,与应用线程并发执行。
CMS 的缺点
- 内存碎片问题::
CMS
使用“标记-清除”算法,回收后不会整理内存,因此可能导致大量的内存碎片。当内存碎片过多时,可能触发 Full GC。 - 需要更多的 CPU 资源:并发标记和并发清除阶段会占用部分 CPU 资源,可能影响应用性能。
- **浮动垃圾:**并发清除阶段,应用线程继续运行,可能会产生一些新垃圾对象(浮动垃圾),需要等到下次 GC 才能被清理。
- 失败风险:如果老年代内存不足,
CMS
的回收速度又跟不上新垃圾产生的速度,JVM
会放弃CMS
,而触发一次“Full GC
”,由Serial Old
收集器执行。Full GC(完全垃圾收集),使用一个单线程、停顿时间长的 Serial Old 收集器,来强制清理老年代。
G1 收集器
- 采用标记-整理 + 复制算法来分代回收垃圾,在
JDK9
中成为了默认的垃圾收集器 - 可以预测停顿时间,允许用户设置
-XX:MaxGCPauseMillis
来控制最大停顿 - G1 通过收集“回收性价比最高”的
Region
,提升效率
堆内存结构:堆内存会被切分成为很多个大小相等的区域(Region
),每个 Region
被标记了 E、S、O 和 H
Humongous 区域主要是为存储超过 50% 标准 region 大小的对象设计,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC 。
停顿预测模型
G1会根据用户设定的最大停顿时间优先选择回收性价比高的Region
进行回收,G1 会为每个 Region
维护一个 “回收性价比”:回收它可以释放多少空间,需要耗费多长时间。G1 通过历史数据预测回收时间。
回收过程:
- 初始标记:标记一下GC Roots能直接关联到的对象。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。
- 最终标记:暂停应用,补充并发阶段遗漏的对象引用,使用原始快照(
Snapshot-At-The-Beginning, SATB
) 算法 - 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划
增量更新(Incremental Update) 和 原始快照(Snapshot-At-The-Beginning, SATB)
增量更新:记录新增的引用,不记录删除的引用;并发标记阶段使用Card Marking(卡表)技术标记哪些内存区域被修改过
原始快照:记录删除的引用,不记录新增的引用,新增引用交给后续的GC正常处理。并发标记阶段如果发现某个引用被删除(A.field = null
),则将该引用加入 SATB 队列。
内存溢出和内存泄漏
内存泄漏:有对象不再使用但仍被引用,JVM无法回收,内存泄漏最终可能导致内存溢出
内存溢出:JVM所有内存都被用完,无法继续分配新内存
内存溢出:
- 堆内存溢出
java.lang.OutOfMemoryError: Java heap space
:大量对象创建且未被回收 - 栈溢出
java.lang.StackOverflowError
:方法调用次数过多,一般是递归不当造成 Metaspace
溢出java.lang.OutOfMemoryError: Metaspace
:类的数量或元数据过多
内存泄漏:
- 静态集合引起的内存泄漏:静态集合(如
static HashMap
、static List
)的生命周期与 JVM 一致,若向其中添加对象后未及时移除,即使对象已不再使用,也会因被集合引用而无法回收。 - 资源未关闭:数据库连接(
Connection
)、文件流(FileInputStream
)、网络连接等未显式关闭,导致底层资源未释放 ThreadLocal
使用不当:使用完之后没有调用remove()
方法清理- 字符串拼接导致的内存泄漏:有大量字符串拼接时,可能会产生大量中间对象
- 缓存没有设置上限或者没有设置缓存淘汰策略
- 非静态内部类持有外部类的引用:每个非静态的内部类都隐式的持有外部类实例的引用,若内部类被长生命周期对象(如线程池)引用,会导致外部类实例无法回收。解决方法是改为静态内部类,静态内部类不会持有外部类实例的引用。
执行引擎
架构
解释器
逐行解释执行字节码(.class
文件)指令,边解释边执行,特点是启动快,执行慢
在JVM中冷代码(只执行一次或执行很少)有解释器直接执行,热代码交给即时编译器编译成本地机器码执行
即时编译器
将热点代码编译成本地机器码,直接执行机器码;特点是启动慢(编译需要的时间长),执行快
怎么判定热点代码?
- 方法调用计数器:某方法或代码块调用次数达到阈值
- 回边计数器:如果某条跳转指令从后面跳回前面(比如进入一个循环体),这条跳转就是回边。回边计数达到阈值触发JIT编译。
分析器是一个性能监控组件,负责在程序运行期间 收集和分析运行数据:方法调用次数、回边次数等
两者并存是为了平衡启动速度和执行效率