Java类加载器classloader

什么是classloader

转载至阿里巴巴淘系技术:https://www.zhihu.com/question/46719811/answer/1739289578

classloader顾名思义,即是类加载。虚拟机把描述类的数据从class字节码文件加载到内存,并对数据进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

classloader的加载过程

类从被加载到虚拟机内存到被卸载,整个完整的生命周期包括:类加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证,准备,解析三个部分统称为连接。

加载

加载指的是把class字节码文件从各个来源通过类加载器装载入内存

主要任务:

  1. 通过“类全名”来获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口

相对于类加载过程的其他阶段,加载阶段是通过类加载(ClassLoader)来完成的,而类加载器也可以由用户自定义完成,因此,开发人员可以通过定义类加载器去控制字节流的获取方式。加载之后,二进制文件会被读入到虚拟机所需的格式存储在方法区中,方法区中存储格式由虚拟机自行定义,然后在java堆中实例化一个java.lang.Class类对象,通过这个对象就可以访问方法区中的数据。

验证

验证阶段是链接阶段的第一步,目的就是确保class文件的字节流中包含的信息符合虚拟机的要求,不能危害虚拟机自身安全。验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。

  1. 文件格式的验证,文件中是否有不规范的或者附加的其他信息。例如常量中是否有不被支持的常量。
  2. 元数据的验证,保证其描述的信息符合Java语言规范的要求。例如类是否有父类,是否继承了不被允许的final类等
  3. 字节码的验证,保证程序语义的合理性,比如要保证类型转换的合理性。
  4. 符号引用的验证,比如校验符号引用中通过全限定名是否能够找到对应的类,校验符号引用中的访问性(private,public等)是否可被当前类访问等。

准备

这个阶段就是为类变量分配内存并设置类变量初始值的阶段,这些内存将在方法区中进行分配。

要注意的是,进行分配内存的只是包括类变量,而不包括实例变量,实例变量是在对象实例化时随着对象一起分配在java堆中的。

通常情况下,初始值为零值,假设public static int value = 2;那么value在准备阶段过后的初始值为0,不为2,这时候只是开辟了内存空间,并没有运行java代码,value赋值为2的指令是程序被编译后。

解析

将虚拟机常量池的符号引用替换为直接引用的过程。

比如,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。

在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换成具体的内存地址或偏移量,也就是直接引用。

初始化

初始化阶段是执行类构造器<clinit>()方法的过程。在以下四种情况下初始化过程会被触发执行:

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需先触发其初始化。生成这4条指令的最常见的java代码场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先出发其父类的初始化。
  4. jvm启动时,用户指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个类。

<clinit>()<init>() 的区别

  • <clinit>()

Java 类加载的初始化过程中,编译器按语句在源文件中出现的顺序,依次自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生 <clinit>() 方法。 如果类中没有静态语句和静态代码块,那可以不生成<clinit>()方法。

并且 <clinit>() 不需要显式调用父类(接口除外,接口不需要调用父接口的初始化方法,只有使用到父接口中的静态变量时才需要调用)的初始化方法 <clinit>(),虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。

  • <init>()

对象构造时用以初始化对象的,构造器以及非静态初始化块中的代码。

public class Test {
    private static Test instance;

    static {
        System.out.println("static开始");
        // 下面这句编译器报错,非法向前引用
        // System.out.println("x=" + x);
        instance = new Test();
        System.out.println("static结束");
    }

    public Test() {
        System.out.println("构造器开始");
        System.out.println("x=" + x + ";y=" + y);
        // 构造器可以访问声明于他们后面的静态变量
        // 因为静态变量在类加载的准备阶段就已经分配内存并初始化0值了
        // 此时 x=0,y=0
        x++;
        y++;
        System.out.println("x=" + x + ";y=" + y);
        System.out.println("构造器结束");
    }

    public static int x = 6;
    public static int y;

    public static Test getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        Test obj = Test.getInstance();
        System.out.println("x=" + obj.x);
        System.out.println("y=" + obj.y);
    }
}

输出信息如下:

static开始
构造器开始
x=0;y=0
x=1;y=1
构造器结束
static结束
x=6
y=1

虚拟机首先执行的是类加载初始化过程中的 <clinit>() 方法,也就是静态变量赋值以及静态代码块中的代码,如果 <clinit>() 方法中触发了对象的初始化,也就是 <init>() 方法,那么会进入执行 <init>() 方法,执行 <init>() 方法完成之后,再回来继续执行 <clinit>() 方法。

上面代码中,先执行 static 代码块,此时调用了构造器,构造器中对类变量 x 和 y 进行加 1 ,之后继续完 static 代码块,接着执行下面的 public static int x = 6; 来重新给类变量 x 赋值为 6,因此,最后输出的是 x=6, y=1。

如果希望输出的是 x=7,y=1,很简单,将语句 public static int x = 6; 移至 static 代码块之前就可以了。

