JavaEE-JVM
JavaEE–JVM
深入理解JVM核心机制
[
VibeCoding·九月创作之星挑战赛
10w+人浏览
1.9k人参与
](
)
一、JVM简介
JVM(Java Virtual Machine,Java虚拟机)是运行Java字节码的虚拟计算机,是Java平台的核心组件。它通过将字节码转换为特定操作系统和硬件平台的机器指令,实现Java的跨平台特性(“一次编写,到处运行”)。
二、JVM的核心功能
- 类加载:加载.class文件到内存,验证字节码的合法性,并生成对应的类对象。
- 内存管理:管理运行时数据区(如堆、方法区、栈等),包括内存分配和垃圾回收(GC)。
- 执行引擎:解释或编译(JIT)字节码为机器码,优化代码执行效率。
- 安全机制:通过字节码验证、沙箱模型等保障程序的安全性。
三、JVM运行流程
程序在执行之前先要把.java代码转换成字节码(.class文件),JVM 首先需要把字节码通过一定的方式类加载器(ClassLoader) 把文件加载到内存中运行时数据区(Runtime Data Area),而字节码文件是 JVM 的⼀套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。
四、JVM运行时数据区
JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型(Java Memory Model,简称 JMM)完全不同,属于完全不同的两个概念,它由以下5个部分组成:
JVM的堆和栈和数据结构中的堆和栈没有关系。
1. 堆(线程共享)
JVM堆(Heap)是Java虚拟机管理的最大内存区域,用于存放对象实例和数组。所有线程共享堆内存,是垃圾回收(GC)的主要区域。堆在JVM启动时创建,其大小可通过参数配置。
我们常见的 JVM 参数设置 -Xms10m 最⼩启动内存是针对堆的,-Xmx10m 最大运行内存也是针对堆的。
ms是memory start简称,mx是memory max简称。
堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定GC次数之后还存活的对象会放入老生代。新生代有3个区域:一个Endn + 两个Survivor(S0/S1)。
GC指的是JVM的垃圾回收机制,文章后面会提到。
垃圾回收的时候会将Endn中存活的对象放到一个未使用的Survivor中,并把当前的Endn和正在使用的Survivor清除掉。
2. Java虚拟机栈(线程私有)
Java虚拟机栈(Java Virtual Machine Stack)是线程私有的内存区域,生命周期与线程相同。每个方法执行时会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
栈的空间不算很大,可以通过JVM的启动参数来设置(一般几MB到几十MB)。少数情况下可能出现“栈溢出StackOverFlow”的错误,那么有可能是递归代码出bug了。
什么是线程私有?
由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称之为"线程私有"的内存。
3. 本地方法栈(线程私有)
本地方法栈和虚拟机栈类似,只不过Java虚拟机栈是给JVM使用的,而本地方法栈是给本地方法使用的。
4. 程序计数器(线程私有)
程序计数器(Program Counter Register)是JVM中一块较小的内存空间,用于存储当前线程执行的字节码指令地址。每个线程都有独立的程序计数器,确保线程切换后能恢复到正确的执行位置。
5. 方法区(线程共享)
方法区(Method Area)是Java虚拟机(JVM)规范中定义的逻辑内存区域,用于存储类信息、常量、静态变量、即时编译器编译后的代码等数据。它是所有线程共享的内存区域,与堆内存类似,但主要存放与类相关的元数据。
不同JVM实现中,方法区的具体形式可能不同:
- HotSpot虚拟机(JDK8之前):方法区被称为“永久代”(Permanent Generation),通过JVM参数调节大小(如
-XX:MaxPermSize
)。 - JDK8及之后:永久代被移除,改用“元空间”(Metaspace)作为方法区的实现,直接使用本地内存(Native Memory),默认情况下仅受系统内存限制,可通过
-XX:MaxMetaspaceSize
设定上限。
运行时常量池是方法区的一部分,用于存放编译期生成的字面量、符号引用,以及运行时动态添加的常量(如String.intern()
的结果)。
6. 小结
五、JVM类加载
1. 类加载步骤
类加载触发的时机(懒汉模式):
Java代码启动,用到哪个类,就会触发哪个类的加载,而不是在启动时加载所有会用到的类。
1. 加载
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,它和类加载Class Loading是不同的,一个是加载Loading另一个是类加载Class Loading,所以不要把二者搞混了。
在加载Loading阶段,Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
2. 验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全并把这里的数据转化成结构化的数据。
3. 准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
比如此时有这样一行代码:
public static int value = 123;
它的初始化vlue的值为0,而非123。
4. 解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
5. 初始化
初始化阶段,Java虚拟机真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程。
2. 双亲委派模型
双亲委派模型是Java类加载机制的核心原则,它定义了类加载器在加载类时的层次化委托行为。这种模型确保类的唯一性和安全性,避免类冲突。下面我将逐步解释其工作原理、优点和相关实现细节。
1. 定义与核心概念
- 双亲委派模型:当一个类加载器收到加载类的请求时,它不会立即尝试加载,而是先委托给其父类加载器处理。如果父类加载器无法加载(例如找不到类),子类加载器才会尝试加载。这形成了一种自顶向下的委托链。
- 关键角色:
- 启动类加载器(Bootstrap ClassLoader):最高层,加载Java核心库(如
java.lang
包)。 - 扩展类加载器(Extension ClassLoader):加载Java扩展库。
- 应用程序类加载器(Application ClassLoader):加载用户类路径(ClassPath)上的类。
- 自定义类加载器:用户可继承
ClassLoader
类创建,用于特定场景(如热部署)。
- 启动类加载器(Bootstrap ClassLoader):最高层,加载Java核心库(如
2. 工作流程
双亲委派模型的工作过程遵循严格的委托顺序:
- 步骤1:子类加载器收到加载请求(例如加载一个类
com.example.MyClass
)。 - 步骤2:子类加载器委托给父类加载器处理。
- 步骤3:父类加载器递归向上委托,直到达到启动类加载器。
- 步骤4:如果父类加载器成功加载类,则返回结果;如果所有父类加载器都无法加载,子类加载器才尝试从自己的类路径加载。
- 优点:
- 避免类重复:确保每个类只被加载一次,防止内存浪费。
- 安全性:核心Java类由高层加载器处理,防止恶意代码替换(如自定义
java.lang.String
)。 - 稳定性:维护类加载的层次结构,减少冲突。
3. 优点与局限性
- 主要优点:
- 高效性:通过委托减少不必要的加载尝试。
- 隔离性:不同加载器加载的类在不同命名空间,便于模块化管理。
- 局限性:
- 在某些场景(如OSGi框架)中,可能需要打破委派模型来实现动态加载。
- 如果父类加载器加载了错误版本,子类无法覆盖。
4. 简单代码示例
以下是一个简化的Java代码示例,展示如何实现自定义类加载器并遵循双亲委派模型。代码中,MyClassLoader
继承自ClassLoader
,并重写findClass
方法,在父类加载失败时执行自定义加载。
import java.io.*;
public class MyClassLoader extends ClassLoader {
// 自定义类加载器构造函数,指定父加载器
public MyClassLoader(ClassLoader parent) {
super(parent); // 委派给父加载器
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 先尝试父类加载器(双亲委派)
try {
return super.findClass(name);
} catch (ClassNotFoundException e) {
// 父类无法加载时,子类加载器处理
byte[] classData = loadClassData(name); // 从自定义路径加载字节码
if (classData == null) {
throw new ClassNotFoundException("Class not found: " + name);
}
return defineClass(name, classData, 0, classData.length); // 定义类
}
}
private byte[] loadClassData(String className) {
// 简化:从文件系统读取类字节码
String path = className.replace('.', '/') + ".class";
try (InputStream is = new FileInputStream(path);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
int data;
while ((data = is.read()) != -1) {
bos.write(data);
}
return bos.toByteArray();
} catch (IOException e) {
return null;
}
}
}
// 使用示例
public class Main {
public static void main(String[] args) throws Exception {
ClassLoader parentLoader = Main.class.getClassLoader(); // 获取当前类加载器(作为父)
MyClassLoader myLoader = new MyClassLoader(parentLoader); // 创建自定义加载器
Class<?> myClass = myLoader.loadClass("com.example.MyClass"); // 加载类,遵循委派模型
}
}
在这个示例中:
MyClassLoader
在尝试加载类时,先委托给父加载器(super.findClass
)。- 如果父加载器失败,才执行自定义加载逻辑(
loadClassData
)。 - 这体现了双亲委派的核心:优先父类,后子类。
5. 实际应用
- 在Java应用中,双亲委派模型是默认行为,无需额外配置。但开发自定义加载器时(如Web容器Tomcat),需注意委派规则。
- 如果遇到类加载问题,可检查类路径或使用
-verbose:class
JVM参数调试。
六、垃圾回收
JVM的垃圾回收机制(Garbage Collection, GC)是Java内存管理的核心组件,其核心概念是通过自动回收无用对象所占用的内存空间来避免内存泄漏和手动管理内存的复杂性。
1. 垃圾的判断算法
1.引用技术算法
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如Python语言就采用引用计数法进行内存管理。
但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的循环引用问题。
观察循环引用问题:
public class referenceCount {
public Object instance = null;
private static int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
referenceCount test1 = new referenceCount();
referenceCount test2 = new referenceCount();
test1.instance = test2;
test2.instance = test1;
test1 = null;
test2 = null;
// 强制jvm进行垃圾回收
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
若GC日志未打印,那么可以按照如下步骤设置:
- 菜单栏Run -> Edit Configurations
2.在VM options一栏填上“-XX:+PrintGC”
3.保存再次运行就能打印出来了。
从结果可以看出,GC日志包含"11M->0M",意味着虚拟机并没有因为这两个对象互相引用就不回收他们。即JVM并不使用引用计数法来判断对象是否存活。
2. 可达性分析算法
可达性分析(Reachability Analysis)用于确定系统中特定状态或节点是否可从初始状态通过一系列操作或路径到达。常见于图论、自动机理论、程序分析(如垃圾回收)等领域。
对象Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。
在Java语言中,可作为GC Roots的对象包含下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(Native方法)引用的对象。
这个过程是周期性的,每隔一段时间,就会触发一次可达性分析。可达性分析有时间开销,没有空间开销。
2. 垃圾回收算法
垃圾回收(Garbage Collection, GC)是自动内存管理的核心机制,用于回收程序中不再使用的内存。以下是常见的垃圾回收算法及其特点:
标记-清除算法(Mark-Sweep)
- 标记阶段:从根对象(如全局变量、活动栈帧)出发,遍历所有可达对象并标记为“存活”。
- 清除阶段:扫描堆内存,回收未被标记的对象内存。
- 缺点:产生内存碎片,可能需额外处理碎片化问题。
复制算法(Copying)
- 实现:将堆分为“From”和“To”两个区域。存活对象从“From”复制到“To”,并紧邻排列。
- 优点:避免碎片化,分配高效(只需移动指针)。
- 缺点:内存利用率仅50%,且复制大对象开销高。
- 应用场景:新生代回收(如Java的Serial、ParNew收集器)。
标记-整理算法(Mark-Compact)
- 标记阶段:与标记-清除相同,标记所有存活对象。
- 整理阶段:将存活对象向内存一端移动,清理边界外内存。
- 优点:解决碎片化问题,适合老年代回收。
- 缺点:移动对象成本高,需暂停程序。
分代收集算法(Generational)
- 分代假设:大多数对象生命周期短(新生代),少数长期存活(老年代)。
- 策略:
- 新生代使用复制算法(如Minor GC)。
- 老年代使用标记-清除或标记-整理(如Major GC/Full GC)。
- 优化:结合多线程、并发标记等技术减少停顿时间(如G1、ZGC)。
选择建议
- 低延迟场景:选择并发收集器(如ZGC、Shenandoah)。
- 高吞吐场景:并行收集器(如Parallel Scavenge/Old)。
- 内存敏感场景:考虑区域化收集器(如G1的Region设计)。
3.垃圾收集器
垃圾收集器是为了保证程序能够正常、持久运行的一种技术,它是将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间。
以下这些收集器是HotSpot虚拟机随着不同版本推出的重要的垃圾收集器:
创作不易,给个三连支持一下吧