美文网首页
java类加载机制

java类加载机制

作者: kindol | 来源:发表于2018-07-30 23:53 被阅读0次

    总体上,可以分为如下几个阶段:

    classLoader.jpg

    其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段,当中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(也成为动态绑定)。而且,这5个阶段是按顺序开始,但未必按顺序进行或完成,可能交叉

    静态绑定和动态绑定

    • 静态绑定

      即前期绑定,在编译器编译的时候方法已被绑定,java中只有final,static,private 和构造方法是前期绑定的。

    • 动态绑定

      即运行时绑定。在运行时根据具体对象的类型进行绑定。在 Java 中,几乎所有的方法都是后期绑定的。

    阶段一:加载

    此阶段jvm做3件事情:

    • 通过类的全限定名获取其定义的二进制字节流(二进制字节流可以通过Class文件、jar包、网络、运行时计算生成——动态代理等获取)
    • 将此字节流所代表的静态存储结构转化为方法区的运行时数据结构
    • java堆中生成一个代表此类的java.lang.Class对象,作为方法区中这些数据的访问入口
      由于后两步,所以加载阶段与验证阶段是交叉进行的

    此阶段是可控性最强的阶段,开发人员可以使用自定义类加载器完成加载。

    类加载器

    类加载器用于实现类的加载动作,但其作用远不仅限于加载阶段;而对于数组类的加载过程,数组类本身不通过类加载器创建,是由jvm直接创建的,但数组类和类加载器仍有很密切的关系,因为数组类的元素类型最终要靠类加载器创建,数组类创建过程即类似递归,不断地去维,最后得到引用类型A,数组将在加载该组件类型的类加载器的类名称空间上被标识

    对于任意一个类,都需要由它的类加载器和类本身一同确定其在就 Java 虚拟机中的唯一性,也即,即使源于同一个Class文件,但加载器不同,两个类也必不相等(此处的相等包括类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用 instanceof 关键字对对象所属关系的判定结果)。

    java类加载器分为3种:

    • 启动类加载器:Bootstrap ClassLoader

      负责加载存放在JDK\jre\lib(JDK 代表 JDK 的安装目录)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如 rt.jar,所有的java.*开头的类均被 Bootstrap ClassLoader 加载)。启动类加载器由C++编写,无法被 Java 程序直接引用的

    • 扩展类加载器:Extension ClassLoader

      负责加载JDK\jre\lib\ext目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器

    • 应用程序类加载器:Application ClassLoader

      负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

    当然了,以上虽说会在指定目录下加载,但如果将自定义类放在JDK\jre\lib下,出于安全考虑,启动类加载器也不会去加载此类;这也说明,如果要使用自定义类加载器,不要将自定义类型的字节码放置到系统路径或者扩展路径中,否则会被系统类加载器或扩展类加载器抢先加载

    同时,我们可以自定义类加载器。因为jvm自带的类加载器只懂得从本地文件加载标准的class文件,若编写自己的类加载器,便可以做到以下几点:

    • 在执行非置信代码之前,自动验证数字签名
    • 动态地创建符合用户特定需要的定制化构建类。
    • 特定的场所取得 java class,例如数据库中和网络中。

    双亲委派机制

    类加载器的层次关系如下图:

    image

    这种层次关系称为类加载器的双亲委派模型,看着像继承,但其实是通过组合来复用父加载器中的代码

    工作流程:

    如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类

    public Class<?> loadClass(String name) throws ClassNotFoundException {
      return loadClass(name, false);
    }
    
    protected synchronized Class<?> loadClass(String name, boolean resolve)  throws ClassNotFoundException {
       // 首先判断该类型是否已经被加载
       Class c = findLoadedClass(name);
       if (c == null) {
         //如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
           try {
             if (parent != null) {
             //如果存在父类加载器,就委派给父类加载器加载
                 c = parent.loadClass(name, false);
               } else {
            //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
                 c = findBootstrapClass0(name);
               }
           }catch (ClassNotFoundException e) {
             // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
             c = findClass(name);
           }
       }
       if (resolve) {
           resolveClass(c);
       }
       return c;
    }
    

    一般情况下,推荐覆盖findClass(),而不是loadClass()

    好处:

    Java 类随着它的类加载器(其实就是它所在的目录)一起具备了一种带有优先级的层次关系,这对于保证 Java 程序的稳定运作很重要。以java.lang.Object为例,最终委派给启动类加载器加载,以此保证了 Object 类在程序中的各种类加载器中都是同一个类。

    java程序动态拓展方式:
    说到这,自然需要总结一下,运行时动态扩展java应用程序有如下两个途径:

    • 调用java.lang.Class.forName(String name, boolean initialize, ClassLoader loader)

      forName有很多重载版本,initialize表明类被加载同时是否完成初始化的工作

    • 用户自定义类加载器

    阶段二:验证

    验证的目的是为了确保 Class 文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。一般都有以下四种验证:(第一种是基于字节流检验, 后三种是是基于方法区的存储结构检验)

    • 文件格式验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内
    • 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合 Java 语法规范的元数据信息。(是否有父类、是否继承了不允许被继承如final类等)
    • 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为,但并不代表通过字节码验证则保证程序逻辑无误。
    • 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验,如符号引用中通过全限定名能否找到对应的类、指定类中是否存在符合方法的字段描述符等,保证解析动作能正常执行。

    阶段三:准备

    正式为类变量分配内存并设置类变量初始值,当然,这些内存都将在**方法区中分配,需要注意的是:

    • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
    • 这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值

    举个例子:一个变量定义如下

    public static int value = 3;
    

    则此阶段变量初始化为0,因为此时并未触发任何初始化操作,把 value 赋值为 3 的 putstatic 指令是在程序编译后,存放于类构造器 ()方法之中的

    需要注意的是:
    对于同时被 static 和 final 修饰的常量必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被 final 修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值

    如果类字段的字段属性表中存在 ConstantValue 属性,即同时被 final 和 static 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。比如修改以上例子:

    public static final int value = 3;
    

    准备阶段即value值为3,可以理解为static final 常量在编译期就将其结果放入了调用它的类的常量池中

    阶段四:解析

    解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。

    此阶段可能发生在初始化前,也可能发生在之后(静态绑定与动态绑定)。对于同一个符号引用进行解析请求是常见的事,因而虚拟机实现可能会对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标示为已解析状态),从而避免解析动作重复进行(除了invokedynamic指令以外)。

    包括四种解析:类或接口、字段、类方法、接口方法

    • 类或接口

      判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析

    • 字段

      对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束

      经典的例子

      class Super{  
          public static int m = 11;  
          static{  
              System.out.println("执行了super类静态语句块");  
          }  
      }  
      
      class Father extends Super{  
          public static int m = 33;  
          static{  
              System.out.println("执行了父类静态语句块");  
          }  
      }  
      
      class Child extends Father{  
          static{  
              System.out.println("执行了子类静态语句块");  
          }  
      }  
      
      public class StaticTest{  
          public static void main(String[] args){  
              System.out.println(Child.m);  
          }  
      }  
      

      输出结果为:

      执行了super类静态语句块
       执行了父类静态语句块
       33
      
    • 类方法

      与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口

    • 接口方法

      到了此阶段,才真正开始执行类中定义的 Java 程序代码。准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,即类构造器

    阶段五:初始化

    在准备阶段,变量已经赋过一次系统要求的初始值;而在初始化阶段,则执行类构造器<clinit>()方法

    <clinit>():

    <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的编译器收集的顺序是由语句在源文件中出现的顺序所决定的静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值(如i=0),但是不能访问(如print(i))。举个例子:

    public class Test
    {
        static
        {
            i=0;
    //      System.out.println(i);
        }
        static int i=1;
    
        public static void main(String args[])
        {
            System.out.println(i);
        }
    }
    //输出结果为1,因为按照源代码限制性静态块
    

    <clinit>()方法与实例构造器<init>()方法不同,它不需要显示地调用父类构造器,虚拟机会保证父类的<clinit>()方法先于子类的;父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

    当然,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生产<clinit>()方法。

    而对于接口的<clinit>(),

    接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法

    一个类的<clinit>()在多线程环境中需要加锁同步,jvm保证只有一个线程执行这个类的<clinit>(),其他线程都需要阻塞等待,这也可能造成一个问题,若此方法耗时严重,则可能造成多个线程阻塞

    类有且只在以下五种情况被初始化:

    • 创建类的实例时(即new)
    • 获取设置类或接口的静态变量,调用类的静态方法
    • 反射
    • 初始化一个类时,会首先初始化其父类
    • jvm启动时标明的启动类,即文件名和类名相同的那个类

    在一个类加载器中,类只能初始化一次

    类初始化的步骤:

    1. 若类还未加载与链接,先进行加载和链接
    2. 若类存在直接父类且类还未初始化,则初始化直接父类(不适用于接口)
    3. 若类中存在初始化语句(如static变量和static块),那就依次执行这些初始化语句

    举几个典例:

    1. 通过数组定义来引用类,不会触发此类的初始化
    2. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
    public class ConstClass
    {
        static
        {
            System.out.println("ConstClass init!");
        }
        public static  final String HELLOWORLD = "hello world";
    }aaa
    public class NotInitialization
    {
        public static void main(String[] args)
        {
            System.out.println(ConstClass.HELLOWORLD);
        }
    }
    //输出"aaa"
    

    几个问题

    1. 由不同的类加载器加载的指定类型还是相同的类型吗

      在Java中,一个类用完全匹配类名(包名+类名)作为标识,但在jvm中一个类用其全名和一个ClassLoader的实例作为唯一标识不同类加载器加载的类将被置于不同的命名空间

    2. 使用Class.forName(String name)触发的是哪个类加载器进行类加载行为

      forName(String name)内部调用了forName0(classString, true, ClassLoader.getCallerClassLoader()),而ClassLoader.getCallerClassLoader()先调用

      static ClassLoader getCallerClassLoader() {
          // 获取调用类(caller)的类型
          Class caller = Reflection.getCallerClass(3);
          // This can be null if the VM is  requesting it
          if (caller == null) {
             return null;
          }
          // 调用java.lang.Class中本地方法获取加载该调用类(caller)的ClassLoader
          return caller.getClassLoader0();
       }
       //java.lang.Class.java
       //虚拟机本地实现,获取当前类的类加载器,前面介绍的Class的getClassLoader()也使用此方法
       native ClassLoader getClassLoader0();
      

      也就是说,forName的classLoader其实就是调用类的classLoader,不一定为系统类加载器

    3. 编写自定义类加载器时,若没有设定父加载器,那么父加载器是

      未指定父类加载器的情况下,默认采用系统类加载器自定义的类加载器直接或者间接继承自java.lang.ClassLoader抽象类,对应的无参默认构造函数实现如下:

      protected ClassLoader() {
        SecurityManager security = System.getSecurityManager();
        if (security != null) {
          security.checkCreateClassLoader();
          }
        this.parent = getSystemClassLoader();
        initialized = true;
      }
      

      而当中的getSystemClassLoader()获得的即为AppClassLoader

    4. 在编写自定义类加载器时,如果将父类加载器强制设置为null,那么会有什么影响?

      JVM规范中规定如果用户自定义的类加载器将父类加载器强制设置为null,那么会自动将启动类加载器设置为当前用户自定义类加载器的父类加载器(这个问题前面已经分析过了)

    5. JVM规范中规定如果用户自定义的类加载器将父类加载器强制设置为null,那么会自动将启动类加载器设置为当前用户自定义类加载器的父类加载器(这个问题前面已经分析过了)。同时,我们可以得出如下结论:

    即使用户自定义类加载器不指定父类加载器,那么,同样可以加载到JDK/JRE/lib下的类,但此时就不能够加载JDK/JRE/lib/ext目录下的类了(loadClass()默认的委派逻辑)

    1. 如何在运行时判断系统类加载器能加载哪些路径下的类?

      • 获取到系统类加载器(系统类加载器和扩展类加载器本身都派生自URLClassLoader),调用URLClassLoader中的getURLs()方法
      • 直接通过获取系统属性java.class.path 来查看当前类路径上的条目信息 , System.getProperty("java.class.path")
    2. 一道有意思的题目

      https://blog.csdn.net/u013256816/article/details/50837863

      重点在于静态初始化的过程只有一次开始,因而在初始化对象内部的静态变量的时候,如果第一个为实例,那么会认为已经开始了静态初始化过程,不会再去执行一次开始

    参考:
    http://wiki.jikexueyuan.com/project/java-vm/class-loading-mechanism.html
    《深入理解java虚拟机》

    相关文章

      网友评论

          本文标题:java类加载机制

          本文链接:https://www.haomeiwen.com/subject/xgoqvftx.html