Skip to content

类加载流程

提示

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

类加载时机

类加载的生命周期

类型被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期经历如上七个阶段。

其中验证、准备、解析统称为连接

需要注意的是:解析阶段顺序是不确定的,它可以在初始化阶段之后再开始

类初始化的六种情况

《Java虚拟机规范》中规定了六种要立即对类进行”初始化“(加载、验证、准备自然需要在此之前执行)的情况:

  • 遇到newgetstaticputstaticinvokestatic这四条字节码指令时,类型没有过初始化,生成这四条字节码指令的场景有:

    • 使用new关键字实例化对象。
    java
    // 会初始化A
    A a = new A();
    • 读取或设置一个类型的静态字段(final修饰、编译器进入常量池的静态字段除外)
    java
    class B {
        // 会导致A类被初始化  
        static A a = new A();
        public static void main(String[] args) {
            // 不会导致A类被初始化
            System.out.print(A.HELLO_WORLD);
        }
    }
    class A {
        static final String HELLO_WORLD = "hello_world";
    }

    引用静态字符串常量不会导致持有该常量的类初始化。

    • 调用一个类型的静态方法
    java
    class A {
        static void print() {
            System.out.print("hello");
        }
        public static void main(String[] args) {
            A.print();
        }
    }

    我们可以用过-XX:+TraceClassLoading来查看类是否被加载。

  • 通过java.lang.Reflect对方法进行反射调用时

  • 初始化类时发现其父类还没有初始化。

java
public class SuperClass {
    static {
        System.out.println("Super class init");
    }

    public static int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("Sub class init");
    }
}

class Test {
    public static void main(String[] args) {
        //  只会初始化父类,不会初始化子类
        System.out.println(SubClass.value);
    }
}
  • 接口中定义的default方法,若该接口的实现类发生初始化,default方法在此之前要被初始化。
java
public interface Father {
    default void print() {
        System.out.print("hello");
    }
}

public class Son implements Father {

}

class Test {
    public static void main(String[] args) {
        // 会初始化Father接口中的default方法
		Father f = new Son();
    }
}

如果是接口初始化,那么不会要求父接口也全部初始化,真正使用到的父接口才会初始化。

  • java.lang.invoke.MethodHandle实例的解析结果为REF_getstaticREF_putstaticREF_invokestaticREF_newInvokeSpecial四种类型的方法句柄还没有初始化时。

类加载流程

加载

加载阶段是整个类加载生命周期的第一个阶段,Java虚拟机需要完成以下三件事情:

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

《Java虚拟机规范》没有规定二进制字节流的具体获取方式,目前已知获取方式包括:从zip包读取、运行时生成、加密文件获取等。既可以通过Java虚拟机内置的类加载器,也可以通过用户自定义的类加载器来实现类的加载动作(类来源的多样性需要自定义类加载器)。

数组本身不通过类加载器创建,但数组的类型需要通过类加载器来完成加载。

  • 将字节流代表的静态存储结构(ex: 常量池 -> 运行时常量池)转换为方法区运行时数据结构

  • 堆中生成java.lang.Class对象,并作为这个类方法区各各种数据的访问入口。

验证

  • 文件格式验证

验证字节流是否符号Class文件格式的规范,并能够被当前版本的虚拟机处理。包括:常量池中的常量是否有不被支持的常量类型是否以魔数0XCAFEBABE开头等验证点。

  • 元数据验证

对字节码描述的信息进行予以分析,确保符合规范,包括:此类是否有父类是否继承了不被允许继承的类是否缺少字段、方法等验证点。

  • 字节码验证

通过数据流和控制流分析,确保程序语义是合法、符合逻辑的。包括:保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作保证方法中类型转换都是有效的等验证点。

  • 符号引用验证

目的是确保将符号引用转为直接引用解析阶段能够顺利执行,对类自身以外的类信息进行匹配性校验。包括:符号引用中通过字符串描述的权限定名能够找到对应的类符号引用中的类、字段、方法的可访问性等验证点。

bash
# 关闭大部分的类验证以缩短虚拟机加载类时间
-Xverify:none

准备

为类中定义的静态变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都会在方法区进行分配。

java
class Test {
    // 类变量(静态变量),初始值为0
    public static int value1 = 123;
    // 类变量,初始值为123
    public static final int value2 = 123;
    // 实例变量
    public Object obj;
}

