再谈类的加载器

概述

类加载器是 JVM 执行类加载机制的前提。

ClassLoader 的作用:

ClassLoader 是 Java 的核心组件,所有的 Class 都是由 ClassLoader 进行加载的,ClassLoader 负责通过各种方式将 Class 信息的二进制数据流读入 JVM 内部,转换为一个与目标类对应的 java.lang.Class 对象实例。然后交给 Java 虚拟机进行链接、初始化等操作。因此,ClassLoader 在整个装载阶段,只能影响到类的加载,而无法通过 ClassLoader 去改变类的链接和初始化行为。至于它是否可以运行,则由 Execution Engine 决定。

20201025101156

大厂面试题

蚂蚁金服:

深入分析 ClassLoader,双亲委派机制

类加载器的双亲委派模型是什么?

一面:双亲委派机制及使用原因

百度:

都有哪些类加载器,这些类加载器都加载哪些文件?

手写一个类加载器 Demo

Class 的 forName("java.lang.String") 和 Class 的 getClassLoader()loadClass("java.lang.String") 有什么区别?

腾讯:

什么是双亲委派模型?

类加载器有哪些?

小米:

双亲委派模型介绍一下

滴滴:

简单说说你了解的类加载器

一面:讲一下双亲委派模型,以及其优点

字节跳动:

什么事类加载器,类加载器有哪些?

京东:

类加载器的双亲委派模型是什么?

双亲委派机制可以打破吗?为什么?

类加载器的必要性

  • 需要支持类的动态加载或需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了。
  • 开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑。

命名空间

  1. 何为类的唯一性?

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在 Java 虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类源自同一个 Class 文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。

类加载机制的基本特征

通常类加载机制有三个基本特征:

  • 双亲委派模型。但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如 JDK 内部的 ServiceProvider/ServiceLoader 机制,用户可以在标准 API 框架上,提供自己的实现,JDK 也需要提供些默认的参考实现。例如,Java 中 JNDI、JDBC、文件系统、Cipher 等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
  • 可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
  • 单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器”邻居”间,同一类型仍然可以被加载多次,因为相互并不可见。

JVM 支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。

20201025114009

  • 除了顶层的启动类加载器外,其余的类加载器都应当有自己的”父类”加载器。

  • 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用。

引导类加载器

启动类加载器(引导类加载器)

  • 这个类加载使用 C/C++ 语言实现的,嵌套在 JVM 内部
  • 它用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar 或 sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类
  • 并不继承自 java.lang.ClassLoader,没有父加载器
  • 出于安全考虑,Bootstrap 启动类加载器之加载包名为 java、javax、sun 等开头的类
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器

启动类加载器使用 C++ 编写的?Yes!

  • C/C++:指针函数 & 函数指针、C++ 支持多继承、更加高效
  • Java :由 C++ 演变而来,(C++)– 版,单继承

扩展类加载器

扩展类加载器(Extension ClassLoader)

  • Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现
  • 继承于 ClassLoader 类
  • 父类加载器为启动类加载器
  • 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/lib/ext 子目录下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载

系统类加载器

应用程序类加载器(系统类加载器,AppClassLoader)

  • Java 语言编写,由 sun.misc.Launcher$AppClassLoader 实现
  • 继承于 ClassLoader 类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库
  • 应用程序中的类加载器默认是系统类加载器
  • 它是用户自定义类加载器的默认父加载器
  • 通过 ClassLoader 的 getSystemClassLoader() 方法可以获取到该类加载器

用户自定义类加载器

  • 自定义加载器能够实现应用隔离,例如 Tomcat、Spring 等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比 C/C++ 程序要好太多,想不修改 C/C++ 程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡所有美好的设想

  • 自定义类加载器通常需要继承于 ClassLoader

ClassLoader 的主要方法

抽象类 ClassLoader 的主要方法:(内部没有抽象方法)

1
public final ClassLoader getParent()

返回该类加载器的超类加载器。

1
public Class<?> loadClass(String name) throws ClassNotFoundException

加载名称为 name 的类,返回结果为 java.lang.Class 类的实例。如果找不到类,则返回 ClassNotFountException 异常。该方法中的逻辑就是双亲委派模式的实现。

1
protected Class<?> findClass(String name) throws ClassNotFoundException

一般情况下,在自定义类加载器时,会直接覆盖 ClassLoader 的 findClass() 方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用 defineClass() 方法生成类的 Class 对象。

在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URLClassLoader 类,这样就可以避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

