深入理解JVM(二)一一类加载器子系统

1,555 阅读19分钟

类加载器子系统

前言

通过加载Class文件,经过链接,初始化等步骤使class文件内容解析成jvm能识别能运行的格式。

类加载器子系统作用

  1. 类加载子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识;
  2. ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定
  3. 加载的类信息存放于一块成为方法区的内存空间。除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

类加载的生命周期

类生命周期.png

类加载器子系统阶段图

类加载子系统.png

1. 加载-loading

JVM对class文件是按需加载,需要才加载到内存生成class对象,采用双亲委派模型加载

  1. 通过一个类的全限定类名获取定义此类的二进制字节流;

  2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据,即instanceKlass实例,存放在方法区;

  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口,即instanceMirrorKlass对象。

启动类加载器(引导类加载器)-BootstrapClassLoader

  1. 使用C++实现,JVM内部实现

  2. 加载核心库(jre/lib/rt.jar,resources.jar,sum.boot.class.path路径下的类库)

  3. 不继承java.lang.ClassLoader,没有父加载器

  4. 出于安全考虑,只加载java,javax,sum等全限定类名开头的类

  5. 我们不能获取到启动类加载器,为null

扩展类加载器-ExtClassLoader

  1. java语言实现,

  2. 加载扩展类库(jre/lib/ext包)如果用户创建的jar包放在此目录,也会加载;或加载java.ext.dirs系统属性所指定的目录

  3. 派生于ClassLoader,父类加载器为启动类加载器

应用类加载器(系统类加载器)-AppClassLoader

  1. java语言实现

  2. 加载环境变量classpath或系统属性java.class.path指定路径下的类库

  3. 派生于ClassLoader,父类加载器为扩展类加载器

自定义类加载器

  1. java语言实现

  2. 通过继承抽象类java.lang.ClassLoader类,实现自定义类加载器

  3. 通过重写findclass方法,不建议覆盖loadclass方法

  4. 没有复杂逻辑的自定义类加载器,可以继承URLClassLoader类,避免自己去编写findclass方法和获取字节码流的方式,编写更加简洁

双亲委派机制

定义

如果一个类加载器收到了加载某个类的请求,则该类加载器并不会去加载该类,而是把这个请求委派给父类加载器,每一个层次的类加载器都是如此,

因此所有的类加载请求最终都会传送到顶端的启动类加载器;

只有当父类加载器在其搜索范围内无法找到所需的类,并将该结果反馈给子类加载器,子类加载器会尝试去自己加载。

image.png

作用

  1. 避免类重复加载

  2. 保护程序安全,防止核心API被篡改(沙箱安全机制)

打破双亲委派机制场景

SPI(JDBC应用)

JDBC的Driver接口定义在JDK中,其实现由各个数据库的服务商来提供,比如MySQL驱动包。DriverManager 类中要加载各个实现了Driver接口的类,然后进行管理,但是DriverManager位于 JAVA_HOME中jre/lib/rt.jar 包,由BootStrap类加载器加载,而其Driver接口的实现类是位于服务商提供的 Jar 包,根据类加载机制,当被装载的类引用了另外一个类的时候,虚拟机就会使用装载第一个类的类装载器装载被引用的类。也就是说BootStrap类加载器还要去加载jar包中的Driver接口的实现类。

我们知道,BootStrap类加载器默认只负责加载 JAVA_HOME中jre/lib/rt.jar 里所有的class,所以需要由子类加载器去加载Driver实现,这就破坏了双亲委派机制。

这个子类加载器是通过 Thread.currentThread().getContextClassLoader() 得到的线程上下文加载器。在 sun.misc.Launcher 初始化的时候,会获取AppClassLoader,然后将其设置为上下文类加载器,所以线程上下文类加载器默认情况下就是应用类加载器。

(理解:其实已经委托了父类加载了,但是需要第三方类,通过线程上下文得到了应用类加载去,再去加载,也算是双亲委派机制吧)

image.png

Tomcat

每个Tomcat的webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。 出于下面三类目的:

  1. 对于各个 webapp中的 class和 lib,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况,而对于许多应用,需要有共享的lib以便不浪费资源。

  2. 与 jvm一样的安全性问题。使用单独的 classloader去装载 tomcat自身的类库,以免其他恶意或无意的破坏;

  3. 热部署。相信大家一定为 tomcat修改文件不用重启就自动重新装载类库而惊叹吧。

image.png

