0 引入
Java诞生之初就有一个口号一次编写,到处运行,也就是我们编写的Java程序不用考虑要运行到什么类型的操作系统上,都是可以运行的,这体现了Java虚拟机的平台无关性。
🤔这样的平台无关性是怎么实现的?
简而言之,字节码+JVM实现了平台无关性。Java在编译成字节码之后可以在任何支持JVM的平台上运行,JVM会将字节码转换为适合相应平台的机器码
,屏蔽了操作系统和硬件的差异,这种机制使得Java可以实现一次编写,到处运行的目标,成为一种广泛应用于各种平台的编程语言。
🤔Java虚拟机(JVM)只能执行Java编写的程序吗?
实际上,Java虚拟机只关心字节码文件
,程序的运行也是虚拟机解释字节码文件来完成的。至于字节码是怎么生成的,虚拟机不做限制,这也体现了Java虚拟机的语言无关性。目前能生成字节码文件的语言有(Java,Jruby、Groovy、Kotlin)
字节码文件,也就是Class文件,最终需要加载到虚拟机中之后才能够被运行和使用,而虚拟机如何加载这些Class文件,Class文件中的信息进入到虚拟机之后会发生什么变化,这些都是我们要学习的东西。
1 概述
Java虚拟机会把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。
与那些在编译时需要进行链接的语言不通,在Java语言里面,类型的加载、连接和初始化都是在程序运行期间
完成的,这种策略会让Java语言进行提前编译时面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提高了极高的扩展性和灵活性,Java天生可以动态扩展
的语言特性就是依赖运行期间动态加载和动态连接这个特性实现的。
2 类的生命周期和加载过程
一个类在JVM的生命周期
有七个阶段,分别是加载(Loading)、校验(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。
其中前五个部分(加载、校验、准备、解析、初始化)统称为类加载
。
我们主要看类加载的五个阶段,其中加载、校验、准备和初始化这四个阶段发生的顺序是一定的,而解析
阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。另外,注意,这里的几个阶段都是按顺序开始,而不是按顺序完成,因为这些阶段通常都是相互交叉的混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
2.1 加载
“加载”(Loading)是整个“类加载”(Class Loading)过程中的一个阶段,名字有点儿像,但是不一样。
在“加载”阶段,Java虚拟机需要完成三件事情(我这辈子就指望你这三件事):
- 通过一个类的
全限定名
来获取定义此类的二进制字节流
- 将这个字节流所代表的
静态存储结构
转化为方法区
的运行时数据结构
- 在内存中生成一个代表这个类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口
补充:
- 类加载器:加载阶段是开发人员控制性最强的阶段,类加载器可以是系统的,也可以是自定义的
- 加载方式:类的二进制字节流并没有限定说必须从Class文件中获取,其它可以获取的渠道有
- 从本地文件系统加载
- 从数据库中获取
- 从zip,jar等文件中获取
- JVM按需加载类,即在第一次使用到某个类时才会进行加载,而不是提前加载所有类
2.2 校验
校验是链接阶段的第一步,这一阶段的主要目的是确保Class字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全,即确保字节码的正确性
和安全性
。
校验需要完成如下工作:
- 文件格式校验:校验读进来的字节流是否符合Class标准格式
- 是否以魔数0xCAFEBABY靠头
- 主次版本号是否在当前虚拟机接受范围之内等
校验字节流格式是否符合标准要求
- 元数据校验:对类的元数据进行语义分析,确保其描述的的信息符合《Java语言规范》的要求
- 是否有父类
- 这个类的父类是否继承了不被允许继承的类
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段,方法,是否和父类产生矛盾(如覆盖父类的final字段,或出现不符合规则的方法重载)等
校验字节流描述的信息是否符合规范要求
- 字节码校验:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的,也可以说是分析类的方法体(Class文件中的Code属性),确保方法在运行时不会危害虚拟机
校验程序执行的流程是否会危害虚拟机
- 符号引用校验(发生在解析阶段):检查常量池中引用的外部类是否存在,是否可以正常访问
2.3 准备
准备阶段主要是为类的静态变量
(static修饰的变量)分配内存
,并将其初始化
为默认值
(0、0L、null、false这种)。
注意:
- 准备阶段只给类变量(static修饰的类属性)分配内存,不会给实例变量分配内存
- 准备阶段正常都是初始化为默认值的,但是如果是加了
final
这种的,会直接赋值为初始值。
public static int value = 1;
//在准备阶段被初始化为0,后面在类初始化的阶段才会执行赋值为1
public static final int value = 1;
//i在准备阶段就被赋值为1
2.4 解析
解析阶段是Java虚拟机将常量池
的符号引用
替换为直接引用的过程
。符号引用用于描述目标,直接引用直接指向目标地址。
举个例子:
源码:
// User.java
public class User {private String name;public void printAddress() {Address address = new Address(); // 引用另一个类System.out.println(address.getCity()); // 引用 Address 的 getCity() 方法}
}// Address.java
public class Address {public String getCity() {return "Beijing";}
}
编译后的User.class常量池部分:
Constant Pool:#7 = Class #23 // com/example/Address#8 = Methodref #7.#24 // com/example/Address.getCity:()Ljava/lang/String;...#23 = Utf8 com/example/Address#24 = NameAndType #25:#26 // getCity:()Ljava/lang/String;#25 = Utf8 getCity#26 = Utf8 ()Ljava/lang/String;
#8
是对Address.getCity()
方法的符号引用,由#7
(类名)和#24
(方法名和描述符)组成。
解析过程详解:
- 符号引用的本质
- 在编译时,JVM并不知道
Address.getCity()
方法的具体位置 ,因此用符号引用com/example/Address.getCity:()Ljava/lang/String;
占位
- 在编译时,JVM并不知道
- 解析阶段的任务
- 当JVM首次执行address.getCity()时:
- 查找目标类:根据符号引用中的类名
com/example/Address
,加载Address
类(如果尚未加载)。 - 定位目标方法:在 Address 类的方法表中找到
getCity()
方法的内存地址
(假设为 0x7f3e0012)。 - 替换为直接引用:将
User 类常量池
中的符号引用 #8
替换为直接引用0x7f3e0012
。
- 内存结构变化
解析前(符号引用):
User 类的字节码指令(printAddress 方法):invokevirtual #8 // 符号引用:Method com/example/Address.getCity:()Ljava/lang/String;
解析后(直接引用):
User 类的字节码指令(printAddress 方法):invokevirtual 0x7f3e0012 // 直接引用:指向 Address.getCity() 的内存地址
🤔 为什么需要符号引用?
- 解耦编译与运行:编译时不需要知道其他类的具体内存布局(如
Address
类可能尚未加载)- 动态扩展性:允许通过自定义类加载器动态加载类(如热部署、插件化架构)
- 跨平台兼容性:符号引用是抽象的,与具体JVM实现无关,确保
.class
文件可跨平台运行
总之,通过这种机制,Java实现了动态链接,使得类在运行时可以灵活的加载和链接其它类,支撑了Java的动态性和跨平台特性
2.5 初始化
JVM规范规定,必须在类的首次主动使用时才能执行类初始化,初始化的过程包括执行:
- 类构造器方法
- static静态变量赋值语句
- static静态代码块
补充:
在Java中对类变量进行初始值设定有两种方式:
- 声明类变量时指定初始值:
public static int value = 1;
- 使用静态代码块为类变量指定初始值
public static int value;
static{value = 1;
}
初始化步骤:
- 如果这个类还没有被加载和链接,则程序先加载并链接该类
- 如果该类的父类还没有被初始化,则先初始化其直接父类
- 如果类中有初始化语句,则系统依次执行这些初始化语句(静态代码块)
类初始化时机:前面也提到过,只有对类主动使用
的时候才会导致类的初始化,类的主动使用包括以下六种:
- 调用类的静态方法
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 初始化某个类的子类,则父类也会初始化
- 创建类的实例,也就是new的方法
- 反射(如Class.forName(“com.lly.jvm.Test”))
- Java虚拟机启动时被标明为启动类的类(Java Test),直接使用Java.exe命令来运行某个类
3 类加载器
3.1 类和类加载器
🤔 类加载器是什么?
类加载器是负责将字节码加载到内存中,并将其转换为java.lang.Class
对象的,JVM中的一部分。
对于任意一个类,都必须由加载它的类加载器
和这个类本身
一同确立其在Java虚拟机中的唯一性。如果同一个类被两个不同的加载器加载,即使来源是同一个Class文件,那这也是两个不同的类,主要体现在,Class对象的equals()方法,isInstance()方法的返回结构,以及使用instanceof关键字走对象所属关系判定等各种情况。
所以说我们要判断两个类是否相等的前提是要看它们是否被同一个类加载器加载,否则两个类必然不相等,比较起来没有意义。
名称 | 加载哪的类 | 说明 |
---|---|---|
BootStrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib 或-Xbootclasspath 参数所制定路径中存放的 | 用来加载Java的核心类,使用原生C++代码实现的,并不继承自Java.lang.ClassLoader ,无法直接访问,例如,Java.lang.String是由类加载器加载的,所以String.class.getClassLoader() 就会返回null |
Extension ClassLoader(扩展类加载器) | JAVA_HOME/jre/lib/ext或被java.ext.dirs系统变量所指定的路径中的所有类库 | 负责加载JER的扩展目录中的类(必须以Jar包的形式存在),上级为Bootstrap,代码里直接获取它的父类加载器为null |
Application ClassLoader(应用类加载器) | classpath | 加载用户类路径(classpath)中指定的类,即应用程序类。可以通过ClassLoader.getSystemClassLoader() 获取,上级为Extension |
自定义类加载器 | 自定义 | 通过继承java.lang.ClassLoader 类的方式实现,上级为Application |
用Bootstrap类加载器加载的类:
public static void main(String[] args) throws ClassNotFoundException {System.out.println(String.class.getClassLoader());
}
3.2 双亲委派模型
🤔 什么是双亲委派?
双亲委派机制是一种任务委派模式,是Java中通过加载工具(classloader)加载类文件的一种具体方式。具体表现为:
- 如果一个类加载器收到了类加载请求,它并不会自己先加载,而是把这个请求委托给父类的加载器去执行
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将达到顶层的启动类加载器
BootstrapClassLoader
. - 如果父类加载器可以完成类加载任务,就成功返回;倘若父类加载器无法完成加载任务,子类加载器才会去尝试自己加载
- 父类加载器一层层往下分配任务,如果子类加载器能加载,则加载此类;如果将加载任务分配至应用类加载器(
AppClassLoader
)也无法加载此类,则抛出异常
🤔 为什么要有双亲委派机制,不嫌麻烦吗?
我觉得主要是出于对安全性的保证吧,比如,本来java.lang.String
这个类是由BootstrapClassLoader
来加载的,如果没有
双亲委派机制的话,我们普通的用户也去定义一个java.lang.String
,然后由于不会再一层一层的向上委托加载了,就会由应用类加载器去加载这个类,那么这个类就相当于被破坏了,就是说,用户可以通过自定义的方式,任意去替换一些Java的核心类,这样是相当不安全的。