JVM 之 类加载器
摘要
-
本文介绍JVM的类加载器
类加载器
-
左侧是JDK中实现的类加载器,通过
parent
属性形成⽗⼦关系。应⽤中⾃定义的类加载器的parent
都是
AppClassLoader
-
右侧是JDK中的类加载器实现类。通过类继承的机制形成体系。未来我们就可以通过继承相关的类实现⾃定义类
加载器。 -
在代码中查看类加载器关系
1 | public class LoaderDemo { |
双亲委派机制
-
当⼀个类加载器要加载⼀个类时,整体的过程就是通过双亲委派机制向上委托查找,如果没有查找到,就向下委托加载。
-
Java 类加载机制中的
双亲委派模型(Parent Delegation Model)
是一种保证了类加载器按照层次结构从上到下来加载类的策略。这种层级化的加载流程确保了应用程序能够安全地加载并使用来自不同来源的类,同时也避免了内存中出现相同类的多个拷贝。 -
以下是双亲委派机制的工作原理:
- 1.当一个类加载器接收到类加载请求时,它首先不会自行尝试去寻找类文件,而是将这个请求委派给它的父类加载器。
- 2.父类加载器同样遵循此规则,它会继续把请求向上委派给它的父类加载器,直到达到根(bootstrapp)类加载器为止。
- 3.根类加载器一般会直接访问本地文件系统来查找类文件,比如 JDK 自带的核心类库,或者在-Xbootclasspath指定的路径下查找。
- 4.如果根类加载器找到了该类,则进行类的加载;如果找不到,则把这个任务交回给发出请求的子类加载器。
- 5.子类加载器也重复步骤 4,若找到则加载,否则传递给下一个子类加载器,直至原始提出请求的类加载器。
- 6.若所有的类加载器都未能找到所需的类,则最终抛出ClassNotFoundException异常。
-
双亲委派有以下几个优点:
- 安全性:由于类加载是从顶层开始,这能防止恶意代码通过加载相同的包名和类名替代系统的关键类。
- 唯一性:每个类都会被特定的类加载器加载一次,即便是在分布式的环境中也能保证类的统一性,避免因为多次加载导致的错误。
- 可靠性:用户自定义的类加载器不用担心基础类已经被加载,它们可以专心于自己需要处理的部分。
-
例如,当应用程序运行时,应用类加载器(Application ClassLoader)接收到对java.lang.String类的加载请求时,它会首先将请求传递给扩展类加载器(Extension ClassLoader),后者再传递给引导类加载器(Bootstrap ClassLoader)。引导类加载器会在其搜索路径中找到String类,并完成加载过程。如果应用程序试图提供自己的String类,由于双亲委派的存在,应用程序所指定的类并不会被加载,从而保证了平台核心 API 的一致性。
-
总之,双亲委派机制是 Java 类加载过程中一个非常重要的特性,它不仅维护了类加载的安全性和一致性,也为开发者提供了灵活定制类加载规则的能力。
每个类加载器查找类的默认路径
-
在 Java 中,每个类加载器都有自己的类路径(Classpath)去查找类文件。下面是几个主要的类加载器以及它们的默认查找路径:
类加载器名称 | 类名 / 别名 | 父类加载器 | 加载内容描述 | 默认查找路径 / 系统属性 |
---|---|---|---|---|
启动类加载器 | BootstrapClassLoader | 无 | 加载 核心 Java 类库 | <JAVA_HOME>/jre/lib/rt.jar (JDK8 及之前)或 sun.boot.class.path 指定路径 |
扩展类加载器 | ExtClassLoader | BootstrapClassLoader | 加载 Java 平台扩展类库 | <JAVA_HOME>/jre/lib/ext 或由 java.ext.dirs 系统属性指定 |
应用类加载器 | AppClassLoader | ExtClassLoader | 加载 应用程序类(用户编写的代码) | -classpath 、-cp 参数、CLASSPATH 环境变量、java.class.path 属性、当前目录 (.) |
自定义类加载器 | 用户自定义 | 可指定 | 可通过继承 ClassLoader 并重写 findClass() 方法实现自定义类加载逻辑,如从网络、数据库等加载类文件 |
默认继承父加载器查找路径,也可自定义 |
-
需要注意的是,除了 Bootstrap ClassLoader 外,其他的所有类加载器最终都是java.lang.ClassLoader的子类,并且每个类加载器实例都有一个直接的父类加载器。如果你创建了一个新的类加载器,它将继承 Application ClassLoader 作为它的父类,除非你在创建时指定了不同的父类加载器。
-
此外,Java 9 引入了模块化系统后,类加载机制也有了一些变化,对于模块路径上的类加载,会使用新的层次结构来处理,这使得类加载过程更加灵活同时保持了向后的兼容性。不过,对于传统的类路径上的类加载,双亲委派模型仍然适用。
类加载器名称 | 类名 / 别名 | 父类加载器 | 加载模块范围 | 默认查找路径 / 模块来源 |
---|---|---|---|---|
引导类加载器 | BootstrapClassLoader |
无 | 加载 java.* 模块,例如 java.base (包含 java.lang , java.util 等) |
$JAVA_HOME/jmods 中的模块文件,核心模块由 JVM 启动时直接加载 |
平台类加载器 | PlatformClassLoader |
BootstrapClassLoader | 加载 jdk.* 、javafx.* 等平台扩展模块 |
$JAVA_HOME/jmods ,模块名为 jdk.* 、javafx.* 等; |
应用类加载器 | AppClassLoader |
PlatformClassLoader | 加载应用模块(用户代码),包括模块路径 --module-path 和类路径 -classpath 下的类 |
应用程序编写的模块或类,来自命令行 --module-path 、-classpath 参数、CLASSPATH 环境变量等 |
自定义类加载器 | 用户自定义 | 可指定父加载器 | 加载非标准位置的类,可用于插件、网络加载、加密类加载等 | 默认遵循双亲委派,可重写 findClass() 实现自定义路径查找或其他数据源,如网络、数据库、加密文件等 |
jdk9+中,在委派给父加载器加载前,先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。
-
在代码中查看类加载路径
1 | // BootStrap Classloader,加载java基础类。 |
双亲委派机制的实现原理
-
java.lang.ClassLoader 类的 loadClass 方法是双亲委派的核心实现
1 | /** |
-
该方法实现了类加载的基本逻辑,包括检查类是否已加载、委托父类加载器加载、自行加载类以及解析类等步骤。该方法是 Java 类加载机制的核心部分之一,遵循双亲委派模型。
-
这个⽅法是
protected
声明的,意味着,是可以被⼦类覆盖的,所以,双亲委派机制也是可以被打破的,如Tomcat⼦类重写这个⽅法,并使⽤自己的类加载逻辑。
沙箱保护机制
-
沙箱保护机制是 Java 虚拟机提供的一种安全机制,用于保护应用程序免受恶意代码的攻击。
-
java.lang.ClassLoader 类的 preDefineClass 方法是沙箱保护机制的核心实现
1 | // 这个方法在双亲委派模型之前被调用,用于在加载类之前进行一些预处理操作。 |
-
这个方法
preDefineClass
的作用是在类被定义之前进行一些安全检查和准备工作,确保类的合法性与安全性。它通常用于自定义类加载器中,以增强类加载过程中的安全控制。 -
方法作用详解:
- 1.防止定义非法类名的类(如 java.* 包下的类):
- 如果尝试加载的类属于 java. 开头的标准包(如 java.lang, java.util 等),会抛出 SecurityException。
- 这是为了防止用户自定义类伪装成 Java 核心类库中的类,从而造成安全风险。
- 2.校验类名合法性:
- 调用 checkName(name) 检查类名是否合法(例如不能包含 /、非法字符等),若不合法则抛出 NoClassDefFoundError。
- 3.证书一致性校验(签名一致性校验):
- 如果类有名称且提供了 ProtectionDomain,会调用 checkCerts(name, codeSource) 来确保当前类的签名与其所在包中其他类的签名一致。
- 防止同一包中混入不同签名的类,避免潜在的恶意篡改。
- 4.设置默认保护域(ProtectionDomain):
- 如果传入的 ProtectionDomain 为 null,则使用类加载器的默认域 defaultDomain。
- 1.防止定义非法类名的类(如 java.* 包下的类):
Linking链接过程
-
在ClassLoader的
loadClass
⽅法中,还有⼀个不起眼的步骤,resolveClass
。这是⼀个native
⽅法。⽽其实现的过程称为linking-链接
。 -
链接过程的实现功能如下图:
-
其中关于半初始化状态就是JDK在处理⼀个类的static静态属性时,会先给这个属性分配⼀个默认值,作⽤是占住内存。然后等连接过程完成后,在后⾯的初始化阶段,再将静态属性从默认值修改为指定的初始值。
-
符号引⽤和直接引⽤
如果A类中有⼀个静态属性,引⽤了另⼀个B类。那么在对类进⾏初始化的过程中,因为A和B这两个类都没有初始化,JVM并不知道A和B这两个类的具体地址。所以这时,在A类中,只能创建⼀个不知道具体地址的引⽤,指向B类。这个引⽤就称为符号引⽤。⽽当A类和B类都完成初始化后,JVM⾃然就需要将这个符号引⽤转⽽指向B类具体的内存地址,这个引⽤就称为直接引⽤。
-
来看一个有意思的示例
1 | class Apple { |
-
这里有一个有意思的问题,就是只有当
loadClass
方法中的resolve
参数为true
时resolveClass
方法才会被调用,但是大部分情况下,resolve
参数都是false
,这不是强制限制而是出于以下原因:-
- 避免递归解析中出现错误或死循环
在类的解析过程中,如果该类引用了另一个还没加载的类,立即解析可能会导致无限递归或加载顺序问题。通过延迟解析,可以更好地控制加载流程。
- 避免递归解析中出现错误或死循环
-
- 提高加载效率
加载类可能不一定马上就用到所有方法、字段等符号引用,推迟解析可以提高性能,尤其在批量加载很多类时。
- 提高加载效率
-
- 更灵活地处理类的依赖
开发者可以先加载类,稍后根据需要再解析。例如,在自定义类加载器中,可能先判断类是否已经存在、是否需要被增强(比如字节码增强),再决定是否解析。
- 更灵活地处理类的依赖
-
通过类加载器引⼊外部Jar包
-
虽然通常我们会将依赖的 jar 包直接放入项目的 classpath 中(比如通过构建工具如 Maven 或 Gradle 管理),但在某些特定场景下,我们确实需要动态地通过 URLClassLoader 加载外部 jar 包,这是 Java 提供的一种更灵活的类加载机制。
-
以下是一些必须或推荐使用 URLClassLoader 的典型应用场景:
1. 插件机制(Plugin System)
-
场景说明:
系统支持用户自定义插件(如 IDE 插件、浏览器扩展、游戏 mod),这些插件在运行时才加载,项目本身在编译期并不知道有哪些插件。 -
举例:
Eclipse 或 IntelliJ 的插件系统
Minecraft 的 mod 加载器
Spring Boot Devtools 重新加载机制 -
为什么不能直接放入 classpath?
因为插件是动态发现和加载的,不是编译时确定的。
2. 热部署 / 动态加载类
-
场景说明:
你想在应用运行过程中加载新的 jar 或类,比如热更新一个模块而无需重启服务。 -
举例:
Web 容器(如 Tomcat)的应用重新部署
使用 URLClassLoader 加载某个模块的新版本以实现热替换 -
为什么不能放入 classpath?
classpath 在启动时就固定了,不能动态添加;而 URLClassLoader 可以运行时加载新 jar。
3. 多版本隔离
-
场景说明:
你希望不同的模块使用同一个库的不同版本,但 classpath 无法支持两个版本的同一个类。 -
举例:
一个服务器运行多个服务实例,它们分别依赖 log4j 的不同版本
一个系统的插件 A 使用 fastjson 1.x,插件 B 使用 fastjson 2.x -
为什么不能放入 classpath?
classpath 是共享的,会发生类冲突。使用多个 URLClassLoader,可实现类隔离。
4. 脚本或用户上传代码执行
-
场景说明:
系统允许用户上传 jar 或 class 文件,然后在服务端执行其中的类逻辑。 -
举例:
在线编程平台(如 LeetCode 后端)
用户上传算法 jar,平台运行并返回结果 -
为什么不能放入 classpath?
用户上传内容是动态的,系统在运行前无法预知。
5. 实现类的延迟加载(节省资源)
-
场景说明:
某些类/模块体积较大或依赖较多,不希望在程序启动时就加载,只有真正使用时再加载。 -
举例:
大型桌面应用(如 IntelliJ)在打开某个功能模块时才加载相应 jar
总结
场景 | 使用 URLClassLoader 的原因 |
---|---|
插件系统 | 插件动态加载,不在项目编译时可知 |
热部署 | 动态替换模块,无需重启 |
多版本共存 | 避免类冲突,实现类加载隔离 |
用户上传 jar | 内容动态生成,classpath 无法预先配置 |
延迟加载模块 | 启动更快,节省内存 |
-
如果你是在做框架设计或需要动态扩展能力的场景,理解并使用 URLClassLoader 会非常有帮助。
场景假设
调用外部jar
-
我们有一个外部 jar 文件:hello-plugin.jar,它包含一个类:
1 | // 这个类编译后打包进 hello-plugin.jar |
-
主程序使用 URLClassLoader 动态加载这个 jar
1 | import java.io.File; |
运行时动态编译 Java 源代码
-
我们有一个 java 代码片段:
1 | import javax.tools.JavaCompiler; |
-
关键点说明
机制 | 说明 |
---|---|
JavaCompiler |
JDK 自带的编译器(tools.jar ) |
ToolProvider.getSystemJavaCompiler() |
只能在 JDK 环境中工作,JRE 无法使用 |
URLClassLoader |
用于加载编译后的 .class 文件 |
FileWriter |
保存代码为临时 Java 文件 |
自定义类加载器
-
要自定义 ClassLoader,只需要继承于
ClassLoader
或者SecureClassLoader
,并重写findClass()
方法即可 -
示例:我们对上面的代码进行升级,不创建临时文件,而是在内存中编译代码,并从内存中加载字节码,而不依赖磁盘文件,更高效也更适合生产环境中动态类加载(如在线代码执行、脚本引擎等)。
1 | import javax.tools.*; |
-
进一步升级:我们现在将这个系统扩展为支持多个类同时动态编译、内存加载并执行。这对于需要处理多个类(例如接口 + 实现、内部依赖等)非常实用。
1 | import javax.tools.*; |
打破双亲委派,实现同类多版本共存
-
假设我们有同一个jar包的不同版本,比如:
a-1.0.jar
和a-2.0.jar
,他们具有同名的DemoClass
类 ,系统classpath
中引入的是a-1.0.jar
,而此时我们通过⾃定的ClassLoader加载a-2.0.jar
,并调用DemoClass
类 ,我们会发现,⾃定的ClassLoader加载的类依旧是a-1.0.jar
中的类,而不是a-2.0.jar
中的类。 -
为什么会出现这种情况呢?这就是因为JDK的双亲委派机制。⾃定的ClassLoader的
parent
属性指向的是JDK内的AppClassLoader
,⽽AppClassLoader
会加载系统当中的所有代码,就包括a-1.0.jar
中的DemoClass
类。这时,⾃定的ClassLoader去加载DemoClass
类时,通过双亲委派向上查找,⾃然加载出来的就是APPClassloader中的DemoClass
了。 -
如何打破双亲委派呢?我们可以通过重写
loadClass()
方法,来打破双亲委派,实现类⽂件的加载。
1 | import java.io.ByteArrayOutputStream; |
-
测试代码
1 | import java.io.File; |
打破双亲委派机制的典型场景
Tomcat类加载器
-
CommonClassLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
-
CatalinaClassLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可⻅;
-
SharedClassLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可⻅,但是对于Tomcat容器不可⻅;
-
WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可⻅,⽐如加载war包⾥相关的类,每个war包应⽤都有⾃⼰的WebappClassLoader,实现相互隔离,⽐如不同war包应⽤引⼊了不同的spring版本,这样实现就能加载各⾃的spring版本;
-
Jsp类加载器:针对每个JSP⻚⾯创建⼀个加载器。这个加载器⽐较轻量级,所以Tomcat还实现了热加载,也就是JSP只要修改了,就创建⼀个新的加载器,从⽽实现了JSP⻚⾯的热更新。