Skip to content

Java 中类加载的机制总结

Published: at 16:50:42

虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析、和初始化,最终形成可以被虚拟机直接使用的JAVA类型,这就是虚拟机的类加载机制 。

类的生命周期

类从被加载到虚拟机到被卸载出内存,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading) 这7个过程。其中验证、准备、解析这三个阶段被统称为连接(Linking)。

其中加载→验证→准备→初始化→卸载这5个阶段的顺序是确定的。而解析则不一定:某些情况下,解析可以在初始化阶段之后,这是为了支持JAVA语言运行时绑定(也称为动态绑定或晚期绑定)。

类加载的时机

什么时候开始类加载,虚拟机规范没有进行强制约束,由虚拟机具体的实现来把握。

但是初始化阶段,虚拟机规范则严格规定了有且只有5种情况必须立即对类进行初始化(加载、验证、准备必然在初始化之前):

  1. 使用new关键字进行实例化对象的时候、读取或设置一个类的静态字段的时候(被final修饰、已在编译器把结果放入常量池的静态字段除外)、调用一个类的静态方法的时候
  2. 使用java.lang.reflect 包的方法对类进行反射调用的时候
  3. 初始化一个类的时候,发现其父类还没有进行初始化,则需要对其父类进行初始化
  4. 虚拟机启动的时候,用户需要指定一个要执行的主类(包含main()方法的那个类)
  5. 当使用JDK1.7的动态语言支持时,如果执行一个java.lang.invoke.MethodHandle实例的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个句柄所对应的类没有进行过初始化的时候

上面5种场景中行为称为对一个类进行主动引动。除此之外,所有引用类的方式都不会触发初始化,称为被动引用

被动引用的示例:

  1. 通过子类引用父类的静态字段,不会触发子类初始化

    public class SupperClass{
        static {
            System.out.println("supper class init");
        }
        public static int value = 100;
    }
    
    public class SubClass extends SupperClass{
        static {
            System.out.println("sub class init!");
        }
    }
    
    public class TestClassInit{
        public static void main(String[] args){
            System.out.println(SubClass.value);
        }
    }

    输出结果为:

    supper class init
    100

    根据上面的输出结果可以看出,通过子类引用父类的静态字段,不会触发子类的初始化。

  2. 通过数组定义来引用类,不会触发此类的初始化

    public class TestClassInit{
        public static void main(String[] args){
            SupperClass[] supperClasses = new SupperClass[10];
        }
    }

    测试结果发现什么也没有输出,说明通过数组定义来引用类,不会触发此类的初始化

  3. 编译阶段被放入到了常量池

    public class ConstClass{
        static {
            System.out.println("const class init!");
        }
        public static final String HELLO_WORLD = "Hello,World!";
    }
    
    public class TestClassInit{
        public static void main(String[] args){
            System.out.println(ConstClass.HELLO_WORLD);
        }
    }

    输出结果:

    Hello,World!

    从结果可以看出,并没有输出”const class init!”,没有触发定义常量类的初始化。

接口的也有初始化过程,和类是一致的,但是接口和类的区别主要是前面类的5种场景中第3点:当接口初始化的时候,不需要其父类已经初始化了,只需要真正用到父接口的时候才会初始化。

类加载的过程

类加载的全过程,也就是加载、验证、准备、解析、初始化这5个阶段所执行的动作。

加载

“加载”是“类加载”过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事情:

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

    可以从ZIP包中获取:jar,war,ear等格式的

    可以从网络获取:比如applet应用

    运行时计算生成:动态代理技术

    其他文件生成:JSP应用

  2. 将这个字节流所代表的静态存储结构转化成方法区的运行时数据结构

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

验证

验证的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会威胁到虚拟机自身的安全。

如果验证输入的字节流不符合虚拟机的要求,虚拟机就会抛出一个java.lang.VerifyError 异常或其子类异常。

验证整体包含4个检验动作:

  1. 文件格式验证

    验证是否符合Class文件格式的规范,并且要能够被当前版本的虚拟机处理

    1. 是否以魔术0xCAFEBABE开头
    2. 主次版本号是否在当前虚拟机能够处理的范围内
    3. 是否有不被支持的常量类型
  2. 元数据验证

    对字节码描述的信息进行语义分析,以保证其描述的信息符合JAVA语言规范的要求

    1. 这个类是否有父类
    2. 这个类是否继承了不被允许继承的类(final修饰的类)
    3. 如果这个类不是抽象类,是否实现了其父类要求实现的所有方法
    4. 类的字段、方法是否与父类有冲突
  3. 字节码验证

    主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

  4. 符号引用验证

    确保解析动作能正确执行。

准备

准备阶段是正式为类变量分配内存并设置类变量(被static修饰的变量)初始值(0)的阶段。

一般情况下,会把变量设置成0,但是特殊情况下,会被设置成具体的值。比如:

public static final int value = 123;

编译时javac会为 value 生成 ConstantValue属性,在准备阶段的时候虚拟机就会根据ConstantValue的设置将 value 值设置成123。

解析

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

解析动作主要针对接口字段类方法接口方法方法类型方法句柄调用点限定符7类符号引用进行。

符号引用就是一组符号来描述目标,可以是任何字面量。

直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化

