Java类加载器的使用

Java类加载器

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

先认识一下类加载器在jvm中所处的位置

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wAPWcPDV-1642959393511)(images/image-20220124011318946.png)]类从被加载到虚拟机内存到被卸载,整个完整的

它在JVM外部,负责将class文件,解析成JVM能识别的Java的类

类加载器ClassLoader中它生命周期包括加载、链接、初始化

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zELT96BW-1642959393511)(images/image-20220124012023321.png)]

链接又分为 验证,准备,解析三个部分

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nh3x2cU3-1642959393512)(images/image-20220124012229360.png)]

很抽象没关系,我们有口诀“家宴准备了西式菜”,即家(加载)宴(验证)准备 (准备)了西(解析)式(初始化)菜。

生命周期包括:类加载、验证、准备、解析、初始化、使用和卸载七个阶段。

《深入理解JVM》有详细介绍

虽然classloader的加载过程有复杂的5步,但事实上除了加载之外的四步,其它都是由JVM虚拟机控制的,我 们除了适应它的规范进行开发外,能够干预的空间并不多。而加载则是我们控制classloader实现特殊目的最重 要的手段了。也是接下来我们介绍的重点了。

用加载器可以完成什么工作呢

1.加载类

这当然是它最本职也是最基础的工作

通过使用不同的类加载器,可以从不同来源加载类的二进制数据,通常有如下几种来源。

从本地文件系统加载class文件,这是前面绝大部分示例程序的类加载方式。

  • 本地类的加载是优先于jar包类的加载的,之前启动应用碰到一个问题。我启动的A应用,在启动时候会去请求B应用,但是B应用的这个接口已经下线了,没有返回结果就启动失败,我们可以通过在自己应用同包名下创建一个同名类,剔除掉引用第三方接口的数据,这样类加载的时候先从本地文件加载,就解决了应用无法启动的问题。
  • 从JAR包加载class文件,这种方式也是很常见的,前面介绍JDBC编程时用到的数据库驱动类就放在JAR文件中,JVM可以从JAR文件中直接加载该class文件。
  • 通过网络加载class文件。
  • 把一个Java源文件动态编译,并执行加载。

2.保证Java程序的稳定运作

3.解决依赖冲突

4.热加载

5.热部署

6.加密保护

下面逐个介绍这些功能是怎么被实现的。

首先要引入一个概念——双亲委派机制

这是Java设计者们推荐给开发者的一种类加载器实现的最佳实践。

(本文只讨论JDK9以前的类加载,对于9及9以后的,自定义类加载器部分也适用),

JDK9以前Java应用都是由三种类加载器互相配合来完成加载的,而这三种加载器就是通过双亲委派机制来加载的。

双亲委派机制

1.加载类

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的 加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ArAaKR0z-1642959393512)(images/image-20220123225658384.png)]

  • 扩展内容 下面是jdk9的示意图在这里插入图片描述

说人话就是 加载器拿到类加载请求先给爸爸干,爸爸拿到请求先给爷爷干,爷爷干不了的爸爸干,爸爸干不了的自己干,有自定义加载器也是这个顺序类推就行。这样就统一加载顺序,先加载根类,再加载扩展类,再加载应用类,再加载自定义的类。

它的实现却非常简单,用以实现双亲委派的代码只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中,

    protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 首先,检查请求的类是否已经被加载过了
        Class c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
            // 如果父类加载器抛出ClassNotFoundException
            // 说明父类加载器无法完成加载请求
            }
            if (c == null) {
                // 在父类加载器无法加载时
                // 再调用本身的findClass方法来进行类加载
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

2.保证Java程序的稳定运作

双亲委派模型对于保证Java程序的稳定运作极为重要,

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

说人话就是 确定内存中类的唯一性

这样哪怕自己也写一个String类,类加载器也会加载jdk中rt.jar中的String类,避免恶意代码植入,避免引入时无法区分加载哪个类。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GdjzHmOH-1642959393512)(images/image-20220123224254250.png)]