类变量value1在准备阶段过后的初始值为0,赋值为123的操作要等到初始化阶段才会执行。

类变量value2在编译时会生成ConstantValue属性,在准备阶段虚拟机就会将value2设置为123。

实例变量会随着对象Test的实例化而一起分配在堆中

解析

Java虚拟机将常量池内的符号引用替换为直接引用的过程。《Java虚拟机规范》没有规定解析阶段发生的具体时间,只要求了执行ldc、getfield、getstatic等17个指令前,先对它们所使用的符号引用进行解析。所以虚拟机可以自行决定解析的触发时机是类被加载器加载时符号引用将被使用前

符号引用:以一组符号来描述所引用的目标。可以引用没有加载到内存中的内容。

直接引用:可以直接指向目标的指针、相对偏移量或者能够定位到目标的句柄池。

  • 类或接口解析

基于以下代码,将从未解析过的符号引用x解析为类或接口的直接引用。包括三个步骤:

java
class Test {
    A x = new A();
}
  1. 若A不是数组类型,虚拟机将x作为权限定名交给Test类的加载器去加载类A类A按照类加载流程执行类加载,若发生错误,那么解析失败。

  2. 若A是数组类型(如Integer[]),那么虚拟机会将x即[Ljava/lang/Integer中的Integer类型交给Test类加载器去加载,再由虚拟机生成对应的数组对象

  3. 若前两步没有问题,在解析完成前还要进行符号引用验证,确保Test对A的访问权限。

  • 字段解析

通过类的常量池表查找字段所属的类或接口的符号引用(用A表示),并执行类或接口的解析。按照如下步骤执行:

  1. 若A本身包含了简单名称字段描述都与目标匹配的字段,返回该字段的直接引用。
  2. 否则,若A实现了接口,会按照继承关系从下往上查找,重复步骤1。
  3. 否则,若A不是java.lang.Object,会按照继承关系从下往上查找,重复步骤1。
  4. 否则,查找失败,抛出NoSuchFieldError异常。
  5. 最后对该字段进行权限验证,若不具备权限,抛出java.lang.illegalAccessError异常。
  • 方法解析

字段解析类似,都是要找到方法所属的类或接口的符号引用(用A表示)。按照如下步骤执行:

  1. 如果A是个接口,那么直接抛出java.lang.IncompatibleClasssChangeError异常。
  2. 否则,查找类A中是否存在简单名称字段描述都与目标匹配的方法,返回该方法的直接引用。
  3. 否则,查找类A实现的接口及它们的父接口中递归查找是否存在简单名称字段描述都与目标匹配的方法,若存在匹配的方法,说明类A是个抽象类,抛出java.lang.AbstractMethodError异常。
  4. 否则宣告查找失败,抛出java.lang.NoSuchMethodError异常。
  5. 若返回了方法的直接引用,则需要进行权限验证,若不具备抛出java.lang.illegalAccessError异常。
  • 接口方法解析
  1. 如果A是个类,那么直接抛出java.lang.IncompatibleClasssChangeError异常。
  2. 否则,查找接口A中是否存在简单名称字段描述都与目标匹配的方法,返回该方法的直接引用。
  3. 否则,在接口A的父接口中递归查找,直到java.lang.Object类为止,若存在简单名称字段描述都与目标匹配的方法,返回该方法的直接引用。
  4. 否则宣告查找失败,抛出java.lang.NoSuchMethodError异常。

初始化

直接来说:初始化阶段就是执行类构造器<clinit>方法的过程。

<clinit>方法是由编译器自动收集类中的所有类变量的赋值动作静态语句块(static {})中的语句合并产生的。编译器的收集顺序由语句在源文件中出现的顺序决定的。 <clinit>不包含静态方法静态方法在被调用的时候才会加载。

java
class Test {
    static {
        // i=1执行先于i=0,但并不能修改成功
        i = 1;
        // 输出i,提示Illegal forward reference编译错误
        // System.out.println(i);
    }
    static int i = 0;

    public static void main(String[] args) {
        System.out.println(i); // 0
    }
}

static静态代码块只能访问到定义在其之前的变量,定义在其之后的变量能赋值但不能访问

clinit

  • 类的构造函数<init>方法不同,<clinit>不需要显示的调用父类构造器,Java虚拟机会保证子类的<clinit>方法执行前,父类的<clinit>已执行完毕,因此java.lang.Object类是Java虚拟机中第一个被执行的<clinit>方法的类型。

  • 由于父类的<clinit>先执行,所以父类中的而静态语句块要先于子类的变量赋值操作。

  • <clinit>方法对于接口或类来说不是必须的,如果没有静态语句块、也没有变量赋值操作,那么编译器不会为该类生成<clinit>方法。

  • 接口中不能使用静态语句块,但能有静态变量赋值操作,所以接口也能生成<clinit>方法。但与类不同的是,执行接口的<clinit>方法不需要执行父接口的<clinit>方法,只有当父接口中定义的变量被使用时,父接口才会初始化。此外接口的实现类在初始化时也不会执行接口的<clinit>方法。

  • Java虚拟机必须要保证一个类的<clinit>方法在多线程环境中,如果多个线程同时初始化一个类,那么只会有一个线程去执行<clinit>方法,其他线程都要阻塞等待,直到活动线程执行完<clinit>。若一个类的<clinit>方法存在耗时很长的操作,那么可能造成多线程阻塞(活动线程执行完,其他线程不会再进入<clinit>方法)。

java
class Parent {
    static int A = 0;
    static {
        A = 2;
    }
}

class Son extends Parent {
    public static int B = A;
}

public class Test1 {
    public static void main(String[] args) {
        System.out.println(Son.B);// 2
    }
}

根据规则:父类的<clinit>先执行,所以父类的静态语句块先于子类而执行。Son.B = 2


类加载器

类加载阶段的通过一个类的全限定名来获取描述该类的二进制字节流这个动作交给Java虚拟机外部去实现,让应用程序决定如何去获取所需的类,实现这个动作的代码被称为类加载器(Class Loader)。类加载器用于实现类的加载动作

判断两个类是否相等,前提是由同一个类加载器加载。不同类加载器加载同一个.class文件也是不相等的。

java
public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {
        // 自定义类加载器,匿名内部类
        ClassLoader classLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = 
                        name.substring(name.lastIndexOf(".") + 1) + ".class";
                    // 使用当前自定义类加载来加载fileName的类为二进制字节流
                    InputStream inputStream = 
                        getClass().getResourceAsStream(fileName);
                    // 如果找不到就让父类加载器去执行加载
                    if (null == inputStream) {
                        return super.loadClass(name);
                    }
                    byte[] bytes = new byte[inputStream.available()];
                    inputStream.read(bytes);
                    // 创建name的Class对象
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        // 通过自定义类加载器实现类的加载,并实例化该对象
        Object obj = 
            classLoader.loadClass("top.leejay.jvm.load.ClassLoaderTest")
            .newInstance();
        // 查看加载的Class对象
        System.out.println(obj.getClass());
        // 验证不同的类加载器加载同一个.class文件是否相同
        System.out.println(obj instanceof ClassLoaderTest);// false
    }
}

