本文只对jvm中常见的概念进行解读,不做深入讨论,后续会再出一篇文章,对这些概念进行深入说明。
术语介绍
术语 |
说明 |
JVM |
Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。 |
GC |
Garbage Collection,有了GC,程序员就不需要再手动的去控制内存的释放。当Java虚拟机(VM)发觉内存资源紧张的时候,就会自动地去清理无用对象(没有被引用到的对象)所占用的内存空间 |
年轻代 |
所有新生成的对象都是优先放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个 Survivor区,S0,S1 |
老年代 |
在年轻代中经历了N次垃圾回收后仍然存活的对象或者一些超大对象,就会被放到老年代中。 |
STW |
Stop the world,虚拟机在进行垃圾回收时,需要业务应用线程执行到一些“安全点”,在到达“安全点”时就会暂停当前运行的线程,再执行标记和清除,这个过程会停顿业务应用,所以称之为stop the world。 |
CMS |
Concurrent Mark Sweep,是一款划时代的垃圾回收器,它尽可能的优化STW的时机,使GC线程与业务程序线程并行执行,令程序暂停时间大大降低。 |
元空间 |
元空间的概念出现在Java8以后,在Java8以前称为永久代(这里说明下,方法区属于抽象概念,永久代以及元空间都是方法区的实现方式),元空间也是一块线程共享的内存区域,主要用来保存被虚拟机加载的类信息、常量、静态变量以及即时编译器编译后的代码等数据。 |
JVM产品
主流的jvm实现有几种:
- Oracle HotSpot JVM
-
- Oracle官方提供的JVM实现
- 最广泛使用的JVM
- 提供客户端(Client)和服务器端(Server)两种模式
- OpenJDK
-
- HotSpot的开源版本
- 由Oracle和开源社区共同维护
- 许多Linux发行版的默认JVM
- IBM J9 (Eclipse OpenJ9)
-
- IBM开发的JVM
- 现以OpenJ9名义开源
- 专注于低内存占用和高性能
- GraalVM
-
- Oracle开发的多语言虚拟机
- 支持Java、JavaScript、Python等多种语言
- 提供原生镜像(Native Image)特性
- Azul Zing
-
- Azul Systems开发的商业JVM
- 以低延迟著称
- 包含创新的"ReadyNow"技术
- Azul Zulu
-
- Azul提供的OpenJDK发行版
- 免费社区版和商业支持版
具体可以通过java -version查看当前实现,下面也重要介绍HotSpot,毕竟用的最多。
HotSpot
- 架构图如下
Class Files
Java字节码文件(通常以.class
为扩展名)是Java源代码编译后的中间表示形式,它包含了JVM可以执行的指令集。字节码文件,也是java语言支持跨平台的基础。
关键组成部分
- 魔数(Magic Number):固定值
0xCAFEBABE
,标识这是一个有效的class文件 - 版本号:major_version和minor_version决定class文件版本,例如Java 8对应主版本号52
- 常量池(Constant Pool):存储字面量(Literal)和符号引用(Symbolic References)、包括类名、方法名、字段名、字符串常量等。
-- 我们常常说的符号引用,其实指的是class文件里面的字段信息。从符号引用指向实际引用发生在类加载时。
4.访问标志(Access Flags):表示类或接口的访问权限和属性,如public、final、abstract等。
示例字节码
简单java代码
package com.example.myapp.service;
public class TestClass {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
转换成字节码javac TestClass.java
查看字节码 javap -c TestClass.class
PS:这里补充说明一点,实际jvm在运行.class的时候,不完全按照文件顺序执行,jvm或者cpu会对命令进行重新排序,以优化其执行效率。这里就是所谓的指令重排序,有兴趣的同学可以了解下。涉及到指令重排序的几个概念有volatile、happens-before原则等。
类加载
Java类加载机制是JVM将.class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型的过程。
类加载的过程包括加载、连接、初始化等。
注意:这里的加载跟类加载不一样,加载只是类加载的第一个环节,不要混淆了。
加载
- 任务:
-
- 查找并加载类的二进制数据
- 具体工作:
-
- 通过类的全限定名获取定义此类的二进制字节流
- 将字节流代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成代表该类的
java.lang.Class
对象,作为方法区该类的访问入口
验证
- 任务:
-
- 确保Class文件符合JVM规范,不会危害虚拟机安全
- 验证阶段:
-
- 文件格式验证:验证字节流是否符合Class文件格式规范
- 元数据验证:对类的元数据信息进行语义校验
- 字节码验证:通过数据流和控制流分析,确定程序语义合法
- 符号引用验证:发生在解析阶段,验证符号引用能否找到对应的类
准备
- 任务
-
- 为类变量(static变量)分配内存并设置初始值
- 特点
-
- 只分配static变量,不包括实例变量
- 初始值通常是数据类型的零值(如0、0L、null、false等)
- 如果static变量是final常量,准备阶段会直接赋值为代码中指定的值
解析
- 任务
-
- 将常量池内的符号引用替换为直接引用
- 解析对象
-
- 类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符
初始化
- 任务
-
- 执行类构造器
<clinit>()
方法
- 执行类构造器
- 特点:
-
<clinit>()
方法由编译器自动收集类中所有static变量的赋值动作和static代码块合并产生- JVM保证子类的
<clinit>()
执行前,父类的<clinit>()
已经执行完毕 - 接口中不能使用static代码块,但可以有变量初始化赋值
- 线程安全,只有一个线程能执行某个类的
<clinit>()
类加载器
提到类加载,就在说到类加载器类,类加载器总共分为4种类型:启动类加载器、扩展类加载器、应用程序类加载器、自定义加载器。以下是对应的作用。
双亲委派模型
双亲委派早期被设计出来,主要是为了防止核心的类被篡改,但后期随着时间的发展,逐渐发现类加载器的不适配情况,所以也陆续出现了打破双亲委派的情况。
1、具体的工作流程如下
类加载器收到加载请求,先将请求委派给父类加载器完成,只有父类加载器无法完成时,子加载器才尝试加载。
2、自定义类加载器实例
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadClassData(name);
return defineClass(name, data, 0, data.length);
} catch (IOException e) {
throw new ClassNotFoundException();
}
}
private byte[] loadClassData(String name) throws IOException {
name = name.replace(".", "/");
FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int b;
while ((b = fis.read()) != -1) {
baos.write(b);
}
fis.close();
return baos.toByteArray();
}
}
这里提出一个很有意思的问题,平时我们在开发代码的时候,涉及需要修改某个jar包里面的类,我们会怎么操作呢。很多时候都是简单的直接复制类代码出来,在项目上创建一个一模一样目录,一模一样类名的文件即可实现,有想过原理么,是否跟双亲委派模型有关系呢?
运行时数据区
Java虚拟机在执行Java程序时会把它所管理的内存划分为若干个不同的数据区域,这些区域统称为运行时数据区(Runtime Data Areas)。它们是JVM内存模型的核心组成部分。
以下是对应的架构图,涉及线程共享的有方法区、堆,线程隔离的有程序计数器、本地方法栈、虚拟机栈。
方法区
方法区是各个线程共享的内存区域,在虚拟机启动时创建,虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目的是与Java堆区分开来。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
String常量存在哪里。
堆
Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享,Java对象实例以及数组都在堆上分配。堆内存空间不足时,也会抛出OOM。一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充
虚拟机栈
- 虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建。
- 每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。
void a() {
b();
}
void b() {
c();
}
void c() {
}
栈帧
栈帧是Java虚拟机栈的基本组成单元,每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。它包含了局部变量表(Local Variable Table)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)
本地方法栈
本地方法栈(Native Method Stack)是为JVM运行Native方法服务的内存区域。Native方法是指用非Java语言(如C、C++)编写并通过Java Native Interface (JNI)调用的方法。
像我们在查看一些源码的时候,方法的修饰符里面带了native字段的,他的实现都依赖了本地方法栈。
程序计数器
- 程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
- 如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是Native方法,则这个计数器为空。
- Java多线程是通过线程轮流切换并分配处理器执行时间实现的,每个线程都需要独立记录自己的执行位置。切换后能恢复到正确的执行位置,确保线程切换后能继续从上次执行点继续执行。