我明明是在自己写的String里面执行main方法,却提示找不到main方法,说明真实加载的类其实是原本的String类,故而没有main方法,这样就保护了jdk中本身存在的类不会被恶意篡改。

扩展:唯一性 任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性

比如我们平时定义的类也是通过包名+类名确定唯一性的,其实这是默认在同一类加载器下

/**
 * 类加载器与instanceof关键字演示
 */
public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1)+".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object obj = myLoader.loadClass("com.example.classloader.ClassLoaderTest").newInstance();
        System.out.println(obj.getClass());
        System.out.println(obj instanceof com.example.classloader.ClassLoaderTest );
        System.out.println(obj.getClass().equals(com.example.classloader.ClassLoaderTest.class));
    }
}
运行结果:
class com.example.classloader.ClassLoaderTest
false
false

例子中,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,但只要加载它们的类加载器不同,那这两个类就必定不相等。

从第一行可以看到这个对象确实是类com.example.classloader.ClassLoaderTest实例化出来的,但在第二行的输出中却发现这个对象与类com.example.classloader.ClassLoaderTest做所属 类型检查的时候返回了false。这是因为Java虚拟机中同时存在了两个ClassLoaderTest类,一个是由虚拟机的应用程序类加载器所加载的,另外一个是由我们自定义的类加载器加载的,虽然它们都来自同一 个Class文件,但在Java虚拟机中仍然是两个互相独立的类,做对象所属类型检查时的结果自然为 false,第三行false也是同理。

非双亲委派机制

3.解决依赖冲突

相信只要用过maven协同开发大型项目的同学都有过这个苦恼,基于maven的pom进制可以方便的进行依赖管理,但是由于maven依赖的传递性,会导致我们的依赖错综复杂,这样就会导致引入类冲突的问题。最典型的就是 NoSuchMethodError错误。

可以参考我之前写的博文

https://blog.csdn.net/wdays83892469/article/details/117204426?spm=1001.2014.3001.5501

那么当一个项目引入不同的中间件的时候,很容易就会带入一些不一样版本的依赖,比如引用fastjson版本不一致。场景如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CLO689LJ-1642959393513)(images/image-20220123231802158.png)]

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

或许你会说,将所有依赖fastjson的版本都升级到3.0不是就能解解决问题吗?确实这样能够解决 问题,但是在实际操作中不太现实,首先,中间件团队和业务团队之间并不是一个团队,并不能做到高效协同,其次是中间件的稳定性是需要保障的,不可能因为包冲突问题,就升级版本,更何况一个中间件依赖的包可能有 上百个,如果纯粹依赖包升级来解决,不仅稳定性难以保障,排包耗费的时间恐怕就让人窒息了。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KaxVzZBZ-1642959393513)(images/image-20220123232433528.png)]

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

现在把视野回到一开始提过的一句 双亲委派机制是Java设计者们推荐给开发者的一种类加载器实现的最佳实践。

也就是说,这只是一个推荐,和饮料的建议零售价是一个道理,我可以自定义顺序去加载类,这样就避免了自身依赖的fastjson版本被忽略。

比如我们可以重新定义一个exportedClassHashMap,用于存放中间件使用到的类,让应用程序类加载器首先使用exportedClassHashMap来加载类。如果exportedClassHashMap没有加载到再使用默认的双亲委派加载。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QcuPQSbH-1642959393514)(images/image-20220123233751962.png)]

阿里实现如图所示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8VwfRLAu-1642959393514)(images/image-20220124005829617.png)]

   /**
   classLoader源码
   **/
	protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先检查这个类是否已经被加载了
            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
					// 说明父类加载器无法完成加载请求

                }
				if (c == null) {
                    // 在父类加载器无法加载时
					// 再调用本身的findClass方法来进行类加载
                    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;
        }
    }
   /**
   如下改造即可
   **/
	@Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 导出类中是否存在,如果存在则直接返回
        if (classCache != null && classCache.containsKey(name)) {
            return classCache.get(name);
        }
        // 双亲委派机制加载
        return super.loadClass(name, resolve);
    }