此时在Java虚拟机中共存在两个ClassLoaderTest类,一个是应用程序类加载器加载的,一个是自定义类加载加载的。

双亲委派模型

上图各种类加载器之间的层次关系被称为类加载器的双亲委派模型。双亲委派模型要求:除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器(继承自java.lang.ClassLoader类)。

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载该类,而是把这个请求委派给父加载器去执行,每个层次都是如此,最终所有的请求都传递到最顶层的启动类加载器中。只有当父加载器无法完成这个加载请求时,子加载才会尝试自己去完成加载。

  • 启动类加载器

负责加载JAVA_HOME/lib目录下,能被Java虚拟机识别的类库。由C++实现。Java中用null来表示。

  • 扩展类加载器

负责加载JAVA_HOME/lib/ext目录下的类库。由Java实现。

  • 应用程序加载器

因为是ClassLoader.getSystemClassLoader的返回值,又被称为系统类加载器。负责加载用户类路径(ClassPath)上所有的类库。

不同加载器加载同一文件

java
protected Class<?> loadClass(String name, boolean resolve)
        						throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 判断类是否被加载
        Class<?> c = findLoadedClass(name);
        // 如果没有被加载
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 如果当前类加载器的父类不为null,说明存在父加载器
                // 如果为null,说明加载到顶层的启动类加载器了(Java中为null)
                if (parent != null) {
                    // 调用父类的类加载器加载类,往上查找
                    c = parent.loadClass(name, false);
                } else {
                    // 如果为null,那么调用顶层的启动类加载器来加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父加载器没找到,那么会抛出该异常,默认不处理
            }
			// 如果c=null,说明启动类加载器也没找到这个类
            // 那么会直接调用本身的findClass方法
            if (c == null) {
                long t1 = System.nanoTime();
                // 调用findClass,从上往下查找
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

判断请求加载的类型是否被加载过,如果没有则调用父加载器的loadClass(),若父加载器为null则默认使用启动类加载器作为自己的父加载器。若父加载器加载失败,抛出ClassNotFound异常后,就会调用自身的findClass方法尝试进行加载。

双亲委派的优点

  • 避免类重复加载。加载类都会先判断这个是否被加载过。
  • 避免核心类被篡改。如果用户自定义了java.lang.Object类,就无法保证最基本的行为。

破坏双亲委派

  • 历史遗留原因

类加载器和ClassLoader抽象类JDK1中就存在,而双亲委派模型JDK1.2才出现,为了面对已经存在的用户自定义类加载器的代码而做出妥协,加入了protected Class<?> findClass()方法,结合ClassLoader的源码,当loadClass加载失败,就会调用自身的findClass方法。

  • JNDI

我们知道JNDI的代码由启动类加载器加载,但JNDI需要对调用部署在应用程序的ClassPath下的JNDI服务提供者接口,简单来说就是顶层启动类加载器需要加载应用程序类加载器,此问题会破坏双亲委派模型。

解决办法:引入线程上下文类加载器,如果创建线程时还未设置,它会从父线程中继承一个,如果在应用程序的全局范围内都没有设置的话,那么这个类加载器默认是应用程序类加载器

正确编写自定义类加载器

在前面我们编写了"糟糕"的自定义类加载器代码来验证不同的类加载器加载同一个.class文件是不相等的。那么这节我们按照JDK1.2后建议我们使用的findClass来编写自定义类加载器。

java
public class MyClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) {
        // 先判断类是否已被加载
        Class<?> c = findLoadedClass(name);
        if (null == c) {
            try {
                // 加载本地磁盘上指定name的class文件(只要不在ClassPath下即可)
                FileInputStream inputStream = 
                    new FileInputStream(new File("D://" + name + ".class"));
                ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                byte[] bytes = new byte[inputStream.available()];
                int index;
                while ((index = inputStream.read(bytes)) != -1) {
                    outputStream.write(bytes, 0, index);
                }
                // 获取字节流
                byte[] byteArray = outputStream.toByteArray();
                // 创建name的Class对象
                c = defineClass(name, byteArray, 0, byteArray.length);
            } catch (IOException e) {
                throw new ClassNotFoundException(name);
            }
        }
        return c;
    }
}

