Java JVM核心知识点复习笔记
前言
本文为自用复习笔记,核心用于梳理Java中JVM的核心知识点,方便后续回顾、巩固重点,避免遗忘关键细节,适配Java面试备考需求(偏向架构方向)。
本次笔记将围绕运行时数据区(堆/栈/元空间)、对象创建与内存分配、OOM场景与原因、类加载过程与双亲委派、如何破坏双亲委派这5个核心知识点展开,重点标注JDK8与JDK21的差异点,兼顾理论、源码与面试易错点。
一、运行时数据区(堆/栈/元空间)
JVM运行时数据区是Java程序运行的核心内存区域,负责存储程序执行过程中的各种数据,JDK8与JDK21在该区域的核心差异集中在元空间优化和堆内存布局调整上,整体结构分为:堆、虚拟机栈、本地方法栈、程序计数器、元空间(JDK8及以后,替代JDK7的永久代)。
1.1 堆(Heap)------ 核心存储区域
堆是JVM中最大的内存区域,被所有线程共享,核心用于存储对象实例和数组,是垃圾回收(GC)的主要区域,其大小可通过JVM参数(-Xms初始堆大小、-Xmx最大堆大小)配置。
1.1.1 堆的分区(通用逻辑)
无论JDK8还是JDK21,堆默认采用"分代回收"思想(部分垃圾收集器可取消分代),分为年轻代和老年代,年轻代又分为Eden区和两个Survivor区(From Survivor、To Survivor),默认比例为Eden:From:To = 8:1:1。
核心逻辑:新创建的对象优先在Eden区分配,经过一次Minor GC后存活的对象进入Survivor区,多次Minor GC后仍存活的对象进入老年代;老年代存储生命周期长的对象,触发Major GC(Full GC)时进行回收。
1.1.2 JDK8与JDK21的堆差异
① 垃圾收集器适配:JDK8默认使用Parallel Scavenge(年轻代)+ Parallel Old(老年代)收集器,侧重吞吐量;JDK21默认使用ZGC收集器(可通过参数切换),ZGC取消了传统分代模型,采用区域化内存管理,支持大堆内存(TB级),垃圾回收停顿时间控制在毫秒级,大幅提升高并发场景下的性能。
② 堆内存优化:JDK21引入了"动态堆调整"机制,可根据程序运行负载自动调整堆大小,减少手动配置参数的复杂度;同时优化了Survivor区的对象晋升逻辑,减少不必要的对象进入老年代,降低Full GC频率。
③ 面试易错点:JDK8及以后,堆中不再包含永久代,永久代的功能被元空间替代;JDK21的ZGC收集器不依赖分代,因此堆分区可灵活调整,这是与JDK8堆结构的核心区别。
1.2 虚拟机栈(VM Stack)------ 线程私有区域
虚拟机栈是线程私有,与线程生命周期一致,每个线程对应一个虚拟机栈,栈中包含多个栈帧(Stack Frame),每个栈帧对应一个方法的调用过程,存储方法的局部变量表、操作数栈、动态链接、方法返回地址等信息。
核心特点:栈是"后进先出"(LIFO)的结构,方法调用时创建栈帧并压栈,方法执行完毕后栈帧出栈;栈的大小可通过-Xss参数配置,默认大小因JDK版本和系统而异(JDK8默认1M,JDK21默认1M,可通过参数调整)。
1.2.1 JDK8与JDK21的栈差异
① 虚拟线程支持:JDK21引入虚拟线程(Virtual Thread),虚拟线程的虚拟机栈与传统平台线程(Platform Thread)的栈结构不同,虚拟线程的栈采用"分段栈"机制,可动态扩容和收缩,避免栈溢出的同时,大幅提升线程并发量(可创建数十万甚至上百万个虚拟线程);JDK8仅支持平台线程,栈大小固定,并发量受限。
② 栈溢出处理:JDK8中,当方法递归调用过深,栈帧数量超过栈大小限制时,会抛出StackOverflowError;JDK21中,虚拟线程的分段栈可动态扩容,一定程度上减少StackOverflowError的出现,但递归深度过大时仍会抛出该异常。
1.3 本地方法栈(Native Method Stack)
本地方法栈与虚拟机栈功能类似,区别在于:虚拟机栈用于执行Java方法,本地方法栈用于执行本地(Native)方法(如调用C/C++编写的方法),线程私有。
JDK8与JDK21的差异:无核心差异,均遵循JVM规范,本地方法栈的大小可通过参数配置,溢出时同样会抛出StackOverflowError或OutOfMemoryError。
1.4 程序计数器(Program Counter Register)
程序计数器是线程私有,体积最小的内存区域,核心作用是存储当前线程正在执行的Java字节码指令的地址(若执行的是Native方法,计数器值为undefined),是JVM中唯一不会抛出OutOfMemoryError的区域。
JDK8与JDK21的差异:无差异,功能和实现逻辑完全一致,仅作为线程执行指令的"指示器"。
1.5 元空间(Metaspace)------ 替代永久代
元空间是JDK8引入的内存区域,用于存储类信息、常量、静态变量、方法字节码等数据,替代了JDK7及以前的永久代(PermGen),核心优势是元空间使用本地内存,而非JVM堆内存,避免了永久代内存溢出的问题。
1.5.1 核心特点(通用)
① 元空间的大小默认不受限制(可通过-XX:MetaspaceSize、-XX:MaxMetaspaceSize参数限制);② 元空间中存储的类信息会在类卸载时被回收,减少内存浪费;③ 类加载器加载类时,会将类的相关信息存入元空间。
1.5.2 JDK8与JDK21的元空间差异
① 内存管理优化:JDK21对元空间的内存分配和回收机制进行了优化,支持更高效的类卸载,减少元空间内存泄漏的风险;同时优化了元空间的扩容逻辑,避免频繁扩容带来的性能开销。
② 模块系统适配:JDK21基于JPMS(Java Platform Module System)模块系统,元空间中存储的类信息会与模块关联,类加载时会校验模块依赖,避免类冲突;JDK8无模块系统,元空间仅单纯存储类信息。
③ 面试提醒:JDK8与JDK21均无永久代,元空间是核心存储类信息的区域;JDK7及以前的永久代已被淘汰,面试中常考查"永久代与元空间的区别",需重点记忆。
二、对象创建、内存分配、指针碰撞
Java对象的创建流程、内存分配方式是JVM面试的高频考点,JDK8与JDK21在内存分配的底层实现上有细微差异,但核心流程一致,以下结合源码和版本差异详细拆解。
2.1 对象创建的核心流程(JDK8与JDK21通用)
对象创建的完整流程可分为5个步骤,核心依赖类加载机制(后续章节详解),流程如下:
-
类加载检查:当执行new关键字创建对象时,JVM首先检查该对象对应的类是否已加载(加载、验证、准备、解析、初始化完成),若未加载,则先执行类加载流程;
-
内存分配:类加载完成后,JVM为对象分配内存(分配的内存大小在类加载的"准备"阶段已确定);
-
初始化零值:JVM将分配的内存区域初始化为零值(如int为0、boolean为false、引用类型为null),确保对象的成员变量无需显式初始化也能有默认值;
-
设置对象头:为对象设置对象头(Object Header),存储对象的哈希值、GC分代年龄、锁状态标志、类元信息指针等;
-
执行init方法:调用对象的构造方法(init),初始化对象的成员变量和业务逻辑,完成对象创建。
源码片段(简化版,JDK8与JDK21核心逻辑一致):
java
// 简化的对象创建底层逻辑(HotSpot虚拟机)
public class ObjectCreationDemo {
public static void main(String[] args) {
// 执行new关键字,触发类加载检查和内存分配
User user = new User("Java", 25);
}
}
class User {
private String name;
private int age;
// init方法(构造方法)
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
2.2 内存分配方式(核心考点)
JVM为对象分配内存的核心方式有两种:指针碰撞(Bump the Pointer)和空闲列表(Free List),分配方式的选择取决于堆内存的是否规整,而堆内存的规整性由垃圾收集器的回收算法决定。
2.2.1 指针碰撞(重点)
核心逻辑:当堆内存是规整的(即已使用内存和空闲内存分别在堆的两侧,无碎片),JVM会维护一个"空闲指针",指向空闲内存的起始地址;为对象分配内存时,只需将空闲指针向空闲内存一侧移动一段与对象大小相等的距离,这种方式高效、简单,时间复杂度接近O(1)。
适用场景:使用"标记-整理"或"复制"回收算法的垃圾收集器,如JDK8的Serial收集器、ParNew收集器,JDK21的ZGC收集器(ZGC采用复制算法,堆内存规整)。
2.2.2 空闲列表
核心逻辑:当堆内存不规整(存在大量内存碎片),JVM会维护一个"空闲列表",记录堆中所有空闲内存块的大小和地址;为对象分配内存时,从空闲列表中找到一块足够大的内存块,分配给对象,并更新空闲列表的信息,这种方式效率略低(需遍历空闲列表)。
适用场景:使用"标记-清除"回收算法的垃圾收集器,JDK8和JDK21中均较少使用,仅在特殊场景下(如自定义垃圾收集器)出现。
2.2.3 JDK8与JDK21的内存分配差异
① 默认分配方式:JDK8默认使用指针碰撞(因默认收集器是Parallel Scavenge+Parallel Old,Parallel Scavenge采用复制算法,堆内存规整);JDK21默认使用指针碰撞(ZGC收集器采用复制算法,堆内存规整),但ZGC的指针碰撞实现更高效,支持大对象的快速分配。
② 大对象分配优化:JDK8中,大对象(超过Survivor区大小的对象)会直接分配到老年代;JDK21中,ZGC收集器支持大对象的"即时分配",无需直接进入老年代,减少老年代的内存压力,降低Full GC频率。
③ 并发安全处理:JDK8和JDK21均采用"CAS+失败重试"机制或"本地线程分配缓冲(TLAB)"解决内存分配的并发安全问题(多个线程同时分配内存时,避免冲突);JDK21对TLAB进行了优化,扩大了TLAB的默认大小,减少CAS重试的次数,提升并发分配效率。
面试提醒:指针碰撞是面试高频考点,需掌握其核心逻辑、适用场景,以及与空闲列表的区别;同时记住JDK8和JDK21默认均使用指针碰撞,核心差异在大对象分配和并发分配效率上。
三、OOM 场景与原因
OOM(OutOfMemoryError)是JVM运行时的严重异常,指JVM无法分配足够的内存空间,核心原因是"内存分配超出了JVM的限制"或"内存泄漏导致可用内存耗尽",JDK8与JDK21的OOM场景基本一致,但部分场景的触发条件有差异,以下按内存区域分类详解。
3.1 堆OOM(java.lang.OutOfMemoryError: Java heap space)------ 最常见场景
3.1.1 触发原因
① 对象创建过多,且对象无法被GC回收(内存泄漏),如长期持有对象引用(静态集合存储大量对象、单例模式持有对象);
② 堆内存配置过小(-Xms、-Xmx参数设置不合理),无法满足程序运行需求;
③ 大对象过多,且无法被及时回收,如一次性加载大量数据(Excel导入、大数据查询)。
3.1.2 JDK8与JDK21的差异
JDK8中,堆OOM多发生在老年代(因分代回收,老年代对象生命周期长,易堆积);JDK21中,ZGC收集器支持大堆内存和快速回收,堆OOM的触发概率降低,但当内存泄漏或堆配置过小时,仍会触发,且异常信息与JDK8一致。
示例(触发堆OOM):
java
public class HeapOOMDemo {
public static void main(String[] args) {
// 无限创建对象,且无法被回收(内存泄漏)
List<Object> list = new ArrayList<>();
while (true) {
list.add(new Object());
}
}
}
3.2 栈OOM(java.lang.OutOfMemoryError: StackOverflowError / OutOfMemoryError)
3.2.1 触发原因
① StackOverflowError:方法递归调用过深,栈帧数量超过虚拟机栈的大小限制(JDK8和JDK21均常见);
② OutOfMemoryError:创建过多线程,每个线程占用一定的栈内存,导致总栈内存超出系统可用内存(JDK8中常见,JDK21中因虚拟线程的引入,该场景大幅减少)。
3.2.2 JDK8与JDK21的差异
JDK8中,创建大量平台线程(如数千个)会触发栈OOM;JDK21中,虚拟线程的栈可动态扩容且占用内存少,创建数十万甚至上百万个虚拟线程也不会触发栈OOM,仅当递归深度过大时,会抛出StackOverflowError。
3.3 元空间OOM(java.lang.OutOfMemoryError: Metaspace)
3.3.1 触发原因
① 加载过多的类,且类无法被卸载(如动态生成大量类、第三方框架反射生成类、类加载器泄漏);
② 元空间内存限制设置过小(-XX:MaxMetaspaceSize参数设置过低);
③ JDK8中,永久代残留的类信息未被清理(虽已替代,但部分旧框架仍可能存在兼容问题)。
3.3.2 JDK8与JDK21的差异
JDK8中,元空间OOM多因加载大量类且类卸载不及时,或MaxMetaspaceSize设置过小;JDK21中,元空间的类卸载机制更高效,且支持动态扩容,OOM的触发概率低于JDK8,但当动态生成类过多(如Spring Boot的AOP动态代理)时,仍会触发。
3.4 直接内存OOM(java.lang.OutOfMemoryError: Direct buffer memory)
3.4.1 触发原因
直接内存(Direct Memory)不属于JVM运行时数据区,是操作系统的内存区域,JDK通过ByteBuffer.allocateDirect()方法分配直接内存,触发OOM的原因:
① 分配的直接内存过多,超出系统可用内存;
② 直接内存未被及时释放(JDK8中,直接内存的回收依赖GC,若GC不触发,会导致内存泄漏;JDK21中,优化了直接内存的回收机制,减少泄漏风险)。
3.4.2 JDK8与JDK21的差异
JDK8中,直接内存的回收依赖Full GC,若Full GC不触发,直接内存会持续堆积,最终触发OOM;JDK21中,引入了直接内存的"主动回收"机制,无需等待Full GC,可及时释放未使用的直接内存,降低OOM概率。
面试提醒:OOM场景是面试高频考点,需掌握每种场景的触发原因、异常信息,以及JDK8与JDK21的差异;同时记住"堆OOM最常见,元空间OOM与类加载相关,直接内存OOM与 ByteBuffer 相关"。
四、类加载过程、双亲委派
类加载过程是JVM将.class文件加载到内存,转化为可执行代码的过程,双亲委派模型是类加载的核心机制,JDK8与JDK21的类加载过程基本一致,核心差异在双亲委派模型的细微优化和模块系统的适配上。
4.1 类加载的完整过程(JDK8与JDK21通用)
类加载过程分为5个步骤,依次执行,缺一不可,核心逻辑是"将类的字节码转化为JVM可识别的类对象":
4.1.1 加载(Loading)
核心操作:通过类加载器(ClassLoader)读取.class文件(从磁盘、网络、内存等位置),将其转化为二进制字节流,然后在内存中创建一个代表该类的java.lang.Class对象,作为类在JVM中的唯一标识。
关键:加载阶段仅负责读取字节流并创建Class对象,不进行任何验证和初始化操作。
4.1.2 验证(Verification)
核心操作:对加载的二进制字节流进行校验,确保其符合JVM规范,避免恶意字节码(如破坏JVM安全的代码)进入JVM,保障JVM运行安全。
验证内容:文件格式验证、元数据验证、字节码验证、符号引用验证。
4.1.3 准备(Preparation)
核心操作:为类的静态变量分配内存,并设置默认零值(如static int a = 10,准备阶段a的默认值为0,10的赋值在初始化阶段完成);同时为静态常量(final static)分配内存并设置初始值(final static int b = 20,准备阶段b直接赋值为20)。
关键:准备阶段仅处理静态变量,不处理实例变量(实例变量在对象创建时分配内存)。
4.1.4 解析(Resolution)
核心操作:将类中的符号引用(如类名、方法名、字段名)转化为直接引用(如内存地址),确保JVM能直接定位到类、方法、字段的实际位置。
例如:将"java.lang.String"符号引用,转化为JVM中String类的实际内存地址。
4.1.5 初始化(Initialization)
核心操作:执行类的静态代码块(static{})和静态变量的赋值操作,完成类的初始化,这是类加载过程的最后一步,也是唯一会执行用户代码的步骤。
触发时机:只有当类被主动使用时,才会触发初始化(如创建类的对象、调用类的静态方法、访问类的静态变量等)。
源码片段(类加载过程示例):
java
public class ClassLoadingDemo {
// 准备阶段:a默认值为0,b默认值为20(final static)
public static int a = 10;
public static final int b = 20;
// 初始化阶段:执行静态代码块,给a赋值10
static {
a = 10;
System.out.println("ClassLoadingDemo 静态代码块执行");
}
public static void main(String[] args) {
// 主动使用类,触发类加载的初始化阶段
System.out.println(ClassLoadingDemo.a);
}
}
4.2 双亲委派模型(核心机制)
4.2.1 核心定义
双亲委派模型是JVM类加载的核心机制,核心逻辑:当一个类加载器(ClassLoader)收到类加载请求时,不会直接加载该类,而是先将请求委派给其父类加载器,父类加载器再委派给其上级类加载器,直到顶层的启动类加载器;只有当父类加载器无法加载该类时,子类加载器才会尝试自己加载。
4.2.2 核心类加载器(JDK8与JDK21通用,略有差异)
JVM中存在3个核心类加载器,自上而下分为:
① 启动类加载器(Bootstrap ClassLoader):最顶层,由C/C++编写,负责加载JDK核心类库(如java.lang包下的类),无法通过Java代码获取;
② 扩展类加载器(Extension ClassLoader):加载JDK扩展类库(如jre/lib/ext目录下的类),JDK8中存在,JDK21中因模块系统,功能被模块类加载器替代;
③ 应用类加载器(Application ClassLoader):加载用户编写的类(classpath下的类),是默认的类加载器,JDK8和JDK21均存在。
4.2.3 双亲委派模型的好处
① 避免类重复加载:同一个类被不同的类加载器加载,会导致多个Class对象,引发类冲突;双亲委派模型确保一个类只被加载一次(由最顶层能加载该类的类加载器加载);
② 保障JVM安全:核心类库(如java.lang.String)只能被启动类加载器加载,避免恶意类伪装成核心类,破坏JVM安全(如自定义java.lang.String类,无法被加载)。
4.2.4 JDK8与JDK21的双亲委派差异
① 扩展类加载器的变化:JDK8中存在扩展类加载器(Extension ClassLoader),负责加载扩展类库;JDK21中,扩展类加载器被"模块类加载器(Module ClassLoader)"替代,模块类加载器负责加载JDK模块中的类,更符合JPMS模块系统的规范。
② 双亲委派的灵活性:JDK21中,双亲委派模型更灵活,允许通过模块系统配置类加载的委派规则,可在特定场景下跳过双亲委派(但不推荐,避免破坏安全机制);JDK8的双亲委派模型更严格,无法灵活配置。
③ 类加载器的实现:JDK21优化了类加载器的底层实现,提升了类加载的效率,尤其是在加载大量模块类时,性能优于JDK8。
面试提醒:类加载的5个过程、双亲委派模型的核心逻辑和好处,是面试高频考点;需重点记忆JDK21中扩展类加载器被模块类加载器替代这一差异。
五、如何破坏双亲委派
双亲委派模型是JVM的默认类加载机制,但在某些场景下(如SPI机制、自定义类加载器),需要破坏双亲委派模型,即让子类加载器优先加载类,而非委派给父类加载器。JDK8与JDK21破坏双亲委派的方式基本一致,但JDK21中结合模块系统,新增了部分破坏方式,以下详解常见的破坏方式及版本差异。
5.1 方式一:自定义类加载器,重写loadClass方法(最常用)
5.1.1 核心原理
双亲委派模型的核心逻辑在ClassLoader的loadClass方法中实现,默认逻辑是"先委派父类加载,父类加载失败再自己加载";自定义类加载器时,重写loadClass方法,修改委派逻辑(如先自己加载,加载失败再委派父类加载),即可破坏双亲委派。
5.1.2 示例代码(JDK8与JDK21通用)
java
// 自定义类加载器,破坏双亲委派
public class CustomClassLoader extends ClassLoader {
// 重写loadClass方法,修改委派逻辑
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 1. 检查该类是否已被加载
Class<?> loadedClass = findLoadedClass(name);
if (loadedClass != null) {
return loadedClass;
}
try {
// 2. 自己加载类(破坏双亲委派:不先委派父类,直接自己加载)
loadedClass = findClass(name);
} catch (ClassNotFoundException e) {
// 3. 自己加载失败,再委派父类加载
loadedClass = super.loadClass(name);
}
return loadedClass;
}
// 重写findClass方法,读取.class文件并转化为Class对象
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 简化逻辑:读取.class文件的二进制字节流(实际需从指定路径读取)
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
// 将字节流转化为Class对象
return defineClass(name, classData, 0, classData.length);
}
// 模拟读取.class文件的二进制字节流
private byte[] loadClassData(String className) {
// 实际场景中,从磁盘、网络等位置读取,此处简化
return null;
}
}
5.1.3 JDK8与JDK21的差异
无核心差异,均通过重写loadClass方法破坏双亲委派;但JDK21中,自定义类加载器需适配模块系统,若加载的类属于某个模块,需在模块描述文件(module-info.java)中配置权限,否则会加载失败。
5.2 方式二:使用线程上下文类加载器(Thread Context ClassLoader)
5.2.1 核心原理
线程上下文类加载器是每个线程持有的一个类加载器,默认情况下,线程上下文类加载器是应用类加载器;其核心作用是"打破双亲委派的层级限制",让父类加载器能加载子类加载器负责的类(如SPI机制中,核心类库由启动类加载器加载,但SPI实现类由应用类加载器加载)。
5.2.2 适用场景:SPI机制(如JDBC、JAXB)
以JDBC为例:JDBC的核心接口(java.sql.Driver)由启动类加载器加载,但JDBC的实现类(如MySQL的com.mysql.cj.jdbc.Driver)由应用类加载器加载;启动类加载器无法加载应用类加载器的类,此时通过线程上下文类加载器,让启动类加载器获取应用类加载器,从而加载实现类,破坏了双亲委派。
5.2.3 JDK8与JDK21的差异
JDK8中,线程上下文类加载器的使用较为简单,无需适配模块系统;JDK21中,SPI机制结合模块系统,线程上下文类加载器需在模块描述文件中配置"uses"和"provides"语句,明确SPI接口和实现类的关联,否则无法加载实现类。
5.3 方式三:SPI机制(Service Provider Interface)
5.3.1 核心原理
SPI是JDK提供的一种服务发现机制,核心逻辑:服务接口由核心类库提供(启动类加载器加载),服务实现类由第三方提供(应用类加载器加载);通过java.util.ServiceLoader类,加载服务实现类,此时核心类库的类加载器(启动类加载器)会通过线程上下文类加载器,加载应用类加载器负责的实现类,打破双亲委派。
5.3.2 JDK8与JDK21的差异
JDK8中,SPI机制通过META-INF/services目录下的配置文件(如META-INF/services/java.sql.Driver)指定实现类,无需模块配置;JDK21中,除了支持传统的SPI配置,还支持通过模块系统的"provides...with"语句指定实现类,更规范、更安全。
5.4 方式四:JDK21新增:模块系统的"未命名模块"
JDK21的模块系统中,未命名模块(Unnamed Module)中的类,可被所有模块访问,且未命名模块的类加载器(应用类加载器)可加载其他模块中的类,无需遵循双亲委派的层级限制,这是JDK21新增的一种破坏双亲委派的方式,适用于兼容性场景(如加载旧版本的非模块类)。
面试提醒:破坏双亲委派的3种通用方式(自定义类加载器重写loadClass、线程上下文类加载器、SPI)是面试高频考点,需掌握每种方式的原理和适用场景;同时记住JDK21新增的模块系统相关破坏方式,体现对新版本的了解。
总结:本文围绕JVM核心知识点展开,重点标注JDK8与JDK21的差异,兼顾源码、面试易错点和实际场景,适合Java面试复习使用;后续可根据复习进度,补充更多细节和面试真题。
(注:文档部分内容可能由 AI 生成)