4.热加载

在开发项目的时候,我们需要频繁的重启应用进行程序调试,但是java项目的启动少则几十秒,多则几分钟。 如此慢的启动速度极大地影响了程序开发的效率,那是否可以快速的进行启动,进而能够快速的进行开发验证 呢?答案也是肯定的,通过classloader我们可以完成对变更内容的加载,然后快速的启动。 常用的热加载方案有好几个,接下来我们介绍下spring官方推荐的热加载方案,即spring boot devtools。

首先我们需要思考下,为什么重新启动一个应用会比较慢,那是因为在启动应用的时候,JVM虚拟机需要将所有的 应用程序重新装载到整个虚拟机。可想而知,一个复杂的应用程序所包含的jar包可能有上百兆,每次微小的改动都 是全量加载,那自然是很慢了。那么我们是否可以做到,当我们修改了某个文件后,在JVM中替换到这个文件相关 的部分而不全量的重新加载呢?而spring boot devtools正是基于这个思路进行处理的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Hr4QTyNI-1642959393515)(images/image-20220124000158547.png)]

如上图所示,通常一个项目的代码由以上四部分组成,即基础类、扩展类、二方包/三方包、以及我们自己编写的 业务代码组成。上面的一排是我们通常的类加载结构,其中业务代码和二方包/三方包是由应用加载器加载的。而 实际开发和调试的过程中,主要变化的是业务代码,并且业务代码相对二方包/三方包的内容来说会更少一些。因 此我们可以将业务代码单独通过一个自定义的加载器Custom Classloader来进行加载,当监控发现业务代码发生 改变后,我们重新加载启动,老的业务代码的相关类则由虚拟机的垃圾回收机制来自动回收。其工程流程大概如 下。有兴趣的同学可以去看下源码,会更加清楚。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qTK6iDxX-1642959393515)(images/image-20220124000214496.png)]

RestartClassLoader为自定义的类加载器,其核心是loadClass的加载方式,我们发现其通过修改了双亲委托机制,默认优先从自己加载,如果自己没有加载到,从从parent进行加载。

这样保证了业务代码可以优先被 RestartClassLoader加载。

进而通过重新加载RestartClassLoader即可完成应用代码部分的重新加载。