初始化顺序依次是:父类static方法 –> 子类static方法 –> 父类构造方法- -> 子类构造方法

记忆

为了方便记忆,我们可以使用一句话来表达其加载的整个过程,家宴准备了西式菜,即家(加载)宴(验证)准备(准备)了西(解析)式(初始化)菜。

classloader双亲委托机制

classloader的双亲委托机制是指多个类加载器之间存在父子关系的时候,某个class类具体由哪个加载器进行加载的问题。其具体的过程表现为:当一个类加载的过程中,它首先不会去加载,而是委托给自己的父类去加载,父类又委托给自己的父类。因此所有的类加载都会委托给顶层的父类,即Bootstrap Classloader进行加载,然后父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。

使用双亲委派模型,Java类随着它的加载器一起具备了一种带有优先级的层次关系,通过这种层次模型,可以避免类的重复加载,也可以避免核心类被不同的类加载器加载到内存中造成冲突和混乱,从而保证了Java核心库的安全。

整个java虚拟机的类加载层次关系如上图所示,启动类加载器(Bootstrap Classloader)负责将/lib 目录下并且被虚拟机识别的类库加载到虚拟机内存中。我们常用基础库,例如java.util.**,java.io.**,java.lang.**等等都是由根加载器加载。

扩展类加载器(Extention Classloader)负责加载JVM扩展类,比如swing系列、内置的js引擎、xml解析器等,这些类库以javax开头,它们的jar包位于 /lib/ext 目录中。

应用程序加载器(Application Classloader)也叫系统类加载器,它负责加载用户路径(ClassPath)上所指定的类库。我们自己编写的代码以及使用的第三方的jar包都是由它来加载的。

自定义加载器(Custom Classloader)通常是我们为了某些特殊目的实现的自定义加载器,如tomcat、jboss都会根据j2ee规范自行实现。

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 {
                //如果parent classloader存在,则委托给父类进行加载
                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;
    }
}

classloader的应用场景

类加载器是java语言的一项创新,也是java语言流行的重要原因这一。通过灵活定义classloader的加载机制,我们可以完成很多事情,例如解决类冲突问题,实现热加载以及热部署,甚至可以实现jar包的加密保护。

依赖冲突

做过多人协同开发的大型项目的同学可能深有感触。基于maven的pom进制可以方便的进行依赖管理,但是由于maven依赖的传递性,会导致我们的依赖错综复杂,这样就会导致引入类冲突的问题。最典型的就是NoSuchMethodException异常了。

例如阿里内部也很多成熟的中间件,由不同的中间件团队来负责。那么当一个项目引入不同的中间件的时候,该如何避免依赖冲突的问题呢?

某个业务引用了消息中间件(例如metaq)和微服务中间件(例如dubbo),这两个中间件也同时引用了fastjson-2.0和fastjson-3.0版本,而业务自己本身也引用了fastjson-1.0版本。这三个版本表现不同之处在于classA类中方法数目不相同,我们根据maven依赖处理的机制,引用路径最短的fastjson-1.0会真正作为应用最终的依赖,其它两个版本的fastjson则会被忽略,那么中间件在调用method2方法的时候,则会抛出方法找不到异常。

或许你会说,将所有依赖fastjson的版本都升级到3.0不是就能解解决问题吗?确实这样能够解决问题,但是在实际操作中不太现实:

  1. 首先,中间件团队和业务团队之间并不是一个团队,并不能做到高效协同
  2. 其次是中间件的稳定性是需要保障的,不可能因为包冲突问题,就升级版本
  3. 更何况一个中间件依赖的包可能有上百个,如果纯粹依赖包升级来解决,不仅稳定性难以保障,排包耗费的时间恐怕就让人窒息了

那如何解决包冲突的问题呢?答案就是pandora(潘多拉),通过自定义类加载器,为每个中间件自定义一个加载器,这些加载器之间的关系是平行的,彼此没有依赖关系。这样每个中间件的classloader就可以加载各自版本的fastjson。因为一个类的全限定名以及加载该类的加载器两者共同形成了这个类在JVM中的惟一标识,这也是阿里pandora实现依赖隔离的基础。

可能到这里,你又会有新的疑惑,根据双亲委托模型,App Classloader分别继承了Custom Classloader,那么业务包中的fastjson的class在加载的时候,会先委托到Custom ClassLoader。这样不就会导致自身依赖的fastjson版本被忽略吗?

确实如此,所以潘多拉又是如何做的呢?

首先每个中间件对应的ModuleClassLoader在加载中间对应的class文件的同时,根据中间件配置的export.index负责将要需要透出的class(主要是提供api接口的相关类)索引到exportedClassHashMap中