双亲委派模型

如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。

规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。

20201025130802

优势与劣势

  • 避免类的重复加载,确保一个类的全局唯一性

  • 保护程序安全,防止核心 API 被随意篡改

双亲委派机制在 java.lang.ClassLoader.loadClass(String, boolean) 接口中体现。该接口的逻辑如下:

  1. 先在当前加载器的缓存中查找有无目标类,如果有,直接返回;
  2. 判断当前加载器的父加载器是否为空,如果不为空,则调用 parent.loadClass(name, false) 接口进行加载;
  3. 反之,如果当前加载器的父类加载器为空,则调用 findBootstrapClassOrNull(name) 接口,让引导类加载器进行加载;
  4. 如果通过以上 3 条路径都没能成功加载,则调用 findClass(name) 接口进行加载。该接口最终会调用 java.lang.ClassLoader 接口的 defineClass 系列的 native 接口加载目标 Java 类。

检查类是否加载的委派过程是单向的,这个方式虽然从结构上说比较清晰,使各个 ClassLoader 的职责非常明确,但是同时会带来一个问题,即顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类。

通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。

破坏双亲委派机制

如果有基础类型又要调用回用户代码,那该怎么办?

一个典型的例子便是 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器来完成加载(在 JDK 1.3 时加入到 rt.jar),肯定属于 Java 中很基础的类型了。但 JNDI 存在的目的就是对资源进行查找和集中管理,它需要调用由其它厂商实现并部署在应用程序的 ClassPath 下的 JNDI 服务提供者接口(Service Provider Interface. SPI))的代码,现在问题来了,启动类加载器时绝对不可能认识、加载这些代码的,那该怎么办?(SPI:在 Java 平台中,通常把核心类 rt.jar 中提供外部服务、可由应用层自行实现的接口称为 SPI)。

为了解决这个困境,Java 的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

有了线程上下文类加载器,程序就可以做一些”舞弊”的事情了。JNDI 服务使用这个线程上下文类加载器去加载所需的 SPI 服务代码。这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则

Java 中涉及 SPI 的加载基本上都采用这种方式来完成,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等

在 JDK 6 时,JDK 提供了 java.util.ServiceLoader 类,以 META-INF/Services 中的配置信息,辅以责任链模式,这才算是给 SPI 的加载提供了一种相对合理的解决方案。

20201025150008

默认上下文加载器就是应用类加载器,这样以上下文加载器为中介,使得启动类加载器中的代码也可以访问应用类加载器中的类。

代码热替换(Hot Swap)

热替换是指在程序运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中。

沙箱安全机制

  • 保护程序安全
  • 保护 Java 原生的 JDK 代码

Java 安全模型的核心就是 Java 沙箱(Sandbox),什么是沙箱?沙箱就是一个限制程序运行的环境。

沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。所有的 Java 程序运行都可以指定沙箱,可以定制安全策略。

自定义类的加载器

  1. 为什么要自定义类加载器?
  • 隔离加载类

在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的 jar 包不会影响到中间件运行时使用的 jar 包。再比如:Tomcat 这类 Web 应用服务器,内部自定义了好几种类加载器,用于隔离同一个 Web 应用服务器上的不同应用程序。(类的仲裁 –> 类冲突)。

  • 修改类加载的方式

类的加载模型并非强制,除 Bootstrap 外,其他的加载并非一定要引入,或者根据实际情况在某个时间点按需进行动态加载。

  • 扩展加载源

比如从数据库、网络、甚至是电视机机顶盒进行加载。

  • 防止源码泄露

Java 代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。

实现方式

用户通过定制自己的类加载器,这样可以重新定义类的加载规则,以便实现一些自定义的处理逻辑。

实现方式

  • Java 提供了抽象类 java.lang.ClassLoader,所有用户自定义的类加载器都应该继承 ClassLoader 类
  • 在自定义 ClassLoader 的子类时候,我们常见的会有两种做法:
    • 方式一:重写 loadClass() 方法
    • 方式二:重写 findClass() 方法

这两种方法本质上差不多,毕竟 loadClass() 也会调用 findClass(),但是从逻辑上讲我们最好不要直接修改 loadClass() 的内部逻辑。建议的做法是只在 findClass() 里重写自定义类的加载方法,根据参数指定类的名字,返回对应的 Class 对象的引用。

  • loadClass() 这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写 loadClass() 方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
  • 当编写好自定义类加载器后,便可以在程序中调用 loadClass() 方法来实现类加载操作。