protected Class<?> loadClassz`(String name, boolean resolve)throws ClassNotFoundException {
        String path = name.replace('.','/').concat(".class");
        ClassLoaderFile file = this.updatedFiles.getFile(path);
        if (file != null && file.getKind() == Kind.DELETED) {
            throw new ClassNotFoundException();
        }
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> loadedClass = findLoadedClass(name);
            if (loadedClass == null) {
                try {
                    // 优先从自己加载
                    loadedClass = findClass(name);
                }catch (ClassNotFoundException exception){
                    // 如果没有加载到,则从父类加载
                    loadedClass = Class.forName(name,false,getParent());
                }
            }
            if (resolve) {
                resolveClass(loadedClass);
            }
            return loadedClass;
        }
    }

5.热部署

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IlvO83RJ-1642959393515)(images/image-20220124001430807.png)]

假设某个营销投放平台涉及到4个业务方的开发,需要对会场业务进行投放。而这四个业务方的代码全部都在一个应 用里面。因此某个业务方有代码变更则需要对整个应用进行发布,同时其它业务方也需要跟着回归。因此每个微小的发动,则需要走整个应用的全量发布。这种方式带来的稳定性风险估且不说,整个发布迭代的效率也可想而知了。这在整个互联网里,时间和效率就是金钱的理念下,显然是无法接受的。 那么我们完全可以通过类加载机制,将每个业务方通过一个classloader来加载。基于类的隔离机制,可以保障各个业务方的代码不会相互影响,同时也可以做到各个业务方进行独立的发布。其实在移动客户端,每个应用模块也可以基于类加载,实现插件化发布。本质上也是一个原理。 在阿里内部像阿拉丁投放平台,以及crossbow容器化平台,本质都是使用classloader的热加载技术,实现业务细粒度的开发部署以及多应用的合并部署。

6.加密保护

众所周期,基于java开发编译产生的jar包是由.class字节码组成,由于字节码的文件格式是有明确规范的。因此对 于字节码进行反编译,就很容易知道其源码实现了。因此大致会存在如下两个方面的诉求。例如在服务端,我们向 别人提供三方包实现的时候,不希望别人知道核心代码实现,我们可以考虑对jar包进行加密,在客户端则会比较普 遍,那就是我们打包好的apk的安装包,不希望被人家反编译而被人家翻个底朝天,我们也可以对apk进行加密。 jar包加密的本质,还是对字节码文件进行操作。但是JVM虚拟机加载class的规范是统一的,因此我们在最终加载 class文件的时候,还是需要满足其class文件的格式规范,否则虚拟机是不能正常加载的。因此我们可以在打包的 时候对class进行正向的加密操作,然后,在加载class文件之前通过自定义classloader先进行反向的解密操作,然 后再按照标准的class文件标准进行加载,这样就完成了class文件正常的加载。因此这个加密的jar包只有能够实现 解密方法的classloader才能正常加载。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gXUIa9AT-1642959393516)(images/image-20220124001549563.png)]

简单的实现方案

protected Class<?> loadClass2(String name, boolean resolve)throws ClassNotFoundException {

        synchronized (getClassLoadingLock(name)) {
            Class clazz = findLoadedClass(name);
            if (clazz != null) {
                return clazz;
            }
            // 提前对class文件进行解密
            Class<?> loadedClass = findLoadedClass(name);
            try {
            // 读取经过加密的类文件
                byte classData[] = Util.readFile("name." + class);
                if (classData != null) {
                    // 解密
                    byte decryedClassData[] = ciper.doFinal(classData);
                    // 转成一个类
                    clazz = defineClass( name, decryedClassData, 0,decryedClassData.length);
                }
            }catch (ClassNotFoundException e){
                e.printStackTrace();
            }
            // 必须的步骤2
            // 尝试用默认的ClassLoader装入它
            if (resolve && clazz != null) {
                clazz = findSystemClass(name);
            }
            if (resolve) {
                resolveClass(loadedClass);
            }
            return loadedClass;
        }
    }

这样整个jar包的安全性就有一定程度的提高,至于更高安全的保障则取决于加密算法的安全性了以及如何保障 加密算法的密钥不被泄露的问题了。这有种套娃的感觉,所谓安全基本都是相对的。并且这些方法也不是绝对 的,例如可以通过对classloader进行插码,对解密后的class文件进行存储;另外大多数JVM本身并不安全, 还可以修改JVM,从ClassLoader之外获取解密后的代码并保存到磁盘,从而绕过上述加密所做的一切工作, 当然这些操作的成本就比单纯的class反编译就高很多了。所以说安全保障只要做到使对方破解的成本高于收益 即是安全,所以一定程度的安全性,足以减少很多低成本的攻击了。

本文对classloader的加载过程和加载原理进行了介绍,并结合类加载机制的特征,介绍了其相应的使用场景。 由于篇幅限制,并没有对每种场景的具体实现细节进行介绍,而只是阐述了其基本实现思路。或许大家觉得 classloader的应用有些复杂,但事实上只要大家对class从哪里加载,搞清楚loadClass的机制,就已经成功 了一大半。正所谓万变不离其宗,抓住了本质,其它问题也就迎刃而解了。

参考资料

《码出高效:Java开发手册》 杨冠宝(孤尽) 高海慧(鸣莎) p100

《深入理解Java虚拟机:JVM高级特性与最佳实践》(第3版)7.4 类加载器

Java类加载器 — classloader 的原理及应用 金雅博(行泽) 出品:淘系技术

https://www.bilibili.com/video/BV1iJ411d7jS?p=3 狂神说Java

公众号:欢迎关注,分享读书笔记,JAVA技术

在这里插入图片描述

下篇简单介绍几种类加载器的区别:预告如下
只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现[1],是虚拟机自身的一部分;另外一种就是其他所有 的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值