是类加载过程的最后一步,在初始化阶段,可以通过通过程序代码定制的主观计划去初始化类变量和其他资源。

从另一个角度来说,就是执行类构造器<clinit>()方法的过程。

<clinit>()方法的特点:

  1. ()方法是由编译器自动收集中所有类变量的赋值动作和静态语句块中的语句合并产生的。
  2. ()方法与类的构造函数不同,它不需要去显式调用父类构造器。
  3. 父类的()方法肯定会优先于子类的()方法执行,因此虚拟机第一个执行()方法的是Object对象。
  4. ()方法对于类和接口来说不是必须的。如果一个类中没有静态语句块(static{}),也没有对变量的赋值操作,则编译器不会为此类生成()方法。
  5. 多线程情况下,虚拟机会保证只有一个线程去执行类的()方法

使用

类访问方法区内的数据结构的接口, 对象是Heap区的数据。

卸载

Java虚拟机将结束生命周期的几种情况

类加载器(Class Loader)

顾名思义,类加载器(class loader)用来加载 Java 字节码到 Java 虚拟机中。

对于任意一个类,都需要由加载它的类加载器和这个类本身共同确立其在JAVA虚拟机中的唯一性。通俗的话说,比较两个类是否“相等”,只有在两个类是由同一个类加载器加载的前提下去比较才有意义。如果两个类来源于同一个class文件,但是是被两个不同的类加载器加载的,那这两个类肯定不相等。这里的“相等”,包括Class对象的eques()方法、isInstance()方法和instanceof关键字的判断。

双亲委派模型

从虚拟机的角度来看,只有两种不同的加载器:一是引导类加载器(Bootstrap ClassLoader),这个类加载器是由C++实现的,是虚拟机自身的一部分。二是其他类加载器,这些类都是由JAVA语言来实现的,独立于虚拟机外部,并且都继承自java.lang.ClassLoader。

从程序员的角度来看,类加载器可以分为以下3类:

  1. 引导类加载器(Bootstrap ClassLoader)

    主要负责:将<JAVA_HOME>\lib、或者被-Xbootclasspath 参数指定的路径中的类库加载到虚拟机内存中。

    开发者不可以直接使用这个类加载器

  2. 扩展类加载器(Extension ClassLoader)

    主要负责:加载<JAVA_HOME>\lib\ext 目录下的、或者被java.ext.dirs 系统变量所指定的路径中的所有类库。

    开发者可以直接使用这个类加载器

  3. 应用程序类加载器(Application ClassLoader)

    主要负责:加载用户类路径(ClassPath)上所指定的类库,默认的类加载器

    开发者可以直接使用这个类加载器

    https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d25dfeab-4475-4132-8ccb-e4aa83204965/Untitled.png

双亲委派模型的工作过程:

  1. AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
  2. ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
  3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
  4. ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

双亲委派模型的优点:

  1. 可以保证一个类在各种加载器环境中都是同一个类。

破坏双亲委派模型

双亲委派模型主要出现过3次较大规模的“被破坏”情况:

  1. 第一次出现在双亲委派模型之前——即JDK1.2发布之前。由于双亲委派模型在JDK1.2之后才被引入,而java.lang.ClassLoader 则在JDK1.0就已经存在了。面对已经存在了的用户自定义类加载器实现代码,JAVA设计者在引入双亲模型的时候不得不妥协,为了向前兼容,JDK1.2之后的java.lang.ClassLoader 添加了一个新的protected方法findClass(),并且不再提倡用户去覆盖ClassLoader 里面的loadClass方法,而是把类加载逻辑写到findClass方法中,在loadClass()方法里面如果父类加载失败,则会调用自己的findClass()方法,这样也可以保证写出来的类加载器是符合双亲委派模型的。

  2. 第二次是由这个模型自身缺陷导致的。会出现一个这样的问题,越基础的类越由上层的类加载器进行加载,如果这些类需要回调用户的代码,该怎么办?

    比如:JNDI服务,它是由引导类加载器去加载,但JNDI的目的是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序CLASSPATH下的JNDI接口提供者(SPI,Service Provider Interface)的代码,但是引导类加载器不可能“知道”这些代码,于是JAVA团队又引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread 类的 setContextClassLoad() 方法进行设置。如果创建线程时还未设置,它会从父线程中继承一个。如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器(Application ClassLoader)。

    有了线程上下文类加载器,JNDI 服务就可以使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父加载器请求子加载器去完成类加载的动作。这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,也违背了双亲委派模型的一般性原则,但是也是无可奈何的事情,JAVA中所有涉及SPI的加载动作基本上都才用这种方式。例如:JNDI、JDBC、JCE、JAXB、JBI等。

  3. 第三次是由用户对程序动态性的追求而导致的。比如:代码热替换(HotSwap)、模块热部署等。

    在OSGi环境下,类加载器不再是双亲委派模型中的树状模型,而是进一步发展为更加复杂的网状模型。

类的初始化顺序

根据多态性, 实际被调用的是子类的方法, 这个没错. 再考虑有继承时, 初始化的顺序. 如果是new一个子类, 那么初始化顺序是:

父类static成员 -> 子类static成员 -> 父类普通成员初始化和初始化块 -> 父类构造方法 -> 子类普通成员初始化和初始化块 -> 子类构造方法