public class ClassLoaderTest {
    public static void main(String[] args) throws ClassNotFoundException {
        MyClassLoader loader = new MyClassLoader();
        Class<?> hello = loader.loadClass("Hello");
        // top.leejay.jvm.load.MyClassLoader@4fccd51b
        System.out.println(hello.getClassLoader());
    }
}
  1. D盘下创建一个名为Hello的java文件,javac -encoding utf-8 Hello.java生成class文件。
  2. 切记Hello.class文件不能在ClassPath下,否则会导致AppClassLoader加载该类。
  3. 自定义类加载器的loadClass方法为入口,在三个默认类加载器都找不到时会调用findClass

Class.forName与ClassLoader.loadClass

结论

  1. forName除了加载.class文件外,还会执行该类的初始化,即执行类的<clinit>方法,所以类的静态代码块静态变量赋值操作都会执行。
  2. loadClass只会将.class文件加载到虚拟机中,不会执行初始化操作。
java
@CallerSensitive
public static Class<?> forName(String className)
    	throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    /**
      * @param 要加载的类名
      * @param 是否需要初始化 true/false 初始化/不初始化
      * @param 调用者类加载器
      * @param 调用者Class对象
      */ 
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
java
public class ClassDemo {
    static int value = 0;
    static {
        System.out.println("static ...");
        value = 1;
        System.out.println(value);
    }
}

class Test {
    public static void main(String[] args) throws ClassNotFoundException {
        // 初始化
        Class<?> aClass1 = 
            Class.forName("top.leejay.jvm.load.ClassDemo");
        // 不会初始化
        Class<?> aClass2 = ClassLoader.getSystemClassLoader()
            .loadClass("top.leejay.jvm.load.ClassDemo");
    }
}