Java热部署原理及实现
才疏学浅,若有不对之处烦请指正
前言
在大型 Java 项目的开发过程中,开发人员往往需要频繁地修改代码并进行测试,而每次的修改都伴随着项目的重启。可惜的是,对于体量庞大、依赖繁多的项目来说,启动一次可能就需要一两分钟甚至更久,这无疑大大拖慢了开发效率。虽然你可以趁这段时间偷偷摸个🐟——咳咳,当然不是鼓励摸鱼——但从整体来看,这种低效的迭代方式是非常不利于快速开发和调试的。
这个时候,热部署(Hot Deployment)技术就成了提升开发体验的“救星”。它可以在不完全重启应用的前提下,将代码的改动动态加载进正在运行的程序中,大大缩短了等待时间,让我们可以更加专注于业务逻辑的实现和问题的定位。
在实际开发中,“热部署(Hot Deployment)”和“热加载(Hot Reloading)”这两个词经常被交替使用,乍一看差不多,其实在技术实现和粒度上是有区别的:
- 热部署:通常指重新部署应用或模块,而不重启整个 JVM,比如 Web 容器(如 Tomcat)重新加载
.war
包。 - 热加载:指在应用运行时,动态替换某个类或资源文件,代码改动即时生效,甚至不需要重启应用容器。
本文所讲的“热部署”是一个泛指的说法,重点讲的是“类级别”的热更新机制,也就是热加载。
主要通过自定义类加载器打破双亲委派机制,实现运行时动态加载修改后的 .class
文件,达到无需重启即可更新逻辑的效果。
参考链接
1. 热部署是什么
热部署(Hot Deployment)是指在不重启整个应用或 JVM 的前提下,动态加载修改过的类或资源的能力。
通过新的类加载器打破双亲委派机制,再生成新的类对象实例,从而实现动态更新
是不是有点懵
- 编译过后不是生成新的字节码了吗?为什么没有通过新的字节码重新生成类实例?
- 为什么需要新的类加载器?不能用默认的类加载器吗?
- 双亲委派机制是什么?为什么要打破它?
先讲点前置知识
2. 前置知识
类加载机制
简单来讲,类加载机制是JVM将类的字节码文件(.class
)加载到内存并初始化的核心过程,主要分为以下阶段
编译(Compile)
这是整个流程的起点(严格属于Java编译过程,非类加载阶段)。将
.java
源码编译为JVM可识别的.class
字节码文件。也就是javac
加载(Loading)
- 任务:通过类加载器(ClassLoader)查找
.class
文件(可来自本地、网络或动态生成)。 - 核心动作:生成类的Class对象(存储在方法区),作为程序访问类元数据的入口。
- 双亲委派机制:类加载器优先委派父类加载,避免重复加载和安全问题。
- 任务:通过类加载器(ClassLoader)查找
连接(Linking)
- 验证(Verification):检查字节码是否符合
JVM
规范(如魔数、版本、语法)。 - 准备(Preparation):为类变量(static变量)分配内存并赋默认值(如int→0,对象→null)。
- 解析(Resolution):将符号引用(如类/方法名)转为直接引用(内存地址)。此阶段可能延迟到初始化后(动态绑定)。
- 验证(Verification):检查字节码是否符合
初始化(Initialization)
- 触发条件:首次主动使用类时(如new对象、访问静态变量)。
- 核心动作:执行类构造器
<clinit>()
,包括静态变量赋值和静态代码块。
使用(Using)
类完成初始化后,程序可通过Class对象创建实例、调用方法、访问字段等。
卸载(Unloading)
- 条件:类的Class对象无引用,且对应的类加载器被回收。
- 实现:由JVM垃圾回收机制完成,通常发生在长时间不使用的场景。
类加载器
类加载器(ClassLoader)是JVM实现类加载机制的核心组件,负责将字节码(.class
文件)动态加载到内存中,形成可执行的Class
对象。其核心设计基于双亲委派模型,通过层级化的加载器结构保障Java程序的安全性和稳定性。
- BootstrapClassLoader(启动类加载器)
- 职责:加载JRE核心类库(如
rt.jar
、resources.jar
),包含java.lang.*
等基础类。 - 实现:由C/C++代码实现,是JVM的一部分,无Java层面的ClassLoader实例。
- 路径:对应
JAVA_HOME/jre/lib
目录。
- 职责:加载JRE核心类库(如
- ExtClassLoader(扩展类加载器)
- 职责:加载Java扩展库(如
javax.*
),默认路径为JAVA_HOME/jre/lib/ext
或java.ext.dirs
指定目录。 - 父加载器:BootstrapClassLoader
- 职责:加载Java扩展库(如
- AppClassLoader(应用程序类加载器)
- 职责:加载用户类路径(ClassPath)下的类,即程序中的自定义类。
- 父加载器:ExtClassLoader
- 默认加载器:Java程序的
main()
方法默认使用此加载器。
- UserApp1/2ClassLoader(自定义类加载器)
- 职责:开发者扩展类加载方式(如网络加载、加密文件、热部署)。
- 父加载器:AppClassLoader
- 实现:继承
ClassLoader
类,重写findClass()
方法。
同一个类加载器在同一个命名空间内加载某个类时,若该类名和包名完全相同,只会定义一次 Class 对象,后续请求将返回缓存的 Class 实例。
不同的ClassLoader
加载相同的Class
对象之间不可转换,本质上是不同对象
双亲委派机制
类加载器遵循向上委托的加载规则:
1 | 1. 收到类加载请求时,先不自行加载,而是委派给父加载器。 |
- 示例:用户自定义类
User.class
的加载流程:UserApp1ClassLoader
→AppClassLoader
→ExtClassLoader
→BootstrapClassLoader
→ 回退到AppClassLoader
加载ClassPath中的类。 - 核心作用:
✅ 避免重复加载:父加载器已加载的类,子加载器不会重复加载。
✅ 防止核心类篡改:用户无法自定义java.lang.String
等核心类(由Bootstrap加载)。
✅ 沙箱安全:不同层级的类隔离,保障系统稳定性。
源码
- 用户请求加载类
- 当前类加载器检查是否已加载 → 已加载则直接返回
- 未加载 → 委派父加载器递归向上(直到Bootstrap)
- 所有父加载器均未找到 → 当前类加载器调用findClass自行加载
- 返回Class对象,可选执行解析
findLoadedClass方法
这个方法先通过checkName
校验名称的有效性,再调用本地方法findLoadedClass0
findLoadedClass0的作用:查询当前类加载器是否已加载过指定名称的类
JVM 为每个类加载器维护一个独立的命名空间(Namespace),记录该加载器已加载的类。findLoadedClass0
的查询范围仅限于当前类加载器的命名空间。
findBootstrapClassOrNull方法
作用:在双亲委派模型下,尝试从 JVM 的启动类加载器(Bootstrap ClassLoader)中查找并加载指定的类。
它是类加载器在委派链条顶端的“最后一环”,专门用于访问由 JVM 核心加载的核心类库(如 java.lang
包中的类)。
findClass方法
作用:类加载器(ClassLoader
)的 核心扩展点,用于定义自定义类加载逻辑。当类加载器的 loadClass
方法在双亲委派模型下 未能通过父类加载器加载类 时,会调用 findClass
尝试自行加载。ClassLoader
的 findClass
默认抛出 ClassNotFoundException
- 定位类字节码:根据类名(如
com.example.Foo
)找到字节码文件(如.class
文件、网络资源、数据库等)。 - 读取字节码:将字节码转换为
byte[]
。 - 定义类:调用
defineClass
方法将字节码转换为Class
对象。
resolve参数
控制类加载后是否立即解析符号引用:
resolve=true
:提前解析,确保类立即可用,适合需要立即操作类成员的场景。resolve=false
:延迟解析,优化性能,适合批量加载或按需加载的场景。
3. 理论准备
知道了这些,就可以解答上面提到的问题了
问题解答
如果类已经被类加载器加载过,在findLoadedClass
方法的时候就能找到该类的信息,所以不会再次被加载
如果想重新生成类实例的话,同一个类加载器的命名空间中已经记录了该类,就不会再次加载。所以需要新的类加载器重新加载
发散一下
假设重写了 loadClass
方法,修改了 findLoadedClass
逻辑(例如新增字节码变动检测),能否绕过限制?
1 |
|
答案是不行的,为啥嘞?
无法卸载已加载的类:
JVM 的类卸载条件非常苛刻:
- Class 对象、其 ClassLoader 及所有相关引用必须都不可达;但由于类的静态字段或线程栈帧可能持有引用,实际中很难满足
- 对应的类加载器实例已被回收。
即使通过反射清空缓存,也无法强制卸载类(JVM 底层未开放此能力)。
重复加载同名类的冲突:
若尝试通过原类加载器多次加载同一类名(即使字节码已修改),会抛出
LinkageError
,因为 JVM 禁止同一加载器定义同名类。
双亲委派模型的“向上委托”逻辑导致同一个类加载器只能加载一次某个类,即使类文件已被修改,也无法重新加载新版本。为了实现热部署(不重启应用更新代码),必须绕过这一限制,使新的类加载器能独立加载修改后的类。
如何打破双亲委派机制呢
打破双亲委派可能导致类冲突(例如多个版本的同名类共存),需谨慎控制加载范围。
简单,重写 loadClass
方法,加载的时候,如果是你需要热部署的类,就用自定义的类加载器加载
1 | // 自定义类加载器实现热部署 |
另一条歪路
既然loadClass
一开始就会检测类是否被加载了,那在这之前加载类不就行了吗
在类加载器构造时就加载所需类(注:该类不能先被其他加载器加载)
1 | public class HotDeployClassLoader extends ClassLoader { |
这就要聊到垃圾回收机制(Garbage Collection)了,主要和可达性
相关
- 若没有其他引用指向旧类加载器及其加载的类和实例,它们会被回收。
- 若旧类实例仍被引用,如某个
static List<Object> cache
持有引用,则无法回收。
诶,这时候就发现另一个问题了,内存泄漏
及时清理引用:
- 替换类加载器时,确保旧类实例、静态字段、线程引用等被清除。
使用弱引用(WeakReference):
1
2// 使用弱引用缓存,允许GC回收旧类
WeakReference<Class<?>> weakClassRef = new WeakReference<>(oldClass);避免跨加载器引用:
- 不要将旧类加载器加载的对象传递给新类加载器管理的代码(如通过接口隔离)。
这里就不再扩展介绍了,感兴趣的同学可以去研究一下哦
4. 实操
基于以上理论,咱们来上手验证一下
代码
HotDeployObject.java
1 | package org.example.hotdeploy; |
HotDeployClassLoader.java
1 | package org.example.hotdeploy; |
运行结果
结果解析
第一、二遍循环中
默认类加载器一组中,哈希码不变,地址不变。
因为用的类加载器没变,而且第二次的类实例是从缓存里获取的
自定义类加载器一组,哈希码改变,地址改变。
说明类加载器和类实例都发生改变了
第二、三遍循环中(修改了版本号并重新编译)
默认类加载器一组中,哈希码不变,地址不变,类字节码改变了但版本并没有改变。
说明已加载过的类不会再次加载
自定义类加载器一组,哈希码改变,地址改变。
因为类重新加载了,所以会把变更都加载
扩展一下
上面的代码中,只有通过类加载器反射得到的类才是由自定义类加载器加载的,不可能热部署都得用反射的方式才能加载吧,有没有不用反射的打法呢?
有的,孩子,有的
一个类通过其自身代码中 new
关键字引用其他类时,这些类的加载由它的类加载器负责寻找和加载(不一定加载成功,还可能触发双亲委派流程,除非你打破它)
也就是说,可以通过外面再套一层的形式,实现自定义全局的类加载器
Application.java
1 | package org.example.hotdeploy; |
这时候new
出来的对象的类加载器就是自定义的了
再加亿点点细节,单开一个线程监听target
目录的变动,实现真正的热部署
Application.java(完整版)
1 | package org.example.hotdeploy; |
ClassFileWatcher.java(GPT友情提供)
1 | package org.example.hotdeploy; |
运行结果
5. 实战案例
以Spring
为例
一、Spring Boot DevTools(官方推荐)
Spring Boot 自带的热部署支持工具。
工作原理:
- 监控 classpath 下的文件改动(如
.class
文件); - 如果发现改动,重启 Spring 容器(不是 JVM!);
- 使用 两个类加载器:
- Base 类加载器:加载不变的类(第三方依赖等);
- Restart 类加载器:加载会变化的应用代码;
- 重启只会重新加载
Restart
部分,速度很快。
特点:
- 自动集成,无需配置;
- 修改代码后自动 reload;
- 适合开发阶段使用。
二、JRebel(商业工具)
非常强大的 Java 热部署工具。
工作原理:
修改 Java 源码后,JRebel 在运行时动态修改字节码;
不需要重启 JVM,不需要重启 Spring 容器;
甚至可以热更新:
- 添加方法/字段
- 修改方法体
- 修改注解等
- 添加方法/字段
特点:
真正的热部署(无重启);
支持 Spring、Hibernate 等主流框架;
收费,但开发效率极高。
三、Spring Loaded(已废弃)
Spring 早期提供的一个 Java agent 工具。
原理:
- 类似于 JRebel,基于 Java Agent 插桩修改类;
- 支持 Spring 框架;
- 已不再维护,不推荐使用。
四、IDE 的热部署支持(如 IntelliJ IDEA)
IntelliJ 提供 Build → Compile on Save + Hotswap 的功能。
特点:
- 修改代码 → 编译 → IDEA 自动替换
.class
文件; - 基于 JVM 的
HotSwap
机制; - 仅限 方法体内部的修改(不能加字段/方法);
- 常与 DevTools 配合使用。
总结对比表
方案 | 是否重启容器 | 是否重启 JVM | 修改范围 | 是否推荐 |
---|---|---|---|---|
Spring Boot DevTools | ✅ 是 | ❌ 否 | 类级别(不支持结构变化) | ✅ 推荐开发使用 |
JRebel | ❌ 否 | ❌ 否 | 类、方法、注解等 | ✅ 强烈推荐(需付费) |
Spring Loaded | ❌ 否 | ❌ 否 | 类级别 | ❌ 已废弃 |
IDEA HotSwap | ❌ 否 | ❌ 否 | 方法体内部 | ✅ 辅助使用 |
感兴趣的同学可以去研究一下哦
PS:热加载
热部署(Hot Deployment)和热加载(Hot Reloading)本质上是同一个目标的不同实现粒度,目的都是为了在不重启 JVM 的情况下让代码变更生效,只是它们操作的范围不同。
最大区别:粒度不同
对比点 | 热部署(Hot Deployment) | 热加载(Hot Reloading) |
---|---|---|
操作粒度 | 模块级 / 应用级别 | 类级 / 方法级别 |
是否替换类 | ✅ 是,但通常是整个模块/包级别 | ✅ 是,替换特定类、方法体或资源 |
使用场景 | Web 应用、插件系统、微服务重新部署 | Spring Bean 更新、页面热刷新、开发阶段调试 |
需要重启容器 | 有时是(比如 DevTools 会快速重启 Spring 容器) | 通常不需要 |
框架示例 | Tomcat 热部署 Web 应用、Spring Boot DevTools | JRebel、DCEVM、Agent 插件、Spring Boot DevTools |
类加载器 | 创建新的类加载器替换整个模块 | 创建类加载器或使用 Instrumentation 替换指定类 |