本文对应的思维导图:https://www.processon.com/view/link/614c8ccaf346fb125807a4e2
说明:
本文虽然借鉴了网络的部分内容,但是由于网络上内容基本都是摘抄JVM书来的,有的地方很晦涩难懂,尤其是SPI和线程上下文类加载器TCCL那里。
本文通过根据jdbc的驱动加载过程,跟踪
双亲委派源码、rt.jar、Class.forName、SPI、TCCL的执行顺序,分析了它们之间的关系。总结完收获很大,希望你也有所收获各小节没有标序号,但大章节序号是有的,慢慢看即可,我花了一整天整理学习。不说了,去睡觉了
类的使用流程:
- 是否加载了该类
- 没有加载:使用类加载器加载该类
- 加载了:链接–初始化—调用main方法
一、简述
类加载归纳为有三个阶段:
先编译java为class,启动程序后开始进行类装载
1) 、加载:
从文件系统或者网络中查找并加载类的二进制数据,利用二进制数据创建class对象
2) 、连接:
2.1) 、验证
2.1) 、验证 : 确保被加载的类的正确性,确保class文件的字节流中信息符合JVM的要求,不会危害JVM的安全,使得JVM免受恶意代码的攻击。主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
2.2) 、准备
2.2) 、准备:为类的static静态变量分配内存,并将其初始化为默认值,但是到达初始化之前类变量都没有初始化为真正的初始值。(这里不包含final修饰的static变量,因为final在编译时候就会分配了,准备阶段会显示初始化。)(这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着对象一起分配到java堆中)这些内存都将在方法区中进行分配。
2.3) 解析
2.3、解析:把类中的符号引用转换为直接引用,就是在类的常量池中寻找类、接口、字段和方法的符号引用,把这些符号引用替换成直接引用的过程。虚拟机规范之中并未规定解析阶段发生的具体时间,只要求了在执行anewarray、checkcast, getfield, getstatic, instanceof, invokeinterface, invokespecial, invokestatic、invokevirtual, multianewarray、new、 putfield和 putstatIc这13个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池的 CONSTANT Class info、 CONSTANT_Fieldref_info、 CONSTANT_Methodref_info及 CONSTANT_InterfaceMethodref_info四种常量类型。
- 符号引用( Symbolic References):符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,无用的且标并不一定已经加载到内存中。
- 符号引用在 Class文件中它以 CONSTANT_Class_info、CONSTANT_Fieldref_info、 CONSTANT_Methodref_info等类型的常量出现
- 直接引用 (Direct Referenc):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
2.3.1、解析阶段:
对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可能会对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的都是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常。
下面将讲解这四种引用的解析过程。
- 1.类或接口的解析:
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于无数据验证、字节码验证的需要,又将可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败
2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[ Ljava. ang Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“ java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象
3)如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认C是否具备对D的访问权限。如果发现不具备访问权限,将抛出 java. lang. IllegalAccessError异常
- 2.字段解析:
要解析一个未被解析过的字段符号引用,首先将会对字段表内 class index项中索引的CONSTANT_Class_info符号引用进行解解析,也就是字段所属的类成接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索
1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返这个字段的直接引用,查找结束
2)否则,如果在C中实现了接口,将会按照继承关系从上往下递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
3)否则,如果C不是 java. lang Object的话,将会按照继承关系从上往下递归搜索其父类)如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
4)否则,查找失败,抛出 java. lang NoSuch Field Error异常如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出 java. lang. IllegalAccess Error异常
在实际应用中,虚拟机的编译器实现可能会比上述规范要求得更加严格一些,如果有一个同名字段同时出现在C的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译。在代码清单7-4中,如果注释了Sub类中的“ public static int A=4;”,接口与父类同时存在字段A,那编译器将提示“ The field Sub.A is ambiguous" ,并且会拒绝编译这段代码
- 3.类方法解析
类方法解析的第一个步骤与字段解析一样,也是需要先解析出类方法表的 class index项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索:
1)类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现 class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
2)如果通过了第(1)步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError异常
5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证;如果发现不具备对此方法的访问权限,将抛出 java .lang.IllegalAccessError异常
- 4.接口方法解析
接口方法也是需要先解析出接口方法表的 class index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:
1)与类方法解析相反,如果在接口方法表中发现 class index中的索引C是个类而不是接口,那就直接抛出java.lang. IncompatibleClassChangeError异常。
2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
3)否则,在接口C的父接口中递归查找,直到 javalang Object类(查找范围会包括 Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常
由于接口中的所有方法都默认是 public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang. IllegalAccess Error异常。
3) 、初始化:
为类的静态变量赋予正确的初始值。为新的对象分配内存,为实例变量赋默认值,为实例变量赋正确的初始值。初始化阶段就是指向类构造器方法<clinit>()【意思是class init】的过程,此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。<clinit>()不同于类的构造器,若该类具有父类,JVM会保证子类的<clinit>执行前,父类的<clinit>已经执行完毕。JVM必须保证一个类的<clinit>()方法在多线程下被同步加锁。
类加载最后阶段,若该类具有超类,则对其进行初始化,执行静态初始化器和静态初始化成员变量(如前面只初始化了默认值的static变量将会在这个阶段赋值,成员变量也将被初始化)。
3.1、<clinit>()是由编译器自动收集类中的所有类变量的赋值动作(static变量)和静态语句块(static代码块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
3.2、 <clinit>()方法与类的构造函数(即实例构造器<init>方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object
3.3、由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语块要优先于子类的变量赋值操作,如代码中,字段B的值将会是2而不是1
static class Parent{
public static int A=1;
static{
A=2;
}
}
public class Sub extends Parent{
public static int B=A;
}
public static void main(String[] args){
Sub.B.sout;
}
3.4、 <clinit>()方法对于类和接口来说并不是必须的,如果一个类中没有静态代码块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法方法
3.5、 接口中不能使用静态代码块,但如果有变量初始化的赋值操作,接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有父接口定义的变量被使用时,父接口才回被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法
3.6、 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果一个类的<clinit>()方法有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
实例初始化是在实例的构造函数中,而他相应的父类是调用super()完成的,
- 如果没有显示写super(),那么将加在第一句。
- 当父类没有无参构造函数时,在子类构造方法中必须显示指定,如super(“hello”)
有父类有无参构造时,super可以省略,此时子类可以使用this调用构造方法,this在构造方法的作用是调用子类的其他构造方法;父类没有无参构造时,子类不能使用this调用构造方法
非静态成员的赋值,是在自己的构造调用之后,并且是在自己的构造调用完父类的构造super之后,
在非静态成员全部赋值完成,才会继续执行自己构造内,剩余代码。
以final关键字为例先体会一下类加载流程
// 常量都是用final来修饰的,所以只要在包含它类实例化对象完成之前初始化就行了,什么都不影响。但是如果前面加个static表明类装载时这个常量必须是有个状态的(被赋予了值,初始化了),所以如果用static就必须类加载时初始化。
// 只被final关键字修饰的常量,可以在其类加载时就初始化,也可以到类的构造方法里面再对它进行初始化:例如
class A{
final int i;//或者final int i=10; // 有没有值无所谓,实例化的构造方法完成之前有值就可
public A(){
i=10;
}
}
//用static和final关键字同时修饰的常量就必须得在定义时初始化,例如:
class A{
static final int i=10;//编译时候就赋值了,它是常量
}
// 基本类型,是值不能被改变 //引用类型,是地址值不能被改变,对象中的属性可以改变
public static void method(final int x) {
//此处的final修饰的 x随着方法使用完毕后回收,当再次调用时,重新分配空间
System.out.println(x);
}
说明:
- 在java代码中,类的加载、连接和初始化过程都是在程序运行期间完成的。(类从磁盘加载到内存中经历的三个阶段)
- 类从磁盘上加载到内存中要经历五个阶段:加载、连接、初始化、使用、卸载
二、类加载器的分类
类加载定义:将类的.class文件中的二进制数据(字节流)读入到内存中,将其放在运行时数据区的方法区内,然后在内存中创建一个java.lang.Class对象(规范并未说明Class对象位于哪里,HotSpot虚拟机将其放在方法区中)用来封装内在方法区内的数据结构。
类的加载时机:类并不需要等到某个类被“首次主动使用”时再加载它:JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类才报告错误(LinkageError错误),如果这个类没有被程序主动使用,那么类加载器就不会报告错误。
将二进制字节流锁代表的静态存储结构转化为方法区的运行时数据结构
注:
-
class文件在文件开头有特定的文件标示
-
ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定
-
加载.class文件的方式
(1)从本地系统中直接加载
(2)通过网络下载.class文件
(3)从zip,jar等归档文件中加载.class文件
(4)从专用数据库中提取.class文件
(5)将java源文件动态编译为.class文件
① 启动类加载器
① 启动类加载器/根加载器/引导类加载器(Bootstrap):
- C++编写。默认加载路径
$JAVAHOME/$jre/lib/rt.jar,或者被-Xbootclasspath参数所指定的路径。里面有如rt.jar/sun/misc/Launcher.class,Object.class等。该加载器没有父加载器,它负责加载虚拟机中的核心类库。根类加载器从系统属性sun.boot.class.path所指定的目录中加载类库。类加载器的实现依赖于底层操作系统,属于虚拟机的实现的一部分,它并没有集成java.lang.ClassLoader类。出于安全考虑,根加载器只加载包名为java,javax,sun等开头的类,意味着及时将你自己的jar放到该目录下也不一定被加载,因为在JVM内已经按照文件名识别。
//通过java对象.object.getClass().getClassLoader();获取该类的载器
Object object = new Object(); // object类的类加载器是根加载器
object.getClass().getClassLoader();//null。Bootstrap根加载器是c++写的,java查不出来
try {
object.getClass().getClassLoader().getParent();//报错,根加载器是最初级的了
} catch (Exception e) {
System.out.println(object.getClass().getClassLoader() + "没有父加载器了");
}
//-----------------------------------------
Object object2 = new Main2();//自己写的类
System.out.println(object2.getClass().getClassLoader());//sun.misc.Launcher$AppClassLoader@18b4aac2
//自定义类默认的加载器是应用加载器AppClassloader//sun.misc.launcher$AppClassLoader$18b4aac2。位于rt.jar包中
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();//获取系统加载器 /
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(systemClassLoader.getParent());// sun.misc.Launcher$ExtClassLoader@8efb846
System.out.println(systemClassLoader.getParent().getParent());//null
//获取根加载器所能加载的路径
URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
for (URL element : urLs) {
System.out.println(element.toExternalForm());
}
/*
file:/E:/Java/jdk1.8.0_231/jre/lib/resources.jar
file:/E:/Java/jdk1.8.0_231/jre/lib/rt.jar
file:/E:/Java/jdk1.8.0_231/jre/lib/sunrsasign.jar
file:/E:/Java/jdk1.8.0_231/jre/lib/jsse.jar
file:/E:/Java/jdk1.8.0_231/jre/lib/jce.jar
file:/E:/Java/jdk1.8.0_231/jre/lib/charsets.jar
file:/E:/Java/jdk1.8.0_231/jre/lib/jfr.jar
file:/E:/Java/jdk1.8.0_231/jre/classes
*/
System.out.println(System.getProperty("sun.boot.class.path"));//获取根加载器路径 // 结果和上面的遍历结果一致
② 扩展类加载器
- Java编写 ,由sun.misc.Launcher$ExtClassLoader(意思是说ExtClassLoader是Launcher的静态内部类)
- 默认加载路径
JDK安装目录/jre/lib/ext/*.jar(或通过-Djava.ext.dirs系统属性重新指定)如果用户创建的JAR放在此目录下,也会由拓展类加载器自动加载。
类结构
继承了ClassLoader,并且是Launcher的静态内部类
public class Launcher {
static class ExtClassLoader extends URLClassLoader {
public class URLClassLoader extends SecureClassLoader implements Closeable {
public class SecureClassLoader extends ClassLoader {
加载路径
默认加载路径JDK安装目录/jre/lib/ext/*.jar(或通过-Djava.ext.dirs系统属性重新指定)如果用户创建的JAR放在此目录下,也会由拓展类加载器自动加载。
System.out.println(System.getProperty("java.ext.dirs"));//获取扩展类加载器路径
// E:\Java\jdk1.8.0_231\jre\lib\ext;C:\WINDOWS\Sun\Java\lib\ext
③ 应用程序类加载器
应用程序类加载器(AppClassLoader,也叫系统类加载器):
- Java编写,它的父加载器为扩展类加载器,他是用户自定义的类加载器的默认父加载器。sun.misc.Launcher$AppClassLoader,也是静态内部类
- 加载当前应用的classpath的所有类。默认加载路径为:
环境变量$classpath(可以通过-Djava.class.path重新指定)。 - 可以通过
ClassLoader.getSystemClassLoader()获取到应用类加载器。
类结构
继承了ClassLoader,并且是Launcher的静态内部类
//
public class Launcher {
static class AppClassLoader extends URLClassLoader {
public class URLClassLoader extends SecureClassLoader implements Closeable {
public class SecureClassLoader extends ClassLoader {
加载路径
System.out.println(System.getProperty("java.class.path"));//获取应用类加载器路径
/*
E:\Java\jdk1.8.0_231\jre\lib\charsets.jar;E:\Java\jdk1.8.0_231\jre\lib\deploy.jar;。。。E:\Java\jdk1.8.0_231\jre\lib\management-agent.jar;E:\Java\jdk1.8.0_231\jre\lib\plugin.jar;E:\Java\jdk1.8.0_231\jre\lib\resources.jar;E:\Java\jdk1.8.0_231\jre\lib\rt.jar;F:\springbootTest\testBoot\target\classes;D:\MavenRepository\org\springframework\boot\spring-boot-starter-web\2.3.1.RELEASE\spring-boot-starter-web-2.3.1.RELEASE.jar;D:\MavenRepository\org\springframework\boot\spring-boot-starter\2.3.1.RELEASE\spring-boot-starter-2.3.1.RELEASE.jar;
。。。省略
D:\MavenRepository\org\springframework\spring-jcl\5.2.7.RELEASE\spring-jcl-5.2.7.RELEASE.jar;D:\MavenRepository\org\projectlombok\lombok\1.18.12\lombok-1.18.12.jar;E:\JetBrains\IntelliJ IDEA 2019.3.3\lib\idea_rt.jar
*/
System.out.println(System.getProperty("sun.boot.class.path"));//打印跟加载路径
// 输出样式为E:\Java\jdk1.8.0_231\jre\lib\resources.jar;E:\Java\jdk1.8.0_231\jre\lib\rt.jar;E:\Java\jdk1.8.0_231\jre\lib\sunrsasign.jar;E:\Java\jdk1.8.0_231\jre\lib\jsse.jar;E:\Java\jdk1.8.0_231\jre\lib\jce.jar;E:\Java\jdk1.8.0_231\jre\lib\charsets.jar;E:\Java\jdk1.8.0_231\jre\lib

本文详细介绍了Java的类加载过程,包括加载、连接(验证、准备、解析)、初始化阶段,强调了双亲委派模型以及如何通过自定义类加载器打破这一模型。还探讨了线程上下文类加载器(TCCL)在JDBC和SPI中的应用,以及Tomcat和Spring中的类加载策略。最后,文章提供了类加载器的源码分析和实例,加深了对类加载机制的理解。

最低0.47元/天 解锁文章
10万+