橙色部分还是和原来一样, 采用双亲委派机制. 而黄色部分是tomcat第一部分自定义的类加载器, 这部分主要是加载tomcat包中的类, 这一部分依然采用的是双亲委派机制

而绿色部分是tomcat第二部分自定义类加载器, 正事这一部分, 打破了类的双亲委派机制. 先面我们就来详细看看tomcat自定的类加载器

  1. 橙色部分类加载器, 依然采用的是双亲委派机制, 原因是, 他只有一份. 如果有重复, 那么也是以这一份为准. 
  • commonClassLoader: tomcat最基本的类加载器, 加载路径中的class可以被tomcat容器本身和各个webapp访问;

  • catalinaClassLoader: tomcat容器中私有的类加载器, 加载路径中的class对于webapp不可见

  • sharedClassLoader: 各个webapps共享的类加载器, 加载路径中的class对于所有的webapp都可见, 但是对于tomcat容器不可见.

2. 绿色部分是java项目在打war包的时候, tomcat自动生成的类加载器, 也就是说 , 每一个项目打成一个war包, tomcat都会自动生成一个类加载器, 专门用来加载这个war包. 而这个类加载器打破了双亲委派机制. 我们可以想象一下, 加入这个webapp类加载器没有打破双亲委派机制会怎么样?

如果没有打破, 他就会委托父类加载器去加载, 一旦加载到了,  子类加载器就没有机会在加载了. 那么环境就会污染. 所以, 这一部分他打破了双亲委派机制

这样一来, webapp类加载器不需要在让上级去加载, 他自己就可以加载对应war里的class文件. 当然了, 其他的项目文件, 还是要委托上级加载的.

两个对象相同条件

  1. 包名+类名一样

  2. 使用同样的类加载器加装

沙箱安全机制

自定义String类,但是在加载子弟敬意String类的时候回率先使用引导类加载器加载,而引导类加载器在加载过程中会先加载jdk自带的文件(rt.jar包中的java\lang\String.class),报错信息说没有main方法就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制

2.链接-linking

链接包括三个阶段:验证--准备--解析

验证-verify

  1. 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。

  2. 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

文件格式验证:

  • 魔数检查:是否以魔数0xCAFEBABE开头。

  • 版本检查:主、次版本号是否在当前虚拟机处理范围之内。

  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。

  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。

  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。

  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

  • 长度检查

元数据验证:

  • 是否有父类

  • 是否继承了final修饰的类

  • 抽象方法是否有实现

字节码验证:

主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会产生危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作:例如不会出现类似这样的情况:在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。

  • 保证跳转指令不会跳转到方法体以外的字节码指令上。

  • 保证方法体中的类型转换是有效的:例如可以把一个子类对象赋值给父类数据类型,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险不合法的。

(Halting Problem:通过程序去校验程序逻辑是无法做到绝对准确的——不能通过程序准确的检查出程序是否能在有限时间之内结束运行。)

符号引用验证:

符号引用验证可以看作是类对自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验以下内容:

  • 符号引用中通过字符串描述的全限定名是否能够找到对应的类。

  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。

  • 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

间接引用:指向运行时产量池的引用,符号引用,比如#32这个符号对应的是某个类的全限定名的字符串而已;

image.png

直接引用:类的内存地址,不再指向常量池

image.png

准备-prepare

  1. 为类静态变量分配内存并且设置该类变量的默认初始值,即零值(0 false,0L,null...)(static修饰的类变量)

  2. final修饰的static常量,因为final在编译的时候就会分配了,在编译的时候会给属性添加ConstantValue属性,准备阶段会显式初始化,不用赋默认零值,会直接初始化为指定值

  3. 不会为实例变量分配初始化,类变量会分配在方法去中,而实例变量是会随着对象一起分配到java堆中。

        class MyObject {
            static int num1 = 100;
            static int num2 = 100;
            static MyObject myObject = new MyObject();

            public MyObject() {
                num1 = 200;
                num2 = 200;
            }

            @Override
            public String toString() {
                return num1 + "\t" + num2;
            }
        }

        class MyObject2 {
            static int num1 = 100;      
            static MyObject2 myObject2 = new MyObject2(); 
            public MyObject2() {
                num1 = 200;
                num2 = 200;
            }

            static int num2 = 100;      
            @Override
            public String toString() {
                return num1 + "\t" + num2;
            }
        }

        public class ClassLoadingProcessStatic {

            public static void main(String[] args) {
                System.out.println(MyObject.myObject);          
                System.out.println(MyObject2.myObject2);      
            }

        }

