1.JVM是什么?
1.1 JVM概念
Java Virtual Machine (JVM) 是JDK的核心组件之一,它使得 Java 程序能够在任何支持 JVM 的设备或操作系统上运行,而无需修改源代码
JDK是什么,JDK和JVM是什么关系?
1.Java IDE(Integrated Development Environment):集成开发环境,专门用于Java编程(例如IDEA),开发Java应用程序前需要选择某具体版本的JDK
2.JDK(Java Development Kit)
Java开发工具包,用于编写,编译,调试和运行Java应用程序,提供了开发Java程序所需的所有工具和资源。JDK=JRE+其他
3.JRE(Java Runtime Environment)
Java运行时环境,包含Java虚拟机(JVM),类库和其他文件,用于运行Java程序
通过以上的内容梳理可知,JDK包含JRE,JRE包含JVM
1.2 JVM作用
说到这里,我就要搞清楚计算机是如何认识我们编写的代码的
首先,计算机只认识0和1,执行的指令集也是一串一串的01。所以,我们编写的代码一定是被转换为二进制文件才能被计算机执行。
其次,不同的操作系统的指令集是不同的。例如,在Windows系统中0000 0010的意思是加法,而在Linux系统中的意思是减法(只是举例,不是真正的指令集)。
那么将开发者编写的代码直接转换为二进制文件,放在不同的操作系统中运行的结果可能也不同,如果要实现同样的功能就需要在不同的操作系统上编写不同的代码。
Java能跨平台运行的原因
先回忆一下刚开始学习Java编程的时候听过的一句话"一次编译,到处运行",这句话体现了Java的跨平台能力,开发者只需要编写一次Java代码并编译成字节码文件,就可以在任何安装了JVM的机器上执行。
下面是Java代码从编写到运行的过程
java文件通过javac(Java编译器,Java Compiler)编译成字节码文件(class文件)
可以把JVM看成计算机,字节码文件就相当于JVM的指令集。然后JVM把字节码文件转换为对应系统的指令集,
例如:现在有"Hello World"这么一串代码,Windows系统上的JVM将代码转换为0010,Linux系统上的JVM将代码转换为0110,最后两个系统的执行结果都是"Hello World",这就实现了Java程序的跨平台运行
1.3 JVM执行流程
程序在执行之前先要把java代码转换成字节码(class文件),JVM 首先需要把字节码通过一定的方式类加载器(ClassLoader) 把文件加载到内存中运行时数据区(Runtime Data Area) ,而字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口本地库接口(Native Interface) 来实现整个程序的功能
2.深入学习JVM
2.1运行时数据区
运行时数据区是Java程序执行时所需的内存区域。JVM启动时,会根据不同的内存区域分配和管理内存。一般来说,线程共享的区域的生命周期和JVM一致;线程私有的区域会随着线程的创建和销毁跟着一起创建和销毁,生命周期和所属线程一致。
2.1.1.方法区(线程共享)
方法区是JVM内存规范中定义的抽象概念,并不是真实的物理空间。
在JDK8以前,使用永久代来实现方法区。永久代是在堆中开辟的内存空间,主要存储:
(1)类的元信息:类名,修饰符等
(2)常量池
(3)静态变量
注:永久代的大小是在JVM启动时固定的,难以根据实际需求来动态调整,而且永久代是在堆内存中开辟的空间,这也限制了永久代的大小。所以在JDK8之后使用元空间来实现方法区
在JDK8及以后,使用元空间来实现方法区(上面图片中的元数据区/元空间不太准确,但我确实没有找到更好的图片)
元空间存储方式相较于永久代有所改动。首先,元空间不再在堆内存中开辟空间,而是单独向操作系统申请空间,这就不会再受到堆内存大小的限制。其次,元空间可以根据实际需求来动态调整大小。然后,元空间内部存储的数据也发生了一些变化
(1)类的元信息依旧存储在元空间中
(2)常量池转移到堆中
(3)静态变量
2.1.2.堆(线程共享)
JVM内存中最大的部分,是所有线程共享的空间。
1.在JVM启动时创建(可以动态调整大小,有上限),是垃圾回收的主要位置。
2.几乎所有的对象实例(通过new创建)都存储在堆中。
3.从内存回收角度来看java堆可分为:新生代和老生代
注:JVM在编译时会分析对象是否逃逸出方法或者线程,如果对象不会逃逸出方法或者线程,只在内部使用,JVM就可以将其分配在栈(线程私有)上,以提高性能减少垃圾回收的开销,这里不做详细讨论
2.1.3.虚拟机栈(线程私有)
每个线程都有一个独立的虚拟机栈,用于存储栈帧(Stack Frame)。每个方法在执行时都会创建一个栈帧,用于存储方法的局部变量,方法调用和返回地址
2.1.4.本地方法栈(线程私有)
存储本地方法(Native Methods)调用的信息。本地方法是使用非Java语言(如C、C++)编写的方法,它们通过JNI(Java Native Interface,Java本地方法接口)与Java代码进行交互
2.1.5.程序计数器(线程私有)
存储当前线程执行的字节码指令的地址
2.2类加载器
2.2.1类加载过程
类加载包括:加载(Loading),连接(Linking)和初始化(Initialization)三个步骤
(1)加载(Loading)
通过类加载器将硬盘中的字节码文件加载到运行时数据区,并生成一个类对象存储在方法区。当然,也不一定是硬盘中的字节码文件,还可能来自于网络、数据库,甚至是即时生成的字节码文件
注意一:着重区分加载和类加载的区别。加载(Loading)只是类加载的第一个阶段;而类加载包括加载(Loading),连接(Linking)和初始化(Initialization)三个步骤
(2)验证(Verification)
确保类文件符合JVM规范中定义的类文件格式。
文件格式验证:检查文件是否是以0xCAFEBABE开头,这是Java类文件的标识;检查类文件的版本号是否和JVM对应,Java 8的JVM不支持Java 9的类文件
元数据验证:确保类的元数据信息没有语法错误
字节码验证:确保类的字节码指令是合法的,不会导致JVM崩溃或者执行不安全操作
注意二:在今天,验证操作不单单是验证(Verification)这一个阶段了。在解析阶段还有符号引用验证,解析阶段可以发生在初始化之前,也可能发生在初始化之后(代码中发生多态来实现后期绑定),而且JVM的开发人员还在不断完善验证策略,所以验证操作分散在各个阶段内,并不是单一的阶段。
(3)准备(Preparation)
为类的静态变量分配内存并设置默认值。
-
将类的静态变量分配到方法区(有一些静态变量不在方法区)
-
基本数据类型初始化默认值,int类型初始化为0,boolean类型初始化为false
-
引用类型初始化为null
-
如果是静态常量,直接赋目标值,跳过默认值
(4)解析(Resolution)
将符号引用替换为直接引用。
直接引用:指向内存中的实际地址的指针或者偏移量
符号引用:是一种文本形式的引用,使用字符串或其他符号来描述目标类,字段和方法
问题:为什么要引入符号引用?
因为在class文件加载到运行时数据区之前,class文件是在硬盘或者其他空间中存储的(反正不是内存),没有地址和指针这个概念,如果要定位一个类,只能使用其他形式的标识符
动态解析举例:加入B类是一个抽象类,实现的是身份选择功能,C和D类继承了B,分别代表普通用户和管理员。那么A到底引用C还是D,这可能需要用户来决定。此时,A类就会先进行初始化阶段,当用户选择完身份后再来解析
(5)初始化(Initialization)
任务:执行类的初始化代码
触发条件(以下任一情况都会触发初始化):
1.创建类的实例(new
)
2.访问类的静态变量(非final
)或静态方法
3.反射调用类(Class.forName("com.example.MyClass")
4.子类初始化时,其父类会先被初始化
5. 作为程序入口的主类(包含main()
方法的类)
执行顺序:
1.父类静态变量和静态代码块(按代码顺序执行)
2.子类静态变量和静态代码块(按代码顺序执行)
3.父类实例变量和构造代码块
4.父类构造函数
5.子类实例变量和构造代码块
6.子类构造函数
2.2.2类加载器
在上述类加载过程中,第一个阶段"加载"涉及到JVM中一个非常重要的模块——类加载器。类加载器主要负责根据类的全限定名找到对应的.class文件
什么是全限定名?
全限定名指的是包含**包名**在内的**类**的完整名称。例如,假设有一个ArrayList类,属于java.util包,那么它的全限定名就是java.util.ArrayList
类加载器的搜索范围:不同的类加载器负责不同路径的类加载。在JVM中,不算自定义的类加载器,默认的类加载器有三种:
(1) 启动/引导类加载器(Bootstrap ClassLoader):加载 `JAVA_HOME/lib` 下的核心类库
(如 `rt.jar`)
注:这里的JAVA_HOME一般指的是JDK的安装路径,如下图
rt.jar(以JDK8为例):包含Java标准库,如java.lang,java.util等,至于标准库有哪些在Java语言规范中有明确规定,这里不过多赘述。这些标准库中的类由启动/引导类加载器负责加载。
(2) 扩展类加载器(Extension ClassLoader):加载 `JAVA_HOME/lib/ext` 下的扩展类
Java语言规范中没有的类,并且是由JVM开发者添加的类,称为扩展类,这些类由扩展类加载器负责加载。JVM的版本有很多,所以扩展类有哪些和JVM的具体版本有关
(3) 应用类加载器(Application ClassLoader):加载用户类路径(ClassPath)下的类
一般包括开发者编写的类和第三方依赖库
2.2.3 双亲委派机制(不考虑自定义类加载器)
核心思想:当类加载器收到类加载请求时,不会自行立即加载,而是先将该加载请求委派给父类加载器,最终请求会到达顶层类加载器。
完整过程:
(1)顶层加载器(启动类加载器,Bootstrap ClassLoader)检查JAVA_HOME/lib路径下的核心类库,如果能找到就加载
(2)如果启动类加载器找不到,请求返回给扩展类加载器,检查JAVA_HOME/lib/ext路径下的扩展类,如果能找到就加载
(3)如果扩展类加载器找不到,请求返回给应用类加载器,检查用户类路径下的类
(4)如果所有类加载器均无法加载请求类,则抛出ClassNotFoundException
双亲委派机制的优势:
1. 避免核心类被篡改
安全性:通过优先由启动类加载器加载核心类(如 java.lang.String
),确保用户无法定义同名类覆盖核心类
示例:若用户自定义 java.lang.String
,JVM 会直接加载核心库中的版本,用户类被忽略
2.防止重复加载
唯一性:每个类由父类优先加载,确保同一个类在多个类加载器中只加载一次
示例:若父类已加载 com.example.MyClass
,子类不会再重复加载,避免内存浪费和类冲突
3.天然的类隔离性
隔离性:不同类加载器加载的类属于不同的命名空间,天然隔离。
4.灵活扩展
可定制性:允许子类加载器扩展加载范围(如从网络、数据库加载类),同时不破坏核心类的稳定性
3.小结
下篇博文将继续介绍JVM剩下核心机制——垃圾回收