JAVA系列之类加载机制详解

2022-11-16 00:00:00 变量 方法 初始化 阶段 加载

类的加载机制 ?
双亲委派机制 ?
什么是类加载器 ?
自定义类加载器有哪些应用场景 ?

通常,在关于Java的类加载部分会遇到以上疑问,本文将对类加载重要部分做详细介绍,包括重要的基础概念和应用场景,在编写过程中也帮助作者重新熟悉并加固了知识点,希望在看完后对读者能有所帮助。

一、类加载过程

程序员编写的Java源程序(.java文件)在经过编译器编译之后被转换成字节代码(.class 文件),类加载器将.class文件中的二进制数据读入到内存中,将其放在方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。

类加载的终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

以下是举例说明类加载过程:


二、类生命周期

类的生命周期包括:加载、验证、准备、解析、初始化、使用、卸载7个阶段。其中加载、验证、准备、初始化、卸载5个阶段是按照这种顺序按部就班的开始,而解析阶段则不一定:某些情况下,可以在初始化之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定,其实就是多态),例如子类重写父类方法。


注意:这里写的是按部就班的开始,而不是按部就班地进行或完成,因为这些阶段通常都是互相交叉混合式进行的,通常会在一个阶段执行过程中调用、激活另外一个阶段。

1、加载

加载阶段会做3件事情:

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

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

  • 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

此处点并没指明要从哪里获取、怎样获取,因此这里给开发人员预留了扩展空间。许多Java技术就建立在此基础上,例如:

  • 从ZIP包读取,如JAR、WAR。

  • 从网络中获取,这种场景典型应用场景应用就是Applet。

  • 运行时计算生成,使用较多场景是动态代理技术,如spring AOP。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

2、验证

确保被加载的类的正确性,分为4个验证阶段:

  • 文件格式验证

  • 元数据验证

  • 字节码验证

  • 符号引用验证

验证阶段非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3、准备

为类的静态变量分配内存,并初始化默认值,这些内存是在方法区中分配,需要注意以下几点:

  • 此处内存分配的变量仅包含类变量(static),而不包括实例变量,实例变量会随着对象实例化被分配在java堆中。

  • 这里默认值是数据类型的默认值(如0、0L、null、false),而不是代码中被显示的赋予的值。

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

4、解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。

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

5、初始化

为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。初始化阶段是执行类构造器<client>()方法的过程。

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

  • <clinit>()方法与类构造函数不一样,不需要显示调用父类构造函数,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已执行完毕。

  • 由于父类的<clinit>()方法首先执行,意味着父类中的静态语句块要优先于子类的变量赋值操作,如下所示,终得出的值是2,而不是1。

public class TestClassLoader {
    public static int A = 1;
    static {
        A = 2;
//        System.out.println(A);
    }
    
    static class Sub extends TestClassLoader {
        public static int B = A;
    }
    
    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}
  • <clinit>()方法对于类和接口来说,并不是必须的,若类没有静态语句块,也没有对变量赋值操作,则不会生成<clinit>()方法。

  • 接口与类不同的是,接口不需要先执行父类的<clinit>()方法,只有父接口定义的变量使用时,父接口才会被初始化。另外接口的实现类也不会先执行接口的<clinit>()方法。

  • 虚拟机保证当多线程去初始化类时,只会有一个线程去执行<clinit>()方法,而其他线程则被阻塞。

    <clinit>()方法和<init>()方法区别:

  • 执行时机不同:init方法是对象构造器方法,在new一个对象并调用该对象的constructor方法时才会执行。clinit方法是类构造器方法,是在JVM加载期间的初始化阶段才会调用。

  • 执行目的不同:init是对非静态变量解析初始化,而clinit是对静态变量,静态代码块进行初始化。

三、双亲委派机制

在介绍双亲委派机制前,先来看下类加载器的层次关系图,如下:


  • 启动类加载器(Bootstrap ClassLoader),负责加载存放在$JAVA_HOME\jre\lib下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。

  • 扩展类加载器(Extension ClassLoader),该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载$JAVA_HOME\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader),该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

  • 自定义类加载器(User ClassLoader),如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件。

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