结果输出

    200	200
    200	100

第一个输出结果:

准备阶段:

num1:0

num2:0

myObject:null

初始化阶段:

num1:0赋值100

num2:0赋值100

myObject:null变成一个内存地址,同时执行构造方法(init()):num由100赋值为200; num2由100赋值为200

所以最终输出结果: 200 200

第二个输出结果:

准备阶段:

num1:0

myObject:null

num2:0

初始化阶段:

num1:0赋值100

myObject:null变成一个内存地址,同时执行构造方法:num1由100赋值200;num2由0赋值200 num2:200赋值100

所以最终输出结果: 200 100

第二个输出结果初始化阶段注意:

这里myObject和num2都是静态变量,初始化阶段语句要包括在clinit()方法,即

            //伪代码
            clinit(){
              static MyObject2 myObject2 = new MyObject2(); 
                static int num2 = 100;      
            }

num2语句且在myObject实例化后面,所有先实例化调用构造函数,再运行static int num2 = 100; 按照顺序执行linit()方法语句

解析-resolve

  1. 将常量池内的符号引用转换为直接引用的过程。直接引用得到了类、字段、方法在内存中的指针或者偏移量

  2. 事实上,解析操作往往会伴随着jvm在执行完初始化之后再执行

  3. 符号引用就是一组符号来描述所引用的目标。符号应用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

  4. 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info/CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。

3.初始化-initialization

  1. 初始化阶段就是执行类构造器方法clinit()的过程。此方法不需要定义,是javac编译器自动收集类中的所有类静态成员的赋值语句(静态变量;常量+值要计算得出的)和静态代码块中的语句合并而来。

  2. 构造器方法中指令按语句在源文件中的出现的顺序执行。Clinit不同于类的构造器

  3. 若该类有父类,jvm会保证子类的clinit()执行前,父类的clinit()已经执行完毕虚拟机必须保证一个类的clinit()在多线程下被同步加载。

  4. clinit()方法的调用,也就是类的初始化,虚拟机会在内部确保其多线程环境中的安全性

插件

  • bytecode viewer

查看字节码

  • jclasslib bytecode viewer

可以可视化已编译Java类文件和所包含的字节码的工具。 另外,它还提供一个库,可以让开发人员读写Java类文件和字节码。

clinit()相关解读

clinit()线程安全

clinit() 方法的调用,虚拟机必须保证一个类的clinit()在多线程下被同步加载。

       public class DeadThreadTest {
           public static void main(String[] args) {
               Runnable r = () -> {
                   System.out.println(Thread.currentThread().getName() + "开始");
                   DeadThread dead = new DeadThread();
                   System.out.println(Thread.currentThread().getName() + "结束");
               };

               Thread t1 = new Thread(r,"线程1");
               Thread t2 = new Thread(r,"线程2");

               t1.start();
               t2.start();
           }
       }

       class DeadThread{

           static{
               if(true){
                   System.out.println(Thread.currentThread().getName() + "初始化当前类");
                   while(true){

                   }
               }
           }
       }

程序不结束,有一个线程始终等待另一个线程加载完,输出结果如下

    线程1开始
    线程2开始
    线程1初始化当前类

Java 编译器就不会生成clinit()方法的场景

  • 场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成clinit()方法

  • 场景2:静态的字段,没有显式的赋值,不会生成clinit()方法

  • 场景3:比如对于声明为 static final 的基本数据类型的字段,不管是否进行了显式赋值,都不会生成clinit()方法

    public class ClinitTest2 {
        /**
         * 哪些场景下,Java 编译器就不会生成<clinit>()方法
         */
        //场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
        public int num = 1;
        //场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
        public static int num1;
        //场景3:比如对于声明为 static final 的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
        public static final int num2 = 1;

        //存在static成员变量或静态代码块就会有clinit()方法生成
        /*
        public static  int num3 = 1;
        static{
            System.out.println("加载我了");
        }*/

        public static void main(String[] args) {
            ClinitTest2 clinitTest2 = new ClinitTest2();
        }

    }

image.png