然后应用程序的类加载器会持有这个exportedClassHashMap,因此应用程序代码在loadClass的时候,会优先判断exportedClassHashMap是否存在当前类,如果存在,则直接返回,如果不存在,则再使用传统的双亲委托机制来进行类加载。这样中间件MoudleClassloader不仅实现了中间件的加载,也实现了中间件关键服务类的透出。

我们可以大概看下应用程序类加载的过程

热加载

通过classloader我们可以完成对变更内容的加载,然后快速的启动。

常用的热加载方案有好几个,接下来我们介绍下spring官方推荐的热加载方案,即 spring boot devtools 。

首先我们需要思考下,为什么重新启动一个应用会比较慢,那是因为在启动应用的时候,JVM虚拟机需要将所有的应用程序重新装载到整个虚拟机。可想而知,一个复杂的应用程序所包含的jar包可能有上百兆,每次微小的改动都是全量加载,那自然是很慢了。

那么我们是否可以做到,当我们修改了某个文件后,在JVM中替换到这个文件相关的部分而不全量的重新加载呢?而spring boot devtools正是基于这个思路进行处理的。

如上图所示,通常一个项目的代码由以上四部分组成,即基础类、扩展类、二方包/三方包、以及我们自己编写的业务代码组成。上面的一排是我们通常的类加载结构,其中业务代码和二方包/三方包是由应用加载器加载的。

而实际开发和调试的过程中,主要变化的是业务代码,并且业务代码相对二方包/三方包的内容来说会更少一些。因此我们可以将业务代码单独通过一个自定义的加载器Custom Classloader来进行加载,当监控发现业务代码发生改变后,我们重新加载启动,老的业务代码的相关类则由虚拟机的垃圾回收机制来自动回收。其工程流程大概如下。有兴趣的同学可以去看下源码,会更加清楚。

RestartClassLoader为自定义的类加载器,其核心是loadClass的加载方式,我们发现其通过修改了双亲委托机制,默认优先从自己加载,如果自己没有加载到,从parent进行加载。这样保证了业务代码可以优先被RestartClassLoader加载。进而通过重新加载RestartClassLoader即可完成应用代码部分的重新加载。

热部署

热部署本质其实与热加载并没有太大的区别,通常我们说热加载是指在开发环境中进行的classloader加载,而热部署则更多是指在线上环境使用classloader的加载机制完成业务的部署。所以这二者使用的技术并没有本质的区别。那热部署除了与热加载具有发布更快之外,还有更多的更大的优势就是具有更细的发布粒度。我们可以想像以下的一个业务场景。

假设某个营销投放平台涉及到4个业务方的开发,需要对会场业务进行投放。而这四个业务方的代码全部都在一个应用里面。因此某个业务方有代码变更则需要对整个应用进行发布,同时其它业务方也需要跟着回归。因此每个微小的发动,则需要走整个应用的全量发布。这种方式带来的稳定性风险估且不说,整个发布迭代的效率也可想而知了。这在整个互联网里,时间和效率就是金钱的理念下,显然是无法接受的。

那么我们完全可以通过类加载机制,将每个业务方通过一个classloader来加载。基于类的隔离机制,可以保障各个业务方的代码不会相互影响,同时也可以做到各个业务方进行独立的发布。其实在移动客户端,每个应用模块也可以基于类加载,实现插件化发布。本质上也是一个原理。

在阿里内部像阿拉丁投放平台,以及crossbow容器化平台,本质都是使用classloader的热加载技术,实现业务细粒度的开发部署以及多应用的合并部署。

加密保护

众所周期,基于java开发编译产生的jar包是由 .class 字节码组成,由于字节码的文件格式是有明确规范的。因此对于字节码进行反编译,就很容易知道其源码实现了。

因此大致会存在如下两个方面的诉求。

  1. 在服务端,我们向别人提供三方包实现的时候,不希望别人知道核心代码实现,我们可以考虑对jar包进行加密
  2. 在客户端则会比较普遍,那就是我们打包好的apk的安装包,不希望被人家反编译而被人家翻个底朝天,我们也可以对apk进行加密。

jar包加密的本质,还是对字节码文件进行操作。但是JVM虚拟机加载class的规范是统一的,因此我们在最终加载class文件的时候,还是需要满足其class文件的格式规范,否则虚拟机是不能正常加载的。因此我们可以在打包的时候对class进行正向的加密操作,然后,在加载class文件之前通过自定义classloader先进行反向的解密操作,然后再按照标准的class文件标准进行加载,这样就完成了class文件正常的加载。因此这个加密的jar包只有能够实现解密方法的classloader才能正常加载。

更高安全的保障则取决于加密算法的安全性了以及如何保障加密算法的密钥不被泄露的问题了。这有种套娃的感觉,所谓安全基本都是相对的。安全保障只要做到使对方破解的成本高于收益即是安全,所以一定程度的安全性,足以减少很多低成本的攻击了。


   转载规则


《Java类加载器classloader》 锦泉 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录