高级 Java 类教程:类重载指南
已发表: 2022-03-11在 Java 开发项目中,一个典型的工作流程包括在每次更改类时重新启动服务器,并且没有人抱怨它。 这是关于 Java 开发的事实。 从我们使用 Java 的第一天起,我们就一直这样工作。 但是 Java 类的重载有那么难实现吗? 对于熟练的 Java 开发人员来说,解决这个问题是否既具有挑战性又令人兴奋? 在这个 Java 类教程中,我将尝试解决这个问题,帮助您获得动态类重新加载的所有好处,并极大地提高您的工作效率。
Java 类的重新加载很少被讨论,并且很少有文档探讨这个过程。 我是来改变这一点的。 本 Java 类教程将逐步解释此过程,并帮助您掌握这种令人难以置信的技术。 请记住,实现 Java 类重新加载需要非常小心,但学习如何实现它将使您成为 Java 开发人员和软件架构师的大联盟。 了解如何避免 10 个最常见的 Java 错误也没有什么坏处。
工作空间设置
本教程的所有源代码都在此处上传到 GitHub。
要在学习本教程时运行代码,您将需要 Maven、Git 以及 Eclipse 或 IntelliJ IDEA。
如果您使用的是 Eclipse:
- 运行命令
mvn eclipse:eclipse
生成 Eclipse 的项目文件。 - 加载生成的项目。
- 将输出路径设置为
target/classes
。
如果您使用 IntelliJ:
- 导入项目的
pom
文件。 - 当您运行任何示例时,IntelliJ 不会自动编译,因此您必须:
- 在 IntelliJ 中运行示例,然后每次要编译时,都必须按
Alt+BE
- 使用
run_example*.bat
在 IntelliJ 之外运行示例。 将 IntelliJ 的编译器自动编译设置为 true。 然后,每次您更改任何 java 文件时,IntelliJ 都会自动编译它。
示例 1:使用 Java 类加载器重新加载类
第一个示例将使您对 Java 类加载器有一个大致的了解。 这是源代码。
给定以下User
类定义:
public static class User { public static int age = 10; }
我们可以做到以下几点:
public static void main(String[] args) { Class<?> userClass1 = User.class; Class<?> userClass2 = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example1.StaticInt$User"); ...
在本教程示例中,将有两个User
类加载到内存中。 userClass1
将由 JVM 的默认类加载器加载, userClass2
使用DynamicClassLoader
加载,DynamicClassLoader 是一个自定义类加载器,其源代码也在 GitHub 项目中提供,我将在下面详细介绍。
这是main
方法的其余部分:
out.println("Seems to be the same class:"); out.println(userClass1.getName()); out.println(userClass2.getName()); out.println(); out.println("But why there are 2 different class loaders:"); out.println(userClass1.getClassLoader()); out.println(userClass2.getClassLoader()); out.println(); User.age = 11; out.println("And different age values:"); out.println((int) ReflectUtil.getStaticFieldValue("age", userClass1)); out.println((int) ReflectUtil.getStaticFieldValue("age", userClass2)); }
和输出:
Seems to be the same class: qj.blog.classreloading.example1.StaticInt$User qj.blog.classreloading.example1.StaticInt$User But why there are 2 different class loaders: qj.util.lang.DynamicClassLoader@3941a79c sun.misc.Launcher$AppClassLoader@1f32e575 And different age values: 11 10
正如您在此处看到的,虽然User
类具有相同的名称,但它们实际上是两个不同的类,它们可以独立管理和操作。 年龄值虽然声明为静态,但存在两个版本,分别附加到每个类,也可以单独更改。
在普通的 Java 程序中, ClassLoader
是将类引入 JVM 的入口。 当一个类需要加载另一个类时,加载是ClassLoader
的任务。
但是,在这个 Java 类示例中,名为DynamicClassLoader
的自定义ClassLoader
用于加载User
类的第二个版本。 如果我们要再次使用默认类加载器而不是DynamicClassLoader
(使用命令StaticInt.class.getClassLoader()
),那么将使用相同的User
类,因为所有加载的类都被缓存。
DynamicClassLoader
一个普通的 Java 程序中可以有多个类加载器。 加载主类ClassLoader
的类是默认类,您可以从您的代码中创建和使用任意数量的类加载器。 那么,这就是 Java 中类重载的关键。 DynamicClassLoader
可能是整个教程中最重要的部分,因此我们必须了解动态类加载是如何工作的,然后才能实现我们的目标。
与ClassLoader
的默认行为不同,我们的DynamicClassLoader
继承了更激进的策略。 一个普通的类加载器会给它的父ClassLoader
优先级,并且只加载它的父类不能加载的类。 这适用于正常情况,但不适用于我们的情况。 相反, DynamicClassLoader
将尝试查看其所有类路径并在放弃对其父类的权限之前解析目标类。
在上面的示例中, DynamicClassLoader
仅使用一个类路径创建: "target/classes"
(在我们的当前目录中),因此它能够加载驻留在该位置的所有类。 对于所有不在其中的类,它必须引用父类加载器。 比如我们需要在我们的StaticInt
类中加载String
类,而我们的类加载器无法访问我们JRE文件夹中的rt.jar
,所以会使用父类加载器的String
类。
以下代码来自DynamicClassLoader
的父类AggressiveClassLoader
,并显示了此行为的定义位置。
byte[] newClassData = loadNewClass(name); if (newClassData != null) { loadedClasses.add(name); return loadClass(newClassData, name); } else { unavaiClasses.add(name); return parent.loadClass(name); }
注意DynamicClassLoader
的以下属性:
- 加载的类与默认类加载器加载的其他类具有相同的性能和其他属性。
-
DynamicClassLoader
可以与其所有加载的类和对象一起进行垃圾收集。
由于能够加载和使用同一类的两个版本,我们现在正在考虑转储旧版本并加载新版本来替换它。 在下一个示例中,我们将这样做……不断地。
示例 2:不断地重新加载一个类
下一个 Java 示例将向您展示 JRE 可以永远加载和重新加载类,转储旧类并收集垃圾,并从硬盘驱动器加载全新的类并投入使用。 这是源代码。
这是主循环:
public static void main(String[] args) { for (;;) { Class<?> userClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example2.ReloadingContinuously$User"); ReflectUtil.invokeStatic("hobby", userClass); ThreadUtil.sleep(2000); } }
每两秒钟,旧的User
类将被转储,一个新的类将被加载并调用它的方法hobby
。
这是User
类定义:
@SuppressWarnings("UnusedDeclaration") public static class User { public static void hobby() { playFootball(); // will comment during runtime // playBasketball(); // will uncomment during runtime } // will comment during runtime public static void playFootball() { System.out.println("Play Football"); } // will uncomment during runtime // public static void playBasketball() { // System.out.println("Play Basketball"); // } }
运行此应用程序时,您应该尝试注释和取消注释User
类中指示代码的代码。 您将看到始终使用最新的定义。
这是一些示例输出:
... Play Football Play Football Play Football Play Basketball Play Basketball Play Basketball
每次创建DynamicClassLoader
的新实例时,它都会从target/classes
文件夹中加载User
类,我们在该文件夹中设置了 Eclipse 或 IntelliJ 以输出最新的类文件。 所有旧的DynamicClassLoader
和旧的User
类将被取消链接并受到垃圾收集器的影响。
如果您熟悉 JVM HotSpot,那么这里值得注意的是,类结构也可以更改和重新加载: playFootball
方法将被移除,而playBasketball
方法将被添加。 这与 HotSpot 不同,HotSpot 只允许更改方法内容,或者不能重新加载类。
现在我们能够重新加载一个类,是时候尝试一次重新加载多个类了。 让我们在下一个示例中尝试一下。
示例 3:重新加载多个类
此示例的输出将与示例 2 相同,但将展示如何在具有上下文、服务和模型对象的更类似于应用程序的结构中实现此行为。 这个例子的源码比较大,这里只展示了一部分。 完整的源代码在这里。
这是main
方法:
public static void main(String[] args) { for (;;) { Object context = createContext(); invokeHobbyService(context); ThreadUtil.sleep(2000); } }
和方法createContext
:
private static Object createContext() { Class<?> contextClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example3.ContextReloading$Context"); Object context = newInstance(contextClass); invoke("init", context); return context; }
方法invokeHobbyService
:

private static void invokeHobbyService(Object context) { Object hobbyService = getFieldValue("hobbyService", context); invoke("hobby", hobbyService); }
这是Context
类:
public static class Context { public HobbyService hobbyService = new HobbyService(); public void init() { // Init your services here hobbyService.user = new User(); } }
还有HobbyService
类:
public static class HobbyService { public User user; public void hobby() { user.hobby(); } }
本示例中的Context
前面示例中的User
类复杂得多:它具有与其他类的链接,并且在每次实例化时都会调用init
方法。 基本上,它与现实世界应用程序的上下文类(跟踪应用程序的模块并进行依赖注入)非常相似。 因此,能够重新加载这个Context
类以及它的所有链接类是将这项技术应用于现实生活的重要一步。
随着类和对象数量的增加,我们“丢弃旧版本”的步骤也将变得更加复杂。 这也是类重载如此困难的最大原因。 为了可能删除旧版本,我们必须确保在创建新上下文后,删除对旧类和对象的所有引用。 我们如何优雅地处理这个问题?
这里的main
方法将持有上下文对象,这是所有需要删除的东西的唯一链接。 如果我们断开该链接,上下文对象和上下文类以及服务对象……都将受到垃圾收集器的影响。
关于为什么通常类如此持久并且不收集垃圾的一点解释:
- 通常,我们将所有类加载到默认的 Java 类加载器中。
- 类-类加载器关系是一种双向关系,类加载器还缓存它已加载的所有类。
- 因此,只要类加载器仍然连接到任何活动线程,一切(所有加载的类)都将不受垃圾收集器的影响。
- 也就是说,除非我们可以将要重新加载的代码与默认类加载器已经加载的代码分开,否则我们的新代码更改将永远不会在运行时应用。
通过这个例子,我们看到重新加载所有应用程序的类实际上是相当容易的。 目标只是保持从活动线程到正在使用的动态类加载器的薄的、可删除的连接。 但是,如果我们希望某些对象(及其类)不被重新加载,并且在重新加载周期之间被重用,该怎么办? 让我们看下一个例子。
示例 4:分离持久化和重新加载的类空间
这是源代码..
main
方法:
public static void main(String[] args) { ConnectionPool pool = new ConnectionPool(); for (;;) { Object context = createContext(pool); invokeService(context); ThreadUtil.sleep(2000); } }
所以你可以看到这里的技巧是加载ConnectionPool
类并在重新加载周期之外对其进行实例化,将其保持在持久空间中,并将引用传递给Context
对象
createContext
方法也有点不同:
private static Object createContext(ConnectionPool pool) { ExceptingClassLoader classLoader = new ExceptingClassLoader( (className) -> className.contains(".crossing."), "target/classes"); Class<?> contextClass = classLoader.load("qj.blog.classreloading.example4.reloadable.Context"); Object context = newInstance(contextClass); setFieldValue(pool, "pool", context); invoke("init", context); return context; }
从现在开始,我们将在每个循环中重新加载的对象和类称为“可重新加载空间”,而其他对象和类——在重新加载周期中未回收且未更新的对象和类——称为“持久空间”。 我们必须非常清楚哪些对象或类位于哪个空间中,从而在这两个空间之间划出一条分隔线。
从图中可以看出,不仅Context
对象和UserService
对象引用ConnectionPool
对象, Context
和UserService
类也引用ConnectionPool
类。 这是一个非常危险的情况,常常导致混乱和失败。 ConnectionPool
类不能被我们的DynamicClassLoader
加载,内存中必须只有一个ConnectionPool
类,也就是默认ClassLoader
加载的那个。 这就是为什么在 Java 中设计类重载架构时要小心谨慎的一个例子。
如果我们的DynamicClassLoader
不小心加载了ConnectionPool
类怎么办? 那么持久化空间中的ConnectionPool
对象就不能传递给Context
对象了,因为Context
对象期待的是一个不同类的对象,它也叫ConnectionPool
,但实际上是一个不同的类!
那么我们如何防止我们的DynamicClassLoader
加载ConnectionPool
类呢? 这个例子没有使用DynamicClassLoader
,而是使用了一个名为ExceptingClassLoader
的子类,它将基于条件函数将加载传递给超级类加载器:
(className) -> className.contains("$Connection")
如果我们在这里不使用ExceptingClassLoader
,那么DynamicClassLoader
将加载ConnectionPool
类,因为该类位于“ target/classes
”文件夹中。 另一种防止ConnectionPool
类被我们的DynamicClassLoader
拾取的方法是将ConnectionPool
类编译到不同的文件夹中,可能在不同的模块中,它将单独编译。
选择空间的规则
现在,Java 类加载工作变得非常混乱。 我们如何确定哪些类应该在持久空间中,哪些类应该在可重新加载空间中? 以下是规则:
- 可重载空间中的类可能会引用持久空间中的类,但持久空间中的类可能永远不会引用可重载空间中的类。 在前面的例子中,可重载的
Context
类引用了持久化的ConnectionPool
类,但是ConnectionPool
没有引用Context
- 如果一个类不引用另一个空间中的任何类,则它可以存在于任一空间中。 例如,具有所有静态方法(如
StringUtils
)的实用程序类可以在持久空间中加载一次,然后在可重新加载空间中单独加载。
所以你可以看到规则不是很严格。 除了具有跨两个空间引用的对象的交叉类之外,所有其他类都可以在持久空间或可重新加载空间或两者中自由使用。 当然,只有可重新加载空间中的类才能享受重新加载循环。
因此,处理了类重载最具挑战性的问题。 在下一个示例中,我们将尝试将这种技术应用于一个简单的 Web 应用程序,并像任何脚本语言一样享受重新加载 Java 类的乐趣。
示例 5:小电话簿
这是源代码..
这个示例将与普通 Web 应用程序的外观非常相似。 它是一个带有 AngularJS、SQLite、Maven 和 Jetty 嵌入式 Web 服务器的单页应用程序。
这是 Web 服务器结构中的可重新加载空间:
Web 服务器不会保存对真实 servlet 的引用,这些 servlet 必须保留在可重新加载空间中,以便重新加载。 它拥有的是存根 servlet,每次调用它的服务方法时,它都会在实际上下文中解析实际的 servlet 以运行。
此示例还引入了一个新对象ReloadingWebContext
,它向 Web 服务器提供所有值,如普通 Context ,但在内部保存对可以由DynamicClassLoader
重新加载的实际上下文对象的引用。 正是这个ReloadingWebContext
为 Web 服务器提供了存根 servlet。
ReloadingWebContext
将是实际上下文的包装器,并且:
- 当调用到“/”的 HTTP GET 时,将重新加载实际上下文。
- 将向 Web 服务器提供存根 servlet。
- 每次初始化或销毁实际上下文时都会设置值并调用方法。
- 可以配置是否重新加载上下文,以及使用哪个类加载器进行重新加载。 这将有助于在生产中运行应用程序。
因为了解我们如何隔离持久空间和可重新加载空间非常重要,所以这里有两个跨越两个空间的类:
Context
中对象public F0<Connection> connF
的类qj.util.funct.F0
- 函数对象,每次调用函数时都会返回一个 Connection。 此类驻留在 qj.util 包中,该包从
DynamicClassLoader
中排除。
Context
中对象public F0<Connection> connF
的类java.sql.Connection
- 普通 SQL 连接对象。 此类不驻留在我们的
DynamicClassLoader
的类路径中,因此不会被拾取。
概括
在本 Java 类教程中,我们了解了如何重新加载单个类、连续重新加载单个类、重新加载多个类的整个空间以及将多个类与必须持久化的类分开重新加载。 使用这些工具,实现可靠的类重新加载的关键因素是拥有超级干净的设计。 然后你可以自由地操作你的类和整个 JVM。
实现 Java 类重载并不是世界上最简单的事情。 但是,如果您试一试,并且在某个时候发现您的类正在动态加载,那么您几乎已经完成了。 在您为您的系统实现完全出色的清洁设计之前,您几乎没有什么可做的。
祝我的朋友们好运,享受你新发现的超能力!