JVM-类加载机制


前言

JVM 将类加载到方法区,作为类的元信息,供后续对象的创建使用。类并不仅仅指的是我们常见的 *.class 文件,准确的说应该是一串二进制字节流,可以以磁盘文件、网络、数据库、内存或动态生成的形式存在。类加载的过程以及类的加载器,都是深入 Java/JVM 所必须的了解的内容,下面会作详细介绍。

类加载

​ 类加载的生命周期是 加载 -> 验证 -> 准备 -> 解析 -> 初始化 。一般以 加载 -> 验证 -> 准备 -> 初始化 的顺序开始执行,而在某些情况下,解析可以在初始化之后开始,这是为了支持 Java 的动态绑定。各阶段按既定顺续开始执行,但通常是相互交叉地混合进行。阶段可以在执行过程中,调用激活另一个阶段。

加载

  1. 获取全限定类名对应的二进制字节流。
  2. 将字节流转化为方法区的运行时数据结构。
  3. 在堆创建代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

连接

验证

​ 为了确保字节流解析出来的类信息符合 《Java虚拟机规范》的要求,保证这些信息不会危害虚拟机自身的安全。验证阶段并非类加载必要的存在,可以使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

  1. 文件格式验证

    验证字节流是否符合 Class 文件的格式规范,且能被当前版本的虚拟机处理。

    • 是否以魔数 0xCAFEBABE 开头
    • 主、次版本在当前虚拟机接受范围内
    • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
    • CONSTANT_Utf8_info **型的常量中是否有不符合 **UTF-8 编码的数据
    • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息
  2. 元数据验证

    对字节码描述的信息进行语义分析。

    • 是否有父类(除了 Object 外,都应该有父类)
    • 该类的父类是否 final 修饰
    • 如果这个类不是抽象类,其是否实现了父类和接口中要求实现的所有方法
    • 类的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者不符合规范的方法重载)
  3. 字节码验证

    对类的方法体进行校验分析,通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。

    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
    • 保证任何跳转指令都不会跳转到方法体意外的字节码指令上
    • 保证方法体中的类型转换总是有效
  4. 符号引用验证

    在解析阶段中发生,符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,如:java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError 等。

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符及简单名称锁描述的方法和字段
    • 符号引用中的类、字段、方法的可访问性(privateprotectedpublic<package>)是否可被当前类访问

准备

​ 为类变量分配内存并设置初始值(零值)的阶段。从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域。在 JDK 7 及之前,HotSpot 使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在 JDK 8 及之后,类变量则会随着 Class 对象一起存放在 Java 堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

解析

​ Java虚拟机将常量池内的符号引用替换为直接引用的阶段。

  1. 类或接口的解析
  2. 字段解析
  3. 方法解析
  4. 接口方法解析

初始化

​ 在准备阶段,类变量被初始化为零值,而在初始化阶段,则会根据程序去初始化类变量。初始化阶段,其实就是执行 <clinit>() 方法的过程,其是由 javac 根据类中定义的类变量的赋值动作,以及静态代码块,按源文件中的编写顺序,自动生成的。

主动引用

《Java 虚拟机规范》严格规定有且只有 6 种情况必须立即对类进行”初始化”,这些行为被称为对一个类型的“主动引用”

  1. 遇到 newgetstaticputstaticinvokestatic 指令时。
  2. 使用 java.lang.reflect 包下的方法对类进行发射调用时。
  3. 初始化某个类,其父类还未初始化时,会先触发父类初始化。
  4. 虚拟机启动时,用户指定的主类(包含 main() 方法的类),会先被初始化。
  5. 当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial 四种类型的方法句柄,且这个方法句柄对应的类未初始化。
  6. 当接口中定义了 JDK 8 新加入的默认方法,其实现类在初始化之前,必须先初始化该接口。

