JVM的艺术—类加载器篇(三)
JVM的艺术—类加载器篇(三)
引言
今天我们继续来深入的剖析类加载器的内容。上篇文章我们讲解了类加载器的双亲委托模型、全盘委托机制、以及类加载器双亲委托模型的优点、缺点等内容,没看过的小伙伴请加关注。在公众号内可以找到,jvm的艺术连载篇。欢迎各位小伙伴儿的持续关注,同时也感谢各位读者一直以来的支持,本人会一直坚持原创、独立创作,给各位读者带来真正的、实用的干货。也会把文章写的通俗易懂,从人的思维、从程序员的思维中,不断的改善写作技巧。争取让每个人都能花最少的学习成本,读懂最好的文章。谢谢。
由于被一些私事耽误了,文章已经大概有一个月的时间没有更新了,在这里给大家真诚的道个歉,上一篇文章,我们提到了线程上下文类加载器,当时举了一个例子说来说明,类加载器双亲委托模型的弊端。今天我们首先来说明白线程上下文类加载这个东西到底是什么,为什么会有这个东西的出现,它帮我们到底解决了什么问题。接下来我们一点点的来分析。从案例入手。
正式介绍线程的上下文类加载器之前需要介绍一些理论性的东东
当前类加载器(Current ClassLoader):每一个类都会使用自己的类加载器(既加载自身的类加载器)来去加载其它类(指的是所依赖的类),如果ClassX引用了ClassY,那么ClassX的类加载器就会加载ClassY(前提是ClassY尚未被加载)。
线程上下文类加载器(Context ClassLoader):线程上下文类加载器是从JDK1.2开始引入的,类Thread中的getContextClassLoader()与setContextClassLoader(ClassLoader cl)分别用来获取和设置上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。
Java应用运行时初始线程的上下文类加载器是系统类加载器
为什么使用线程上下文类加载?
为什么使用线程上下文类加载?上篇文章我也简单的提到了。线程上下文类加载的设计初衷,原因就在于我们JAVA语言的SPI机制,我又提供了一张图,希望下面这张图可以全面的阐述上下文类加载器的含义。
线程上下文类加载器的重要性
我们在使用JDBC操作数据库时会如下进行编写:
Class.forName("com.mysql.driver.Driver");
Connection conn = Driver.getConnection();
Statement st = conn.getStatement();
JDBC是一个标准,这就说明使用到的Connection和Statement都是内置在JDK当中的标准,都是抽象接口,而且是位于rt.jar中,其实现肯定是由不同的数据库厂商来实现,那么问题就来了:这些标准都是由根类加载器所加载的,但是具体的实现是由具体的厂商来做的,那肯定是需要将厂商的jar放到工程的classpath当中来进行使用,很显然厂商的这些类是没办法由启动类加载器去加载,会由应用类加载器去加载,而根据“父类加载器所加载的类或接口是看不到子类加载器所加载的类或接口,而子类加载器所加载的类或接口是能够看到父类加载器加载的类或接口的”这一原则,那么会导致这样一个局面:比如说java.sql包下面的某个类会由启动类加载器去加载,该类有可能会要访问具体的实现类,但具体实现类是由应用类加载器所加载的,java.sql类加载器是根据看不到具体实现类加载器所加载的类的,这就是基于双亲委托模型所出现的一个非常致命的问题,这种问题不仅是在JDBC中会出现,在JNDI、xml解析等SPI(Service Provider Interface)场景下都会出现的
所以这里总结一下:父ClassLoader可以使用当前线程Thread.currentThread().getContextLoader()所指定的ClassLoader加载的类,这就改变了父ClassLoader不能使用子ClassLoader或者其它没有直接父子关系的ClassLoader加载的类的情况,既改变了双亲委托模型。线程上下文类加载器就是当前线程的Current ClassLoader。在双亲委托模型下,类加载是由下至上的,既下层的类加载器会委托上层进行加载。但是对于SPI来说,有些接口是Java核心库所提供的,而Java核心库是由启动类加载器来加载的,而这些接口的实现却来自于不同的jar包(厂商提供)。Java的启动类加载器是不会加载其它来源的jar包,这样传统的双亲委托模型就无法满足SPI的要求。而通过给当前线程设置上下文类加载器,就可以由设置的上下文类加载器来实现对于接口实现类的加载。
下面以JDBC的这种SPI场景用图来更具体的描述一下:
很明显JDBC会去引用JDBCImpl的具体厂商的实现,而JDBC标准是由根类加载器所加载,那对于具体实现厂商的类也会用根类加载器去加载,而由于它们是处于工程中的classPath当中,由系统类加载器去加载,很显然是没办法由根类加载器去加载的,为了解决这个问题,线程的上下文类加载器就发挥作用了。
分析:
由上面的理论可知:Java应用运行时初始线程的上下文类加载器是系统类加载器
那思考一下:为什么默认的线程上下文类加载器就是系统类加载器呢?肯定是在某个地方给设置了,其实它是在Launcher中进行设置的,如下:
1、线程上下文类加载器的一般使用模式(获取 - 使用 - 还原)
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();//获取
try{
ClassLoader targetTccl = xxx;//要设置的上下文类记载器
Thread.currentThread().setContextClassLoader(targetTccl);//设置
myMethod();//使用
} finally {
Thread.currentThread().setContextClassLoader(classLoader);//还原
}
2、如果一个类由类加载器A加载,那么这个类的依赖类也是由相同的类加载器加载的(如果该依赖类之前没有被加载过的话),ContextClassLoader的作用就是为破坏Java的类加载委托机制。
3、当高层提供了统一的接口让低层来实现,同时又要在高层加载(或实例化)低层的类时,就必须要通过线程上下文类加载器来帮助高层的ClassLoader找到并加载该类。
Thread.currentThread().getContextClassLoader();//获取
Thread.currentThread().setContextClassLoader(targetTccl);//设置
至此线程上下文类加载器就介绍到这里。
类加载的过程
其实一个类从加载到使用是要经历很多个过程的,下面我们来详细的说说,一个类从加载到初始化的这个过程,然而还有哪些坑不为人知。
下面给出一张图:
固定的类加载执行顺序: 加载 验证 准备 初始化 卸载 的执行顺序是一定的 为什么解析过程没有在这个执行顺序中?(接下来分析)
什么时候触发类加载不一定,但是类的初始化如下四种情况就要求一定初始化。 但是初始化之前 就一定会执行 加载 验证 准备 三个阶段。
触发类加载的过程(由初始化过程引起的类加载)
1):使用new 关键字 获取一个静态属性 设置一个静态属性 调用一个静态方法。
int myValue = SuperClass.value;会导致父类初始化,但是不会导致子类初始化
SuperClass.Value = 3 ; 会导致父类初始化,不会导致子类初始化。
SubClass.staticMethod(); 先初始化父类 在初始化子类
SubClass sc = new SubClass(); 先初始化父类 再初始化子类
2):使用反射的时候,若发现类还没有初始化,就会进行初始化
Class clazz = Class.forName("com.hnnd.classloader.SubClass");
3):在初始化一个类的时,若发现其父类没有初始化,就会先初始化父类
SubClass.staticMethod(); 先初始化父类 在初始化子类
4):启动虚拟机的时候,需要加载包含main方法的类.
class SuperClass{
public static int value = 5;
static {
System.out.println("Superclass ...... init........");
}
}
class SubClass extends SuperClass {
static {
System.out.println("subClass********************init");
}
public static void staticMethod(){
System.out.println("superclass value"+SubClass.value);
}
}
下面我们对类的加载、连接、初始化这几个过程逐一的解释:
1:加载
1.1)根据全类名获取到对应类的字节码流(字节流的来源 class 文件,网络文件,还有反射的Proxygeneraotor.generaotorProxyClass)
1.2)把字节流中的静态数据结构加载到方法区中的运行时数据结构
1.3)在内存中生成java.lang.Class对象,可以通过该对象来操作方法区中的数据结构(通过反射)
2:验证
文件格式的验证: 验证class文件开头的0XCAFFBASE 开头
验证主次版本号是否在当前的虚拟机的范围之类
检测jvm不支持的常量类型
元数据的校验:
验证本类是否有父类
验证是否继承了不允许继承的类(final)修饰的类
验证本类不是抽象类的时候,是否实现了所有的接口和父类的接口
字节码验证:验证跳转指令跳转到 方法以外的指令.
验证类型转换是否为有效的, 比如子类对象赋值父类的引用是可以的,但是把父类对象赋值给子类引用是危险的
总而言之:字节码验证通过,并不能说明该字节码一定没有问题,但是字节码验证不通过。那么该字节码文件一定是有问题:。
符号引用的验证(发生在解析的过程中):
通过字符串描述的全类名是否能找到对应的类。
指定类中是否包含字段描述符,以及简单的字段和方法名称。
3:准备:为类变量分配内存以及设置初始值。
比如public static int value = 123;
在准备的过程中 value=0 而不是123 ,当执行类的初始化的方法的时候,value=123
若是一个静态常量
public static final int value = 9; 那么在准备的过程中value为9.
4:解析 :把符号引用替换成直接引用
符号引用分类:
CONSTANT_Class_info 类或者接口的符号引用
CONSTANT_Fieldref_info 字段的符号引用
CONSTANT_Methodref_info 方法的符号引用
CONSTANT_intfaceMethodref_info- 接口中方法的符号引用
CONSTANT_NameAndType_info 子类或者方法的符号引用.
CONSTANT_MethodHandle_Info 方法句柄
CONSTANT_InvokeDynamic_Info 动态调用
直接引用:
指向对象的指针
相对偏移量
操作句柄
5:初始化:类的初始化时类加载的最后一步:执行类的构造器,为所有的类变量进行赋值(编译器生成CLInit<>)
类构造器是什么?: 类构造器是编译器按照Java源文件总类变量和静态代码块出现的顺序来决定
静态语句只能访问定义在静态语句之前的类变量,在其后的静态变量能赋值 但是不能访问。
父类中的静态代码块优先于子类静态代码块执行。
若类中没有静态代码块也没有静态类变量的话,那么编译器就不会生成 Clint<>类构造器的方法。
public class TestClassInit {
public static void main(String[] args) {
System.out.println(SubClass.sub_before_v);
}
}
class SubClass extends SuperClass{
public static int sub_before_v = 5;
static {
sub_before_v = 10;
System.out.println("subclass init.......");
sub_after_v=0;
//抛错,static代码块中的代码只能赋值后面的类变量 但是不能访问。
sub_before_v = sub_after_v;
}
public static int sub_after_v = 10;
}
class SuperClass {
public static int super_before_v = 5;
static{
System.out.println("superclass init......");
}
public static int super_after_v = 10;
}
下面我们通过一系列的案例来说验证上面所说的。先做个小的总结。
类的初始化需要对类进行主动使用,下面总结了几点,都可以看做是对类的主动使用:
1:创建类的实例。
2:访问某个类或者接口中的静态变量,或者对其赋值。
3:访问某个类的静态方法。
4:反射。
5:初始化一个类的子类。
6:包含main方法的类。
7:jdk1.7开始提供动态语言的支持。
除了以上7种情况,都是被动使用,都不会导致类被初始化。
根据以上结论,我们来写几个案例,针对每种情况进行一下证明。
结论一:
静态常量初始化过程是,在jvm连接之后,静态常量的初始化,是由调用这个静态常量方法所在的类的常量池中被保存,此时,被调用的静态常量所在的类的class文件就可以被删除,即使被删除,该常量依然有效。调用某个类的静态常量不能初始化该类。
代码:
package com.jdyun.jvm001;
public class TestClass03 {
public static void main(String[] args) {
System.out.println(Pet1.a);
}
}
class Pet1{
public static final int a = 10;
static {
System.out.println("我是Pet1,我被初始化了");
}
}
运行结果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=64451:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.TestClass03
10
Process finished with exit code 0
从上面这个案例可知,一个类调用另一个类的常量不会导致一个类的初始化。
结论二:
- 此处声明的静态常量,按照之前的理解是静态常量被调用不会初始化该静态常量所在的类
- 但是此处当静态常量的值是一个引用类型的时候,这个时候该静态常量所在的类就会被初始化
- 故此会先打印我被初始化了,然后在打印a的随机值
代码:
package com.jdyun.jvm001;
import java.util.UUID;
public class TestClass03 {
public static void main(String[] args) {
System.out.println(Pet1.a);
}
}
class Pet1{
public static final String a = UUID.randomUUID().toString();
static{
System.out.println("我被初始化了");
}
}
运行结果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=50237:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.TestClass03
我被初始化了
e5b56749-5a97-405f-9fe9-dfe4211bc0ce
Process finished with exit code 0
结论三:
静态变量初始化与静态常量初始化不同,静态变量初始化是在初始化阶段被赋予真实的值比如int a = 2,那么2会被真正的赋值给a。
如果某个类调用了该类的静态变量,那么静态变量所在的类就会被视为被主动调用了。那么该类就会被初始化。
该类如果有静态代码块儿那么静态代码块儿的优先级高于静态变量。
如果该静态变量所在的类中有父类,那么会优先初始化父类。
package com.jdyun.jvm001;
import java.util.Random;
import java.util.UUID;
public class TestClass03 {
public static void main(String[] args) {
System.out.println(Dog3.a);
}
}
class Dog3 extends Pet1{
public static final int a = new Random().nextInt();
static {
System.out.println("我是Pet1,我是父类,我被最先加载了");
}
}
class Pet1{
static{
System.out.println("我被初始化了");
}
}
运行结果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=64951:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.TestClass03
我被初始化了
我是Pet1,我是父类,我被最先加载了
-1203457101
Process finished with exit code 0
结论四:
验证初始化次数,只会被初始化一次。
package com.jdyun.jvm001;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.UUID;
public class MyTest02 extends ClassLoader{
public static void main(String[] args) throws ClassNotFoundException {
//1,验证初始化次数
for(int i=0;i<50;i++){
Test01 test01 = new Test01();
}
}
}
class Test01{
static{
System.out.println("我被初始化了");
}
}
运行结果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=65340:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.MyTest02
我被初始化了
Process finished with exit code 0
结论五:
接口的初始化,子接口的初始化不会导致父接口的初始化,如果可以导致父接口的初始化,那么Test01类中的静态代码块儿就会被打印。很显然结果来看,Test01
中的静态代码块儿没有被打印,所以,接口的初始化中,子接口的初始化,不会导致父接口的初始化。
package com.jdyun.jvm001;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.UUID;
public class MyTest02 extends ClassLoader{
public static void main(String[] args) throws ClassNotFoundException {
//2,接口初始化,子接口的初始化不会导致父接口的初始化
System.out.println(MyChild.b);
/* System.out.println(MyParent.test01);
System.out.println(MyChild.test001);*/
//3,反射初始化类
//Class.forName("com.jdyun.jvm001.Test01");
//4,创建数组不会导致类的初始化
//Test01[] test01 = new Test01[1];
//5,静态变量赋值
//System.out.println(MyChild.b);
//Class clesses = String.class;
}
}
class Test01{
static{
System.out.println("Test01被初始化了");
}
}
interface MyParent{
Test01 test01 = new Test01();
public static final String a="5";
}
interface MyChild extends MyParent {
public static Integer b= UUID.randomUUID().hashCode();
}
"C:\Program Files\Java\jdk-11.0. 2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=49632:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.MyTest02
-221561202
Process finished with exit code 0
结论六:
创建一个数组,不会导致类的初始化。
package com.jdyun.jvm001;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.UUID;
public class MyTest02 extends ClassLoader{
public static void main(String[] args) throws ClassNotFoundException {
//4,创建数组不会导致类的初始化
Test01[] test01 = new Test01[1];
}
}
class Test01{
static{
System.out.println("Test01被初始化了");
}
}
运行结果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=50058:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.MyTest02
Process finished with exit code 0
结论七:
此处声明的静态常量,按照之前的理解是静态常量被调用不会初始化该静态常量所在的类
但是此处当静态常量的值是一个引用类型的时候,这个时候该静态常量所在的类就会被初始化
故此会先打印我被初始化了,然后在打印a的随机值
package com.jdyun.jvm07;
import java.util.Random;
import java.util.UUID;
/**
* 此处声明的静态常量,按照之前的理解是静态常量被调用不会初始化该静态常量所在的类
* 但是此处当静态常量的值是一个引用类型的时候,这个时候该静态常量所在的类就会被初始化
* 故此会先打印我被初始化了,然后在打印a的随机值
*/
public class Test {
public static void main(String[] args) {
System.out.println(Pet.a);
}
}
class Pet{
public static final String a = UUID.randomUUID().toString();
static{
System.out.println("我被初始化了");
}
}
运行结果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=50995:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm07.Test
我被初始化了
3febaad7-90fe-4d7f-be1c-62b70b9f41cc
Process finished with exit code 0
结论八:
对子接口静态常量调用时,父接口没有被加载也并没有被初始化。当我们有两个接口,父子接口,然后在子接口中声明一个静态变量,此时对子接口中的静态变量进行主动调用,此时父接口没有被初始化,也没有被加载。(删除父接口中的class)
package com.jdyun.jvm8;
import java.util.Random;
public class Test {
public static void main(String[] args) {
System.out.println(MyChild.b);
}
}
interface MyParent{
public static final String a="5";
}
interface MyChild extends MyParent{
public static Integer b= 1;
}
运行结果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=51297:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm8.Test
1
Process finished with exit code 0
结论九:
接口中的变量赋予引用初始值会初始化子接口。
public class Test {
public static void main(String[] args) {
System.out.println(MyChild.b);
}
}
interface MyParent{
public static String a=5;
}
interface MyChild extends MyParent{
Integer b= new Random().nextInt(2);
}
小结:
1,假如这个类还没有被加载和链接就先进行加载和链接。
2,假如类存在直接引用父类,并且这个父类还没有被初始化,就先初始化父类。
3,如果类中存在初始化语句,就依次执行这些初始化语句。
命名空间相关结论总结:
1:同一个命名空间下的Class对象相同(hasCode相同),不同命名空间下不同。
2:同一个类加载器加载的类处于一个命名空间。
3:不同的类加载器实例加载的类命名空间不同。
4:每一个类加载器都有自己的命名空间。
5:子类加载器加载的类能见父类加载器加载的类。
6:父类加载器不可见子类类加载加载的类。
至此:jvm艺术类加载器篇就说这么多,如果jvm的艺术三篇文章,各位小伙儿伴都看懂了。并且掌握了。那么恭喜你,至少在面试的时候,考类加载器应该不会丢分。后面的文章还是针对jvm的。将会开启一个新的篇章。主要针对,jvm的内存模型、对象模型、以及jvm的堆栈、调优、垃圾回收等领域进行细致的讲解。欢迎各位小伙伴儿持续关注更新。也感谢大家一直以来的支持和关注。笔者会继续努力,深度学习并且拿出高质量的文章来回馈广大的读者。谢谢!!!
更多内容请关注我的公众号:奇客时间