结论:

  1. 在链接阶段的准备环节赋值的情况:
  • 对于基本数据类型的字段来说,如果使用 static final 修饰,则显式赋值(直接赋值常量,而非调用方法)通常是在链接阶段的准备环节进行

  • 对于 String 来说,如果使用字面量的方式赋值,使用 static final 修饰的话,则显式赋值通常是在链接阶段的准备环节进行

  1. 在初始化阶段()中赋值的情况
  • 排除上述的在准备环节赋值的情况之外的情况
  1. 最终结论:
  • 使用 static + final 修饰,且显式赋值中不涉及到方法或构造器调用的基本数据类型或String类型的显式赋值,是在链接阶段的准备环节进行

  • 使用static修饰的静态变量在初始化阶段clinit()赋值

    public int aa = 2;   //
    public Integer aa2 = 2;   //
    public Integer AA3 = Integer.valueOf(1000); //

    private static int num = 1; //在初始化阶段clinit()中赋值(静态变量)

    public static final String s0 = "helloworld0"; //在链接阶段的准备环节赋值 (常量)

    // 也就是说在用到该final变量的地方,相当于直接访问的这个常量,不需要在运行时确定。
    public static final String FINAL_STR = "aaa";//在链接阶段的准备环节赋值 (常量)

    public static final int INT_CONSTANT = 10;  //在链接阶段的准备环节赋值 (常量)

    public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);   //在初始化阶段clinit()中赋值(常量+需要计算赋值)

    public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000); //在初始化阶段clinit()中赋值(静态变量)

    public static final String s1 = new String("helloworld1"); //在初始化阶段clinit()中赋值 (常量+需要计算赋值)

    static {
        num = 2;
        number = 20;
        System.out.println(num);
        System.out.println("加载我了");
        //System.out.println(number);//报错:Illegal forward reference 非法的前向引用。
    }

    //只要静态代码块不使用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
        System.out.println(ClassInitTest.FINAL_STR);//aaa
    }

image.png

执行顺序(静态代码块+构造代码块+构造方法)

            public class Parent {
            
                // 初始化clinint()运行,执行一次
                static {
                    System.out.println("父-静态代码块");
                }
                // 实例化运行时调用,每次实例化调用
                {
                    System.out.println("父-构造代码块");
                }
                // 实例化构造时调用,每次实例化调用
                public Parent() {
                    System.out.println("父-构造器");
                }

            }
            
            public class Son extends Parent {

                static {
                    System.out.println("子-静态代码块");
                }

                {
                    System.out.println("子-构造代码块");
                }

                public Son() {
                    System.out.println("子-构造器");
                }

                public static void main(String[] args) {
                    Son zi = new Son();
                    Son zi2 = new Son();

                }

            }

输出结果

    父-静态代码块
    子-静态代码块
    父-构造代码块
    父-构造器
    子-构造代码块
    子-构造器
    父-构造代码块
    父-构造器
    子-构造代码块
    子-构造器

结论:

  • 父-静态代码块--》 子-静态代码块--》 父-构造代码块/父-构造器->子-构造代码块/子-构造器

  • 静态代码块>mian方法>构造代码块>构造方法

  • 其中静态代码块只执行一次。构造代码块在每次创建对象是都会执行。

获取类加载器

   public static void main(String[] args) {

           //获取应用类加载器
           ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
           System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

           //获取其上层:扩展类加载器
           ClassLoader extClassLoader = systemClassLoader.getParent();
           System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d

           //获取其上层:获取不到引导类加载器
           ClassLoader bootstrapClassLoader = extClassLoader.getParent();
           System.out.println(bootstrapClassLoader);//null

           //对于用户自定义类来说:默认使用系统类加载器进行加载
           ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
           System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

           //String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
           ClassLoader classLoader1 = String.class.getClassLoader();
           System.out.println(classLoader1);//null 为null 启动类加载器
           
           
           //1.获取当前类的加载器
           ClassLoader classLoader = Class.forName("java.lang.String").getClassLoader();
           ClassLoader classLoader3 = String.class.getClassLoader();
           System.out.println(classLoader);
           System.out.println(classLoader3);
           
           //2.获取当前线程上下文的加载器
           ClassLoader classLoader1 = Thread.currentThread().getContextClassLoader();
           System.out.println(classLoader1);

           //3.获取应用类加载器
           ClassLoader classLoader2 = ClassLoader.getSystemClassLoader().getParent();
           System.out.println(classLoader2);
           
           
           //获取BootstrapClassLoader能够加载的api的路径
           URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
           for (URL element : urLs) {
               System.out.println(element.toExternalForm());
           }

           System.out.println("***********扩展类加载器*************");
           //获取扩展类加载器能够加载的api的路径
           String extDirs = System.getProperty("java.ext.dirs");
           for (String path : extDirs.split(";")) {
               System.out.println(path);
           }

    }

深入理解JVM系列