被动引用

  • 通过子类引用父类的静态字段,不会导致子类初始化(通过 -XX: +TraceClassLoading 观察,该操作还是会导致 SubClass 加载)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /**
     * output:
     * SuperClass init!
     * 123
     */
    public class NotInitialization {
        public static void main(String[] args) {
            System.out.println(SubClass.value);
        }
    }
    class SuperClass {
        static {
            System.out.println("SuperClass init!");
        }
        public static int value = 123;
    }
    class SubClass extends SuperClass {
        static {
            System.out.println("SubClass init!");
        }
    }
    
  • 通过数组定义来引用类,不会触发此类的初始化。且触发一个名为 [Lname.guolanren.SuperClass 初始化。它由虚拟机自动生成、直接继承于 java.lang.Object。创建动作由字节码指令 newarray 触发。

    1
    2
    3
    4
    5
    6
    7
    8
    /**
     * output:
     */
    public class NotInitialization {
        public static void main(String[] args) {
            SuperClass[] sca = new SuperClass[10];
        }
    }
    
  • 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /**
     * output:
     * Hello World!
     */
    public class NotInitialization {
        public static void main(String[] args) {
            System.out.println(ConstClass.HELLO_WORLD);
        }
    }
    class ConstClass {
        static {
            System.out.println("ConstClass init!");
        }
        public static final String HELLO_WORLD = "Hello World!";
    }
    

接口初始化

​ 接口的初始化与类的初始化类似,但当一个接口在初始化时,不要求其父接口全部都完成初始化。

类加载器

​ 加载阶段的 “获取全限定类名对应的二进制字节流”,是由类加载器来实现的。

命名空间可见性

​ 子类加载器可以看见父类加载器加载的类,而父类加载器看不到自类加载器加载的类。

​ 每个类加载器都有它独立的命名空间,而每一个类,都由加载它的类加载器与这个类自身一起共同确立其在虚拟机中的唯一性。也就是说,比较两个类是否是同一个类(如使用 instanceof 关键字),除了比较它们是否源自于同一份 Class 文件外,还要比较它们是否被同一个虚拟机,同一个类加载器加载的。

ClassLoader

  • BootStrapClassLoader(启动类加载器)

    C++ 编写,虚拟机的一部分,在 JavaClassLoadernull 则表示其类加载器为 BootStrapClassLoader。负责加载存放在 $JAVA_HOME/lib 或被 -Xbootclasspath 指定的路径下,虚拟机能够是别的类库(如 rt.jartools.jar)

  • ExtClassLoader(扩展类加载器)

    BootStrapClassLoader 外,其它类加载器都直接或间接地继承 java.lang.ClassLoader。负责加载 $JAVA_HOME/lib/ext 或被 java.ext.dirs 系统变量指定的路径下的所有类库。其父类加载器是 BootStrapClassLoader (这里指的不是继承关系)。

  • AppClassLoader(系统/应用类加载器)

    由于 ClassLoader#getSystemClassLoader() 方法返回的是 AppClassLoader ,所以也称为 “系统类加载器”。负责加载用户类路径(ClassPath)下的所有类库。其父类加载器是 ExtClassLoader

    实现自定义类加载器,只需继承 ClassLoader 重写 findClass() 方法即可。重写时,你可能还需要使用 defineClass() 方法,将字节数组转成 Class 对象。

双亲委派机制

​ 除了 BootStrapClassLoader 外,其它类加载器都有父类加载器。当类加载器加载一个类时,它首先做的不是马上去加载对应类的字节流,而是现将加载动作委托给父类加载器,交由父类加载器完成累的加载。如此递归,每个类的加载最终都将委托到 BootStrapClassLoader。只有在父类加载器无法加载的情况下,才会交由本类进行加载。

双亲委派模型

双亲委派机制

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
	// 获取同步锁,确保该类只加载一次
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        // 首先检查该类是否已经加载过了
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
            	// 如果不是启动类加载器,委托父加载器加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            // 加载失败,异常处理
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
            // 父加载器加载失败时
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                // 由本类加载器加载
                c = findClass(name);
                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

作用

  • 双亲委派的同时,获取同步锁,防止重复加载同一个类。
  • 保证基础类不被篡改,如 java.lang.Objectjava.lang.String 最终由 BootStrapClassLoader 加载。并且就算被其他类加载器加载,由于加载器的命名空间,也不是同一个类,确保类的安全。(实际上,包名以 java. 开头的类,在运行时会抛 java.lang.SecurityException)。

打破双亲委派机制

重写 loadClass()

​ 默认的 loadClass() 方法,实现了双亲委派机制。一般情况下,实现自定义的类加载器,只需要重写 findClass() 即可。如果重写 loadClass(),也就打破了双亲委派机制。

SPI

SPI(Service Provider Interface),是一种服务发现机制。

JNDI 服务中,它的代码是由启动类加载器加载的。在调用由其它厂商实现并部署在应用程序的 ClassPath 下的 JNDI 服务提供者接口时,启动类加载器是无法加载 ClassPath 的代码的。为了解决这种窘境,引入了上下文类加载器,通过 java.lang.Thread#setContextClassLoader() 进行设置。如果创建线程时没有设置,它会从父线程继承一个;如果全局范围内都没有设置,那默认就是应用程序类加载器。

JDK 6 时,JDK 提供 java.util.ServiceLoader ,通过在 ClassPath 路径下的 META-INF/services 文件夹下,查找文件,加载文件定义的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
 * output:
 * SpiServiceImpl1.execute()
 * sun.misc.Launcher$AppClassLoader@18b4aac2
 * SpiServiceImpl2.execute()
 * sun.misc.Launcher$AppClassLoader@18b4aac2
 */
public class SpiTest {
    public static void main(String[] args) {
        ServiceLoader<SpiService> load = ServiceLoader.load(SpiService.class);
        Iterator<SpiService> iterator = load.iterator();
        
        while (iterator.hasNext()) {
            SpiService service = iterator.next();
            service.execute();
            System.out.println(service.getClass().getClassLoader());
        }
    }
}

interface SpiService {
    void execute();
}

public class SpiServiceImpl1 implements SpiService {
    @Override
    public void execute() {
        System.out.println("SpiServiceImpl1.execute()");
    }
}

public class SpiServiceImpl2 implements SpiService {
    @Override
    public void execute() {
        System.out.println("SpiServiceImpl2.execute()");
    }
}

META-INF/services/name.guolanren.spi.SpiService

1
2
name.guolanren.spi.SpiImpl1
name.guolanren.spi.SpiImpl2

参考

​ [1] 周志明. 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版). 第7章 虚拟机类加载机制