第2章、JVM类加载机制--类加载器子系统
参考书籍:《深入理解Java虚拟机》
参考链接:
1、JVM整体架构简图
了解了JVM的一个整体结构,因此如果自己想手写一个Java虚拟机的话,主要考虑哪些结构呢?
- 类加载器
- 执行引擎【逐条的解释字节码中的指令】
所以这两部分是JVM中最重要的
本节重点内容:
2、类加载器子系统作用概述
2.1、引言
《深入理解Java虚拟机》摘记:
在Class文件中描述的各种信息,最终都需要加载到虚拟机中之后才能运行和使用。而虚拟机如何加载这些Class文件?Class文件中的信息进入到虚拟机后会发生什么变化?这是我们本节需要掌握的
虚拟机的类加载机制:
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。与那些在编译时需要进行连接工作的语言不同,Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的。
这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
约定:
- 在实际情况中,每个Class文件都有代表着Java语言中的一个类或接口的可能。
- “Class文件”并非特指某个存在于具体磁盘中的文件,应当指的是一串二进制的字节流,无论以何种形式存在都可以。
2.2、类加载子系统
-
类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识魔数CAFEBABE。
-
ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine执行引擎决定
-
类加载器主要是将字节码文件加载到内存中,生成一个大的Class实例。加载的Class文件中的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池【Constant pool】信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)【常量池在运行过程中加载到内存,叫做运行时常量池】
Constant pool:
#1 = Methodref #3.#21 // java/lang/Object."<init>":()V
#2 = Class #22 // com/atguigu/java/StackStruTest
#3 = Class #23 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
例子:说明类加载器ClassLoader扮演的角色,图示如下
- class file存在于本地物理硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM内存当中来根据这个文件实例化出n个一模一样的实例。
- class file加载到JVM中,被称为DNA元数据模板,放在方法区。
- 可以通过getClassLoader方法知道该类是由哪个类加载器加载的
所以综述: 在.class文件--->JVM--->最终成为元数据模板【类加载器子系统加载class文件流程中class文件的变化状态】,此过程的成功实现【即加载class文件的成功实现】就要一个运输工具(类装载器Class Loader),它在类加载器子系统里面扮演一个快递员的角色。
3、类的加载过程
3.1、概述
类加载器子系统内部细节阐述【类的生命周期】:
例如下面的一段简单的代码,它整个类的加载过程、执行过程是怎么样的呢?
public class HelloLoader {
public static void main(String[] args) {
System.out.println("我已经被加载啦");
}
}
如下图所示:首先就是要用类加载器ClassLoader加载HelloLoader类的字节码文件HelloLoader.class、继而成功装载HelloLoader这个类,然后链接、初始化HelloLoader这个类、然后才能调用main方法,才能执行里面的代码。如果字节码文件不合法导致装载失败则会抛出异常。
3.2、注意点【延伸】
-
加载->验证->准备->初始化->卸载【多一个使用和卸载的过程】这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始
-
而解析阶段则不一定:它某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
-
对于什么情况下需要开始类加载过程中第一个阶段“加载”,虚拟机规范没有强制约束,自由把握
-
对于初始化阶段,虚拟机规范严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行初始化,则需要先触发其初始化阶段。能够生成这4条指令的典型java代码场景有:
- new:使用new关键字实例化对象的时候
- putstatic:设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)
- getstatic:读取一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)
- invokestatic:调用一个类的静态方法的时候
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要制定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个类。
- 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对对应的类没有进行过初始化,则需要先触发其初始化。
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行初始化,则需要先触发其初始化阶段。能够生成这4条指令的典型java代码场景有:
有且只有以上这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
被动引用的例子之一:
package org.fenixsoft.classloading;
/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化
*/
class SuperClass{
static{
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass{
static{
System.out.println("SubClass init!");
}
}
/**
* 非主动使用类字段演示
*/
public class NotInitialization {
public static void main(String[] args){
//读取一个父类的静态字段,先触发父类初始化,在看是否触发子类初始化
System.out.println(SubClass.value);
}
}
运行结果:
SuperClass init!
123
解释:对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证,在虚拟机规范中并未明确规定,这点取决于虚拟机的具体实现。
被动引用的例子之三:
/**
* 被动使用类字段演示三:
* 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量类的初始化
*/
class ConstClass{
static{
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";//final修饰
}
/**
* 非主动使用类字段演示
*/
public class NotInitialization {
public static void main(String[] args){
System.out.println(ConstClass.HELLOWORLD);
}
}
运行结果:
hello world
上述代码运行之后,也没有输出“ConstClass init!”,这是因为虽然在Java源码中引用了ConstClass类中的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”存储到了常量池中,NotInitialization的引用实际都被转化为NotInitialization类对自身常量池的引用了。也就是说,实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。
值得一提:
接口也有初始化过程,这点与类是一致的,接口中不能使用“static{}”语句块,但编译器仍然会为接口生成“()”类构造器,用于初始化接口中所定义的成员变量。
接口与类真正有所区别的是:
当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父类接口的时候(如引用接口中定义的常量)才会初始化。
3.3、加载阶段Loading
- 通过一个类的全限定名【全类名】获取定义此类的二进制字节流【以流的方式来实现把此类的字节码文件加载进内存中】
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构【因为加载的类的信息要存在于我们内存中的方法区里面】
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据信息的访问入口【类的大的Class实例对象--反射】
补充:加载.class文件的方式:
-
从本地系统中直接加载
-
通过网络获取,典型场景:Web Applet
-
从zip压缩包中读取,成为日后jar、war格式的基础
-
运行时计算生成,使用最多的是:动态代理技术
-
由其他文件生成,典型场景:JSP应用从专有数据库中提取.class文件,比较少见
-
从加密文件中获取,典型的防Class文件被反编译的保护措施
3.4、链接阶段Linking
3.4.1、验证 Verify
目的在于确保待加载进来的Class文件的字节流中包含的信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
查看字节码文件工具:Binary Viewer查看
有效的字节码文件:开头为魔数CAFE BABE
如果出现不合法的字节码文件,那么将会验证不通过
同时我们可以也通过安装IDEA的插件jclass,来查看我们的Class文件,如下
备注:
也可以idea安装插件,如下
安装完成后,我们编译完一个class文件后,点击view即可显示我们安装的插件来查看字节码方法了
3.4.2、准备 Prepare
为类变量【静态变量】分配内存并且设置该类变量的默认初始值,即整数类型为零值,引用类型为null,这些变量所使用的内存都将在方法区中进行分配
public class HelloApp {
private static int a = 1; // 准备阶段a的值为0,在下个阶段,也就是初始化的时候才是1
public static void main(String[] args) {
System.out.println(a);
}
}
上面的变量a在准备阶段会赋初始值,但不是1,而是0。
这里不包含用final修饰的static篇【常量】,因为final在编译的时候就会分配了,准备阶段会显式初始化; 这里也不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
3.4.3、解析 Resolve
解析是将常量池内的符号引用转换为直接引用的过程。
事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
- 符号引用(Symbolic References)就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。 符号可以是任何形式的字面量,只要使用时无歧义地定位到目标即可。
- 直接引用(Direct Reference)就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。 如果有了直接引用。那引用的目标必定已经在内存中存在。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应Class文件中的常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等常量信息
3.5、初始化阶段
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。
到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说字节码)。
1、初始化阶段就是执行类构造器方法<clinit>()的过程。即在构造器方法中按照相应的指令为类中的静态变量按照代码顺序进行赋值操作
public class ClassInitTest {
private static int num = 1;
static {
num = 2;
number = 20;
System.out.println(num);
System.out.println(number); //报错,非法的前向引用。因为number这个变量我们声明在后面,不能调用,可以赋值
}
//这样是可以的,因为准备阶段就为所有的类变量赋了初始值0,然后按照顺序是20,然后这里覆盖变成10
private static int number = 10;
public static void main(String[] args) {
System.out.println(ClassInitTest.num); // 2
System.out.println(ClassInitTest.number); // 10
}
}
2、此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有<clinit>()方法
3、构造器方法中的指令按语句在源文件中出现的顺序执行。
4、<clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>()),比如以前我们说过,任何一个类声明以后,内部至少存在一个类的构造器,或显式提供的构造器,或默认有,这个默认的就是我们的这个<init>()
4、<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法以及执行完毕。
因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。看如下代码:
//关于涉及到父类时候的变量赋值过程
public class ClinitTest1 {
static class Father {
public static int A = 1;
static {
A = 2;
}
}
static class Son extends Father {
public static int b = A;
}
public static void main(String[] args) {
System.out.println(Son.b); //2
}
}
我们输出结果为 2,也就是说首先加载ClinitTest1类到内存中,然后调用main方法,然后执行Son的加载初始化,即它的<clinit>()方法,但是Son继承了Father,因此还需要先执行Father的加载初始化,父类初始化完以后A=2,同时将A赋给b赋值为2。我们通过反编译得到Father的加载过程,首先我们看到A原来的值被赋值成1,然后又被复制成2,最后返回
iconst_1
putstatic #2 <com/atguigu/java/chapter02/ClinitTest1$Father.A>
iconst_2
putstatic #2 <com/atguigu/java/chapter02/ClinitTest1$Father.A>
return
5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()()方法完毕。
public class DeadThreadTest {
public static void main(String[] args) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 线程t1开始");
new DeadThread(); //构造器创建类对象,执行<clinit>()方法
}, "t1").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 线程t2开始");
new DeadThread();
}, "t2").start();
}
}
class DeadThread {
static {
//如果不加上这个if 语句,编译器将提示“Initializer does not complete normally”并拒绝编译
if (true) { //代表一个线程在工作的时候,另外一个线程进不来,相当于同步加锁的过程
System.out.println(Thread.currentThread().getName() + "\t 初始化当前类");
while(true) {
}
}
}
}
上面的代码,输出结果为
线程t1开始
线程t2开始
线程t2 初始化当前类
从打印结果可以看出类初始化好以后,只能够执行一次初始化,即只会调用一次<clinit>()方法,所以只会出现一次xx线程初始化当前类, 也就是说有一条线程在死循环以模拟长时间操作,另外一条线程在阻塞等待。 这相当于我们类的<clinit>()方法在多线程下被同步加锁。
6、<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为类生成<clinit>()方法。
7、接口中不能使用静态语句块,但仍然有变量初始化的赋值操作。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化,另外,接口的实现类在初始化时也一样不会执行接口的()方法。
4、类加载器总体介绍
-
JVM支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader),即其他类加载器。
-
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器【比如我们的扩展类加载器、系统类加载器(应用程序类加载器)】
-
无论类加载器的类型如何划分,在Java程序中我们最常见的类加载器始终只有3个,如下所示:
5、获取不同的类加载器演示
public class ClassLoaderTest {
public static void main(String[] args) {
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// 获取其上层的:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);
// 试图获取引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent()
System.out.println(bootstrapClassLoader);//null
// 获取自定义类的加载器,是系统类加载器
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);
// 获取String类型的加载器
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1); //null
}
}
得到的结果
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1540e19d
null
sun.misc.Launcher$AppClassLoader@18b4aac2
null
从结果可以看出引导类加载器无法直接通过代码获取,同时目前用户自定义的ClassLoaderTest类所使用的加载器为系统类加载器。同时我们通过获取String类型的加载器,发现是null,那么说明String类型是通过引导类加载器进行加载的,也就是说Java的核心类库都是使用根加载器进行加载的。
6、启动类加载器(引导类加载器,Bootstrap ClassLoader)
-
这个类加载使用C/C++语言实现的,嵌套在JVM内部。
-
它用来加载Java的核心库(<JAVA_HOME>/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
-
并不继承自ava.lang.ClassLoader,没有父加载器。
-
启动类加载器无法被Java程序直接引用
-
加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
-
出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类,比如String就是java.lang.String包下的,不符合的类库即使放在lib目录也不会被加载
7、扩展类加载器(Extension ClassLoader)
-
Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。
-
派生于ClassLoader类
-
父类加载器为启动类加载器
-
从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR文件放在此目录下,这些jar文件里面的类也会自动由扩展类加载器加载。
8、应用程序类加载器(系统类加载器,AppClassLoader)
-
java语言编写,由sun.misc.LaunchersAppClassLoader实现
-
派生于ClassLoader类
-
父类加载器为扩展类加载器
-
它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
-
该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
-
通过classLoader.getSystemclassLoader( )方法可以获取到该类加载器
9、查看启动类加载器所能加载的目录
刚刚我们通过概念了解到了,启动类加载器只能够加载 java /lib目录下的class,我们通过下面代码验证一下
package com.atguigu.java;
import java.net.URL;
import java.security.Provider;
/**
* @author lemon
* @create 2021-12-13 13:31
* TO:一把青梅换了酒钱
*/
public class ClassLoaderTest1 {
public static void main(String[] args) {
System.out.println("**启动类加载器*****");
// 获取BootstrapClassLoader 能够加载的API的路径
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
System.out.println(url.toExternalForm());
}
// 从上面路径中,随意选择一个类,来看看他的类加载器是什么:
//得到的是null,说明是 根加载器(引导类)
ClassLoader classLoader = Provider.class.getClassLoader();
System.out.println(classLoader);//null
}
}
得到的结果:符合
**启动类加载器*****
file:/D:/java/jdk1.8.0_131/jre/lib/resources.jar
file:/D:/java/jdk1.8.0_131/jre/lib/rt.jar
file:/D:/java/jdk1.8.0_131/jre/lib/sunrsasign.jar
file:/D:/java/jdk1.8.0_131/jre/lib/jsse.jar
file:/D:/java/jdk1.8.0_131/jre/lib/jce.jar
file:/D:/java/jdk1.8.0_131/jre/lib/charsets.jar
file:/D:/java/jdk1.8.0_131/jre/lib/jfr.jar
file:/D:/java/jdk1.8.0_131/jre/classes
null
10、用户自定义类加载器
在JDK9之前的Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
为什么要自定义类加载器?
-
隔离、重载加载类
-
修改类加载的方式
-
扩展了.class文件的加载源
-
防止源码泄漏
用户自定义类加载器实现步骤:
-
开发人员可以通过继承抽象类Java.Lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
-
在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写1oadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖1oadclass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中
-
在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
11、关于ClassLoader
ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)
下面这几个方法都不是抽象方法
sun.misc.Launcher 它是一个java虚拟机的入口应用
获取ClassLoader的途径
- 获取当前ClassLoader:clazz.getClassLoader()
- 获取当前线程上下文的ClassLoader:Thread.currentThread().getContextClassLoader()
- 获取系统的ClassLoader:ClassLoader.getSystemClassLoader()
- 获取调用者的ClassLoader:DriverManager.getCallerClassLoader()