Java热部署原理及实现
才疏学浅,若有不对之处烦请指正
前言
在大型 Java 项目的开发过程中,开发人员往往需要频繁地修改代码并进行测试,而每次的修改都伴随着项目的重启。可惜的是,对于体量庞大、依赖繁多的项目来说,启动一次可能就需要一两分钟甚至更久,这无疑大大拖慢了开发效率。虽然你可以趁这段时间偷偷摸个🐟——咳咳,当然不是鼓励摸鱼——但从整体来看,这种低效的迭代方式是非常不利于快速开发和调试的。
这个时候,热部署(Hot Deployment)技术就成了提升开发体验的“救星”。它的目标很朴素:尽量不重启整个 JVM,让新代码尽快接管后续请求,大大缩短等待时间,让我们可以更加专注于业务逻辑的实现和问题的定位。
在实际开发中,“热部署(Hot Deployment)”“热加载(Hot Reloading)”乃至“热替换(HotSwap)”经常被交替使用。不同工具对这些词的定义也不完全统一,所以比起死磕名字,更重要的是看它到底换了什么、重启了什么:
- ClassLoader 级模块重载:创建新的类加载器,重新加载一套应用类,再把后续调用切换到新模块。Tomcat 重新加载 Web 应用、Spring Boot DevTools 快速重启都接近这条路线。
- JVM 已加载类重定义:通过 Instrumentation/JVMTI 的 HotSwap 能力修改已经加载的类。它更适合 fix-and-continue 调试:可以更新方法体、常量池和部分 class 属性,但不能新增、删除或重命名字段和方法,也不能改变方法签名、修饰符或继承关系。
- 框架或容器级重启:JVM 不退出,但应用上下文、Web 模块或部分容器会重新创建。它经常和更换类加载器一起出现。
本文所讲的“热部署”是一个泛指,实操重点是第一种:ClassLoader 级模块重载。我们会通过自定义类加载器加载修改后的 .class,再执行新版本入口。它不会把旧的 Class 原地变成新版本,而是让新旧类型短暂并存,等调用入口切换、旧引用释放后,再由 GC 尝试回收旧模块。
参考链接
1. 热部署是什么
热部署(Hot Deployment)通常指在不停止整个 JVM 的前提下,让修改后的代码或资源生效。至于实现过程中会不会重建应用上下文、会不会更换 ClassLoader、会不会直接重定义已加载类,要看具体方案。
创建新的 ClassLoader,加载一套新版 Class 和实例,再将调用入口切换到新版模块。这是“重新加载一套类”,不是“原地替换旧类”。
是不是有点懵
- 编译过后不是生成新的字节码了吗?为什么没有通过新的字节码重新生成类实例?
- 为什么需要新的类加载器?不能用默认的类加载器吗?
- 双亲委派机制是什么?为什么本文的目标包要采用 child-first?
- 新类加载出来后,旧类和旧对象又去哪了?
先讲点前置知识
2. 前置知识
类加载机制