为了更清楚的了解双亲委派机制,我们来看下jdk1.8源码java.lang.ClassLoader.loadClass()方法实现:

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    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;
        }
    }

上面代码注释写的很清楚,首先调用findLoadedClass方法检查是否已加载过这个类,如果没有就调用parent的loadClass方法,从底层一级级往上。如果所有ClassLoader都没有加载过这个类,就调用findClass方法查找这个类,然后又从顶层逐级向下调用findClass方法,终都没找到就抛出ClassNotFoundException。这样设计的目的是保证安全性,防止系统类被伪造。

为了便于理解,以下是加载逻辑示意图:


四、自定义类加载器的应用

自定义类加载器通常有以下四种应用场景:

  • 源代码加密,防止源码泄露

  • 隔离加载类,采用隔离加载,防止依赖冲突。

  • 修改类加载的方式。

  • 扩展加载源。

1、源代码加密

源代码加密的本质是对字节码文件进行操作。我们可以在打包的时候对class进行加密操作,然后在加载class文件之前通过自定义classloader先进行解密操作,然后再按照标准的class文件标准进行加载,这样就完成了class文件正常的加载。因此这个加密的jar包只有能够实现解密方法的classloader才能正常加载。


2、隔离加载类

我们常常遇到头疼的事情就是jar包版本的依赖冲突,写代码五分钟,排包一整天。

举个栗子:

工程里面同时引入了 A、B 两个 jar 包,以及 C 的 v0.1、v0.2 版本,v2 版本的 Log 类比 v1 版本新增了 error 方法,,打包的时候 maven 只能选择 C 的一个版本,假设选择了 v1 版本。到了运行的时候,默认情况下一个项目的所有类都是用同一个类加载器加载的,所以不管你依赖了多少个版本的 C,终只会有一个版本的 C 被加载到 JVM 中。当 B 要去访问 Log.error,就会发现 Log 压根就没有 error 方法,然后就抛异常 java.lang.NoSuchMethodError。这就是类冲突的一个典型案例。


类隔离技术就是用来解决这个问题。让不同模块的 jar 包用不同的类加载器加载。

JVM 提供了一种非常简单有效的方式,我把它称为类加载传导规则:JVM 会选择当前类的类加载器来加载所有该类的引用的类。例如我们定义了 TestA 和 TestB 两个类,TestA 会引用 TestB,只要我们使用自定义的类加载器加载 TestA,那么在运行时,当 TestA 调用到 TestB 的时候,TestB 也会被 JVM 使用 TestA 的类加载器加载。依此类推,只要是 TestA 及其引用类关联的所有 jar 包的类都会被自定义类加载器加载。通过这种方式,我们只要让模块的 main 方法类使用不同的类加载器加载,那么每个模块的都会使用 main 方法类的类加载器加载的,这样就能让多个模块分别使用不同类加载器。这也是 OSGi 和 SofaArk 能够实现类隔离的核心原理。

3、热加载/热部署

在应用运行的时升级软件,无需重新启动的方式有两种,热部署和热加载。

对于Java应用程序来说,热部署就是在服务器运行时重新部署项目,热加载即在运行时重新加载class,从而升级应用。

热加载可以概括为在容器启动的时候起一条后台线程,定时的检测类文件的时间戳变化,如果类的时间戳变掉了,则将类重新载入。对比反射机制,反射是在运行时获取类信息,通过动态的调用来改变程序行为。而热加载则是在运行时通过重新加载改变类信息,直接改变程序行为。

热部署原理类似,但它是直接重新加载整个应用,这种方式会释放内存,比热加载更加干净彻底,但同时也更费时间。

4、扩展加载源

字节码文件可以从数据库、网络、移动设备、甚至是电视机机顶盒进行加载,可以与源代码加密方式搭配使用。比如部分关键代码可以通过移动U盘读取再加载到JVM。

相关文章