简单来讲,类加载机制是JVM将类的字节码文件(.class)加载到内存并初始化的核心过程,主要分为以下阶段
编译(Compile)
这是整个流程的起点(严格属于Java编译过程,非类加载阶段)。将
.java源码编译为JVM可识别的.class字节码文件。也就是javac加载(Loading)
- 任务:通过类加载器(ClassLoader)查找
.class文件(可来自本地、网络或动态生成)。 - 核心动作:创建类的运行时表示以及对应的
Class对象,作为程序访问类元数据的入口。具体数据如何分布在 Java 堆、元空间等区域属于 JVM 实现细节,不宜简单概括成“Class 对象都在方法区”。 - 双亲委派机制:类加载器优先委派父类加载,避免重复加载和安全问题。
- 任务:通过类加载器(ClassLoader)查找
连接(Linking)
- 验证(Verification):检查字节码是否符合
JVM规范(如魔数、版本、语法)。 - 准备(Preparation):为类变量(static变量)分配内存并赋默认值(如int→0,对象→null)。
- 解析(Resolution):将运行时常量池中的符号引用转换为 JVM 可以直接使用的引用。直接引用可能表现为指针、句柄或偏移量等内部结构,不等于 Java 代码能够看到的“内存地址”。JVM 可以按需延迟解析;它和虚方法调用时根据对象实际类型选择实现的“动态绑定”不是一回事。
- 验证(Verification):检查字节码是否符合
初始化(Initialization)
- 触发条件:首次主动使用类时(如
new对象、调用静态方法、访问非常量静态字段等;读取编译期常量通常不会触发初始化)。 - 核心动作:执行类构造器
<clinit>(),包括静态变量赋值和静态代码块。
- 触发条件:首次主动使用类时(如
使用(Using)
类完成初始化后,程序可通过Class对象创建实例、调用方法、访问字段等。
卸载(Unloading)
- 核心条件:定义该类的 ClassLoader 不再可达并可被垃圾回收。通常这也意味着它加载的类、Class 对象和实例都没有来自 GC Root 的有效引用。
- 实现:由 JVM 垃圾回收机制完成。即使满足可回收条件,也只是允许 JVM 卸载,不保证立即发生;由 Bootstrap ClassLoader 加载的核心类不会以这种方式卸载。
类加载器

类加载器(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 8 扩展机制目录中的类库,默认路径为
JAVA_HOME/jre/lib/ext或java.ext.dirs指定目录。包名是否以javax.*开头,并不能直接判断它是不是由扩展类加载器加载。 - 父加载器:BootstrapClassLoader
- 职责:加载 Java 8 扩展机制目录中的类库,默认路径为
- AppClassLoader(应用程序类加载器)
- 职责:加载用户类路径(ClassPath)下的类,即程序中的自定义类。
- 父加载器:ExtClassLoader
- 默认加载器:Java程序的
main()方法默认使用此加载器。
- UserApp1/2ClassLoader(自定义类加载器)
- 职责:开发者扩展类加载方式(如网络加载、加密文件、热部署)。
- 父加载器:AppClassLoader
- 实现:继承
ClassLoader类,重写findClass()方法。
以上是 Java 8 及之前的类加载器体系。Java 9 引入模块化(JPMS)后,扩展机制及 ExtClassLoader 被新的平台类加载机制和 PlatformClassLoader 取代,并不只是简单改了个名字;rt.jar 也不再存在,核心类库改为模块形式加载。不过父优先委派的核心思路仍然存在,本文主要以 Java 8 的名字讲解。
同一个类加载器不能重复定义具有相同二进制名称的类,否则会抛出 LinkageError。正常调用 loadClass 时,后续请求会通过 findLoadedClass 返回已经加载的 Class。
JVM 中一个类的身份由 二进制名称 + 定义它的 ClassLoader 共同确定。不同 ClassLoader 定义的同名类是两个不同的运行时类型,彼此不能直接强转;不过它们可以共同实现一个由父加载器加载的共享接口,这也是插件系统常见的隔离方式。
双亲委派机制
类加载器遵循向上委托的加载规则:
1 | 1. 收到类加载请求时,先不自行加载,而是委派给父加载器。 |
- 示例:用户自定义类
User.class的加载流程:UserApp1ClassLoader→AppClassLoader→ExtClassLoader→BootstrapClassLoader(找不到)→ 回退到ExtClassLoader(找不到)→ 回退到AppClassLoader加载ClassPath中的类。 - 核心作用:
✅ 避免委派链中的重复加载:父加载器已经加载的类,采用父优先策略的子加载器会直接复用。注意,互不关联的加载器仍然可以各自定义同名类。
✅ 保护核心 API 的一致性:核心类优先由 Bootstrap ClassLoader 加载;JVM 还会拒绝应用加载器定义java.*包中的类。
✅ 建立隔离边界:不同加载器可以隔离应用、插件和依赖版本,但安全性并不是只靠双亲委派这一条规则保障的。
源码

- 用户请求加载类
- 当前类加载器检查是否已加载 → 已加载则直接返回
- 未加载 → 委派父加载器递归向上(直到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参数
控制是否对刚加载的类调用 resolveClass 完成连接:
resolve=true:在返回前调用resolveClass。resolve=false:这里只是不主动调用resolveClass,不代表这个类及其依赖以后都不会被 JVM 按需解析。
3. 理论准备
知道了这些,就可以解答上面提到的问题了
问题解答
.class 文件变了,不代表 JVM 会主动回头重新读取它。类一旦被某个 ClassLoader 定义,正常的 loadClass 流程就会先通过 findLoadedClass 找到原来的 Class 并直接返回。
而且要注意,加载 Class 和创建实例是两件事:每次 new 都可以创建新实例,但这些实例仍然属于已经加载的旧 Class,执行的也还是旧版本代码。
真正卡住我们的不是“不能再 new”,而是同一个定义类加载器不能再次定义相同二进制名称的类。强行对原加载器再次调用 defineClass,会得到 LinkageError。
因此,ClassLoader 级重载会创建一个新的加载器。新加载器拥有新的类定义命名空间,可以从修改后的字节码定义一个新的 Class。代价也很明显:新版和旧版是两个运行时类型,旧实例不会自动升级,调用入口必须显式切换。
发散一下
假设重写了 loadClass 方法,修改了 findLoadedClass 逻辑(例如新增字节码变动检测),能否绕过限制?
1 |
|
答案是不行的,为啥嘞?
无法按需卸载某一个已加载的类:
JVM 没有提供“现在就卸载这个 Class”的公开 API。ClassLoader 级卸载依赖可达性:通常要等定义该类的 ClassLoader 连同它加载的类和实例都不再被 GC Root 引用,JVM 才可能将整套类卸载。
线程、ThreadLocal、定时任务、框架缓存和资源注册都可能让旧 ClassLoader 继续可达。即使手动清理部分缓存,也无法强制 JVM 立即卸载它。
重复加载同名类的冲突:
若尝试通过原类加载器多次加载同一类名(即使字节码已修改),会抛出
LinkageError,因为 JVM 禁止同一加载器定义同名类。
创建新 ClassLoader 解决的是“获得一个新的类定义命名空间”,child-first 解决的则是另一个问题:本文的业务类也在应用 classpath 中,AppClassLoader 已经能加载它。如果新加载器继续父优先,父加载器会直接返回旧 Class,新加载器根本没有机会读取新版字节码。
所以本文只对目标业务实现包 org.example.hotdeploy.app. 采用 child-first,让它优先调用自己的 findClass;启动器、类加载器、JDK 类、第三方依赖和共享边界仍然交给父加载器。不是所有热部署方案都必须打破双亲委派:如果父加载器本来就看不到目标类,普通父优先流程最终也会回到子加载器。
如何让目标业务类走 child-first 呢
child-first 可能导致类冲突(例如多个版本的同名类共存),需要严格控制包范围。实际项目中应把“可热重载实现类”和“父加载器共享边界类”分包管理:共享接口、DTO、注解、SPI、日志门面和框架扩展点由父加载器加载,业务实现类才交给热部署 ClassLoader。否则同名接口也被重复定义后,看起来一模一样,强转时照样会喜提 ClassCastException。
简单来讲,就是重写 loadClass:目标业务包优先自己找,找不到再回退父加载器;其他类直接交给父加载器。这里还要保留 ClassLoader 原本的类加载锁,否则并发加载同一个类时可能重复执行 defineClass。
1 | // 自定义类加载器实现热部署 |
另一条歪路
既然loadClass一开始就会检测类是否被加载了,那在这之前加载类不就行了吗
在类加载器构造时直接调用 findClass,确实也能绕过父优先查找。不过它只是提前定义目标类,仍然没有解决依赖类的加载范围、入口切换和旧模块退出,所以不适合作为完整方案。
1 | public class HotDeployClassLoader extends ClassLoader { |
这就要聊到垃圾回收机制(Garbage Collection)了,主要和可达性相关
- 若没有 GC Root 能够继续访问旧类加载器及其加载的 Class 和实例,它们之后可能被一起回收。
- 若父加载器中的静态缓存、仍在运行的线程、ThreadLocal、定时任务、JDBC 驱动或其他资源注册仍持有旧模块对象,旧 ClassLoader 就无法回收。
- 旧类内部的对象形成引用环不一定泄漏;关键是这条引用链能不能从 GC Root 到达。
诶,这时候就发现另一个问题了,内存泄漏
及时清理引用:
- 替换类加载器时,确保旧类实例、静态字段、线程引用等被清除。
谨慎设计缓存引用:
1
2// 弱引用适合“不希望缓存阻止回收”的场景,但不能消除其他地方的强引用
WeakReference<Class<?>> weakClassRef = new WeakReference<>(oldClass);单独给
Class套一层弱引用不是通用解法,真正要处理的是外部线程、注册表和框架缓存中的强引用。避免跨加载器引用:
- 跨加载器通信尽量只使用由父加载器定义的稳定接口和数据类型。切换完成后,不要让新版模块长期持有旧实现对象。
这里就不再扩展介绍了,感兴趣的同学可以去研究一下哦
4. 实操
基于以上理论,咱们来上手验证一下
代码
先把稳定基础设施和可热重载业务类分开,示例包结构如下:
1 | org.example.bootstrap |
bootstrap 包始终由 AppClassLoader 加载,负责监听、创建新加载器和切换入口;只有 hotdeploy.app 包交给新的 HotDeployClassLoader。
HotDeployObject.java
1 | package org.example.hotdeploy.app; |
这里的“类哈希码”来自 System.identityHashCode(getClass()),观察的是 Class 对象,不是 HotDeployObject 实例,更不能把它当成内存地址。它的作用只是辅助判断前后两次拿到的是不是同一个类定义。
HotDeployClassLoader.java
1 | package org.example.bootstrap; |
Bootstrap.java
1 | package org.example.bootstrap; |
这里 Bootstrap 直接引用 HotDeployObject,只是为了同时打印 AppClassLoader 对照组和自定义加载器实验组。真正作为模块启动器时不应直接依赖业务实现,所以下面的完整入口会只通过类名反射进入 Application。
运行结果与分析
前两轮保持 version = "1.0",第二轮结束后将它改为 2.0 并重新编译。控制台会得到类似下面的结果:
1 | 当前版本:1.0 类哈希码:1163157884 |
默认类加载器一组中,类哈希码不变。
每次
new HotDeployObject()确实都会创建一个新实例,但这些实例都属于 AppClassLoader 已经定义好的同一个 Class。即使磁盘上的字节码已经重新编译,第三轮输出仍然是1.0,因为 AppClassLoader 没有重新定义这个类。自定义类加载器一组中,类哈希码和类加载器标识都会改变。
每轮循环都创建新的 HotDeployClassLoader,因此会从当前
.class文件定义一个新的 Class。第三轮的新加载器读取到重新编译后的字节码,所以输出变为2.0;旧 Class 和旧实例并没有被原地修改。
扩展一下
上面的代码中,我们通过反射进入新版对象。总不能业务里每调用一个类都手写一遍反射吧,有没有把反射限制在启动边界的打法呢?
有的,孩子,有的
一个类在自身字节码中引用其他类时,JVM 通常会以定义当前类的加载器发起依赖类加载。因此,只要先通过反射进入新版应用入口,入口内部的普通 new 和方法调用就会继续沿着这套加载器关系工作。是否最终由同一个加载器定义,还要看它的委派策略。
也就是说,可以在外面套一层稳定的启动器,把反射集中在“进入新版模块”这一个边界。它不是把自定义加载器变成 JVM 的全局加载器,而是让这一模块的实现类都由它负责定义。
Application.java
1 | package org.example.hotdeploy.app; |
Bootstrap.java
1 | package org.example.bootstrap; |
这时候 Application.start() 中 new 出来的 HotDeployObject 就由自定义加载器定义了,而 Bootstrap、HotDeployClassLoader 仍由父加载器稳定持有。反射仍然存在,但只存在于稳定启动器切换模块入口的位置。
再加亿点点细节,单开一个线程监听 target 目录的变动,就能做一个自动触发的模块重载演示。
Bootstrap.java(完整版)
1 | package org.example.bootstrap; |
ClassFileWatcher.java(GPT友情提供)
1 | package org.example.bootstrap; |
这里用的是尾沿防抖:只要编译器还在连续写入 .class,就继续等,直到一段时间没有新事件再触发回调。它比“收到第一个事件就立即重载”稳一些,但依然只是教学版。
真正放进长期运行的应用时,还需要补齐一整套生命周期:重载前停止旧模块并注销线程、监听器和连接;新模块启动成功后原子切换请求入口;如果启动失败,则继续保留旧版本。否则就算新 Class 加载成功,也可能留下两个同时工作的模块。
5. 实战方案放回原理图里
前面手搓的是 ClassLoader 级模块重载。实际开发中的工具不一定走同一条路,这里把它们放回开头那三层机制里看看。
一、Spring Boot DevTools(开发期快速重启)
DevTools 更准确地说是开发期快速重启,不是 JVM 层面的原地类替换。引入 DevTools 依赖后,它会监控 classpath 中重新编译出来的文件。检测到变化时,不退出 JVM,而是关闭旧的 ApplicationContext,丢弃旧的 RestartClassLoader,再用新的 RestartClassLoader 启动应用。
- Base ClassLoader:加载变化较少的第三方依赖;
- RestartClassLoader:加载经常修改的项目代码;
- 发生变化时,主要重建 Restart 部分和应用上下文,所以通常比完整 JVM 重启快;
- 因为本质上会重新加载应用类,所以增加字段、方法和类等结构变化通常可以生效,它不受标准 HotSwap“只能改方法体”的同一套限制;
- 它适合开发阶段,不是生产环境的无损发布方案。是否自动触发还取决于 IDE 或构建工具有没有把源码重新编译到 classpath。
看到这里是不是有点眼熟?没错,DevTools 和本文实验都利用了“稳定部分不动、变化部分换加载器”的思路,只是它把 Spring 应用上下文的关闭和重建也一起处理了。
二、标准 JVM HotSwap 与 IDEA 调试
IDEA 在调试期间可以把重新编译的字节码交给 JVM,通过 Instrumentation/JVMTI 的 RedefineClasses、RetransformClasses 等类重定义能力更新已经加载的 Class。这类能力主要服务于 fix-and-continue 调试场景。
- 不需要创建一个同名的新运行时类型,已有实例后续调用可以执行更新后的方法;
- 可以更新方法体、常量池和部分 class 属性;
- 不能新增、删除或重命名字段和方法,也不能改变方法签名、修饰符或继承关系;
- IDEA 负责触发编译和提交变更,真正执行重定义的是 JVM 的 HotSwap 能力;
- 修改超出 HotSwap 范围时,通常还得退回 DevTools 重启或完整重启。
这条路线才更接近大家直觉中的“把已经加载的旧类原地换掉”。
三、JRebel(商业工具)
JRebel 通过 Java Agent、字节码增强以及针对 Spring、Hibernate 等框架的集成,让更多结构变化可以在不重启整个应用上下文的情况下生效。
- 不需要重启 JVM,很多场景也不需要重建完整 Spring 上下文;
- 支持的方法、字段、注解和框架配置变化范围比标准 HotSwap 更广,但也不能理解成任何修改都百分百无条件生效;
- 收费,适合对大型项目启动时间比较敏感的团队。
四、Spring Loaded(已停止维护)
Spring Loaded 是 Spring 早期提供的 Java Agent 类重载工具,思路同样是运行时转换和更新类,并对 Spring 做适配。项目已经停止维护,不建议用于新项目。
总结对比表
| 方案 | 核心机制 | 是否重启 JVM | 是否重建应用上下文 | 典型修改范围 |
|---|---|---|---|---|
| 本文示例 | 新 ClassLoader 加载新类型,手动进入新版入口 | 否 | 示例中没有上下文 | 新加载器可读取完整新版类,但旧实例不会升级 |
| Spring Boot DevTools | 更换 RestartClassLoader 并快速重启应用上下文 | 否 | 是 | 通常支持新增字段、方法、类等结构变化 |
| 标准 HotSwap / IDEA | Instrumentation/JVMTI 重定义已加载类 | 否 | 否 | 可更新方法体、常量池和部分属性,不支持类结构变化 |
| JRebel | Agent、字节码增强与框架集成 | 否 | 多数场景不需要完整重建 | 支持范围较广,具体能力依修改类型而定 |
| Spring Loaded | Java Agent 和 Spring 集成 | 否 | 视场景而定 | 已停止维护 |
PS:热部署和热加载到底怎么叫
绕了一大圈会发现,“热部署”和“热加载”在不同文章、IDE 和框架里并没有完全统一的边界。有的人按操作粒度区分,有的人按是否重启上下文区分,还有的人干脆全都叫 HotSwap。
所以本文最后不再强行给这两个词划一条绝对分界线。遇到一个“热部署”工具时,直接问下面三个问题更靠谱:
- 它有没有退出 JVM?
- 它是创建了新的 ClassLoader 和新类型,还是重定义了已经加载的 Class?
- 它有没有销毁并重建应用上下文,旧实例和运行状态怎么处理?
把这三个问题答清楚,基本就能看明白它的实现原理和能力边界。相比具体叫法,弄清楚它替换了什么、重启了什么,以及旧状态如何处理更重要。








