欢迎阅读关于字节码工程的 Discotek.ca 系列的第二部分。第一篇文章,字节码工程概述,可以 在这里 找到。
JRebel 无疑是行业领先的 类重新加载 软件。它是一个有用的产品,通过帮助许多组织加快 Java 开发而赢得了声誉。该产品的工作原理对大多数人来说是个谜。我想解释一下我认为它是如何工作的,并提供一个 基本原型 (带有 源代码 )。
自从采用应用服务器将业务逻辑与通用管道逻辑隔离开来以来,开发人员一直在经历在测试服务器端代码更改之前构建和重新部署的耗时过程。应用程序越大,构建/重新部署周期往往越长。对于经常测试的开发人员来说,花在构建和重新部署上的时间可能会占用一天的大部分时间。项目的实际成本可以等同于开发人员数量 * 每小时工资 * 构建和重新部署所花费的小时数。这个数字不一定只是做生意的成本。
前段时间我在探索 Instrumentation 的时候,写了一个叫做 Feenix 的 产品,我认为它可以帮助人们克服和 JRebel 一样的类重载,但那并没有发生。该产品仍然存在于我的网站上,但我怀疑是否有人真正使用过它。现在,我把它放在那里作为我失败的痛苦提醒,这应该会激励我建立一个更好的。在 JRebel 作者 Anton Arhipov 提供了一些有见地的批评 之前,我不明白为什么我的产品失败了:
Feenix 可以做 Java Instrumentation API 允许它做的事情。这基本上意味着它并没有真正在 JVM 的标准 HotSwap 之上增加价值。
有几种产品提供了一种在运行的 JVM 中修改类功能的机制,但它们并非都是一样的。可能最著名的是 Java 的内置热交换,像 Eclipse 这样的 IDE 在调试模式下利用了它。其他人,如 Feenix,利用 Java 的内置检测 API。由于 JVM 的限制,这些尝试中的大多数都失败了。具体来说,JVM 限制了允许对加载的类进行更改的类型。例如,JVM 不允许您更改类模式。这意味着您不能更改字段或方法的数量或它们的签名。您也不能更改继承层次结构。它们也不能改变现有对象的行为。不幸的是,这大大降低了这些产品的效用。
输入 JRebel。 JRebel 似乎是市场上功能最强大、最受赞誉的类重载产品。它几乎没有缺点,而且似乎得到了极好的支持。 JRebel 是一种商业产品,对于大多数自掏腰包购买工具的开发人员来说,它的价格可能高得令人望而却步。 JRebel 支持者发表了一些文章讨论他们如何解决各种类重载问题,但由于它们是商业产品,他们自然不会详细讨论实现。了解细节可能会导致替代开源产品。如果有足够的兴趣,我会把JRebel风格的类reloading集成到Feenix中并开源。
创建类重载机制 (CRM) 必须解决几个问题:
- CRM 必须知道类的新版本所在的位置。这些类可能位于本地磁盘或远程位置。它们可能被捆绑在一个罐子里、战争中或耳朵里。
- 虽然在技术上不是类加载,但 CRM 还应该支持重新加载非类资源,如图像或 html 文件。
- CRM 应确保当类加载器首次加载类时,它会加载最新版本。尽管一个类已经被类加载器加载,CRM 应该确保一个类的新实例将使用一个类的最新版本的功能。
- CRM 应确保现有对象的功能应使用其类的最新版本的功能。
- 虽然类重新加载显然是任何 CRM 所需的核心功能,但许多应用程序中使用了通用框架,这些应用程序的重新配置需要构建/重新部署周期。这些更改应该没有代码更改那么频繁,但提供这种重新加载功能仍然有价值。
上面的第四个问题在复杂性和实用性方面使其他问题相形见绌。应用程序服务器重用池化对象比总是创建新实例更便宜。除非 CRM 可以使池化实例知道类更改,否则它的作用很小。 JRebel 开发人员声称通过“类版本控制”来解决这些问题,但为实现的解释留有很大空间。我们知道类加载器可能只加载一个类一次。此规则的例外是检测,但我们知道这不是 JRebel 解决此问题的方式(主要是因为他们对此持开放态度,而且)因为检测不允许更改类模式。 CRM 设计的另一种方法通常称为“一次性类加载器”,它使用新的类加载器来加载类的每个新版本。这种设计有很多缺点,但最重要的是无法解决为现有对象引入新功能的问题。
要向现有对象引入新功能,必须将它们的执行转发给包含新功能的方法。由于类加载器只能加载给定的类一次,因此新功能必须托管在具有新的唯一名称的类中。但是,类无法在编译时或运行时知道其后继者的名称。我们可以在加载类时使用检测来修改它,但是在 CRM 检测到新编译的类并使它们可用于 JVM 之前,我们不会知道其后继者的名称。可以使用两种机制将执行转发给它的后继者:反射或接口。反射可以检查类的方法并调用具有匹配名称和签名的方法。众所周知,反射很慢,不适合应用于每个方法调用。或者,可以创建一个接口,该接口定义一个方法以允许一般地调用后继类中的任何方法。这样的方法可能具有以下名称和签名:
public Object invoke(int methodId, Object invoker, Object args[]);
如果给定类的较新版本实现了此接口,则可以将执行转发到适当的方法。 methodId 参数用于确定方法。 invoker 参数提供对原始对象状态(字段)的访问, args 参数为新方法提供对原始方法参数的访问。
一个可行的解决方案比上面的概述有更多的活动部分。它还引入了两个额外的问题来解决。每次调用重新加载的对象的方法都会在堆栈上产生额外的意外帧,这可能会让开发人员感到困惑。在重新加载的类上使用反射可能无法正常运行(假定类名已更改并且已添加 调用 方法,继承层次结构不存在,等等)。识别此类问题以及提供可行的解决方案都很重要。在一篇文章中解决上述所有问题,很可能会导致眼皮沉重。相反,让我们关注类转发功能的基本实现。如果有兴趣,我们总是可以在另一篇文章中重新讨论其他问题。
本文将介绍类重载机制的以下功能部分:
- 用于发现和管理类版本的中央组件
- 生成一个继承类和引用它的接口
- 修改应用程序类以将方法调用转发给它的后继者
- 修改java.lang.ClassLoader安装以上功能
在深入细节之前,我想警告您,我已经将这篇文章重写了两次。尽管我对字节码工程很感兴趣,但写 ASM 代码的解释连我自己都无聊得流泪。因此,这第三份草案(希望是最终草案)将包含比其他草案少得多的 ASM 代码。它将更多地关注类重新加载的工作原理,但您始终可以参考 参考资料 部分中的源代码以查看实现细节。
类重载机制设计
类版本管理器(AKA ClassManager)将有几个工作:
- 加载配置,指定要重新加载的类的名称空间以及在何处找到它们
- 确定类版本是否过时
-
提供字节码:
- 给定类的新版本
- 通用可调用接口类
- 接口实现类(包含新功能)
如果我详细讨论以上所有内容,这篇文章将比
《战争与和平》
更长。相反,我将掩盖与字节码工程不直接相关的细节。详细信息
在配置上,您可以查看
ca.discotek.feenix.Configuraton
和
ca.discotek.feenix.ClassManager
的静态初始化程序。这是一个示例配置文件:
public Object invoke(int methodId, Object invoker, Object args[]);
要指定配置文件的位置,请使用 feenix-config 系统属性指定完全限定路径。
要确定一个类是否已过时,我们将使用 ca.discotek.feenix.ClassManager 中的以下代码:
public Object invoke(int methodId, Object invoker, Object args[]);
调用者传入类的名称和他们希望测试的类的时间戳。
类管理器的最后一项任务是提供类字节码,但让我们首先重新审视一下类将如何重新加载。一个重要的步骤是覆盖 JVM 的 java.lang.ClassLoader 类,以便它可以在加载应用程序类时检测它们。每个应用程序类都将在每个方法的开头插入以下功能: 如果存在新类版本,则将执行转发到该新类实例中的相应方法 。让我们通过应用程序类的一个简单示例来仔细观察:
public Object invoke(int methodId, Object invoker, Object args[]);
上面的类将由我们特殊的 java.lang.ClassLoader 进行检测,看起来像这样:
public Object invoke(int methodId, Object invoker, Object args[]);
Print 类的修改版本有以下变化:
- 添加了 Printer_interface 打印机接口 字段。
- 添加了 check_update 方法。
-
printMessage
方法现在具有以下逻辑:
- 检查类更新
- 如果存在更新,则调用新类中的相应方法。
- 否则执行原代码
check_update 方法调用 ClassManager.getUpdate(…) 。此方法将确定更新是否可用,如果是,则生成一个新的实现类:
public Object invoke(int methodId, Object invoker, Object args[]);
一旦 getUpdate(…) 调用了 ClassManager.getClassBytes(…) 来检索表示类的原始字节,它将使用反射来调用 java.lang.ClassLoader 中的 defineMyClass 方法。 defineMyClass 是我们稍后在生成自定义 java.lang.ClassLoader 类时添加的方法。要将原始字节转换为 java.lang.Class 对象,您需要有权访问 java.lang.ClassLoader 中的 defineClass 方法,但它们都仅限于 受保护的 访问。因此,我们添加了我们自己的 公共 方法,它将调用转发给 defineClass 方法。我们需要使用反射访问该方法,因为它在编译时确实存在。
修改后的 Printer 类引入了 Printer_interface 类,而 ClassManager.getUpdate(…) 方法引入了新版本的 Printer 类 Printer_impl_0 ,它实现了 Printer_interface 接口类。这些类不会存在于应用程序类路径中,因为它们是在运行时生成的。我们将覆盖 java.lang.ClassLoader 的 loadClass 方法来调用 getUpdate(…) 已调用 ClassManager.getClassBytes(…) 来发现我们的应用程序类的新版本并根据需要生成接口和实现类。这是 getUpdate(…) 调用了 getClassBytes(…) 方法:
public Object invoke(int methodId, Object invoker, Object args[]);
有很多实现细节从这个方法中看不出来。 isInterface 和 isImplementation 方法通过检查类名后缀来做出决定。如果类名后缀与接口或实现类已知后缀格式不匹配,则请求是常规类。
如果请求的类是实现类实现的接口类,则调用 InterfaceGenerator.generate(…) 生成接口类。下面是为 打印机 示例生成的接口调用方法:
public Object invoke(int methodId, Object invoker, Object args[]);
ImplementationGenerator 类用于生成实现InterfaceGenerator生成的接口的类。这个类比 InterfaceGenerator 更大更复杂。它做以下工作:
- 为具有新命名空间的类生成原始字节码。该名称将与原始名称相同,但会附加一个独特的后缀。
- 它从原始类复制所有方法,但将初始化方法转换为常规方法,方法名称为 __init__ ,静态初始化方法名称为 __clinit__ 。
- 对于非静态方法,它添加了一个类型为< InterfaceGenerator生成的接口 >的参数。
- 将 对此 进行操作的非静态方法更改为对上一个项目符号中添加的参数进行操作。
- 对于构造函数,它去除了对 super.<init> 的调用。常规方法不能调用实例初始化器。
如果无法修改应用程序类以利用它们, InterfaceGenerator 和 ImplementationGenerator 类将毫无用处。 ModifyClassVisitor 完成这项工作。它添加了 check_update 方法并修改了每个方法,以便它将检查更新的类版本并将执行转发给那些存在的版本。它还将所有字段更改为 public 和 non-final 。这是必要的,以便它们可以被实现类访问。这些属性在编译时最有效,但当然这些更改可能会对使用反射的应用程序产生影响。解决这个问题现在必须放在待办事项清单上,但我想这并没有那么困难。解决方案可能涉及适当地覆盖 JRE 的类反射类(顺便说一句,它还可以解决因使用反射而引起的有关我们添加到应用程序类的方法和字段的问题)。
现在让我们讨论如何修改 java.lang.ClassLoader 。 JRebel 生成一个引导程序 jar,其中包含一个新的 java.lang.ClassLoader 类(以及其他类)并使用 JVM 的 -Xbootclasspath/p: 参数取代 JRE 的 java.lang.ClassLoader 。我们也将采用这种方法,但您应该注意,您可能必须为希望运行的目标 JVM 的每个版本执行此任务。如果您将 JRE X 中生成的 ClassLoader 类与 JRE Y 一起使用,则版本之间可能存在内部 API 更改,这会破坏兼容性。
为了生成一个新的 java.lang.ClassLoader ,我创建了三个类:
ClassLoaderGenerator 执行一些基本任务。它是程序的入口点。它的主要方法需要目标 JRE 的 rt.jar 文件的路径和输出目录。它从 rt.jar 的 java.lang.ClassLoader 中提取原始字节,它调用 ClassLoaderClassVisitor 来生成我们修改后的 java.lang.ClassLoader 的原始字节,然后将这些字节捆绑在 java/lang/ClassLoader.class 条目中一个 feenix-classloader.jar 文件,存放到指定输出目录。
ClassLoaderClassVisitor 使用 ASM 直接修改字节码,但它也从 ClassLoaderTargeted 拉取原始字节码。具体来说,我在 ClassLoaderTargeted 中编写了我希望出现在生成的 java.lang.ClassLoader 版本中的方法。虽然我确实喜欢直接使用 ASM 编写字节码指令,但它确实很乏味,尤其是当您在开发过程中不断进行增量更改时。通过用 Java 编写代码,这个过程变得更像常规的 Java 开发(与字节代码级开发相反)。这种方法可能会让一些人说“但是为什么不使用 Asmifier”来为您生成 ASM 代码呢?这种方法可能介于我的方法和从头编写 ASM 代码之间,但是运行 ASM 并将生成的代码复制到 ClassLoaderClassVisitor 中也是相当繁琐的工作。
让我们看一下 ClassLoaderClassVisitor 的内部结构。它要做的第一项工作是重命名 defineClass 和 loadClass 方法(稍后我们将添加我们自己的 defineClass 和 loadClass 方法):
public Object invoke(int methodId, Object invoker, Object args[]);
为 java.lang.ClassLoader 中定义的每个方法调用第 7 行的 visitMethod 方法。 METHOD_NAME_UTIL 是一个对象,它被初始化以替换具有相同名称但带有“_feenix_”前缀的匹配“defineClass”或“loadClass”的字符串。 ClassLoader 的 loadClass(String name) 方法调用 loadClass(String name, boolean resolve) 第 8-9 行用于更新新的 _feenix_loadClass(String name) 方法中的任何方法指令,以便调用 _feenix_loadClass(String name, boolean resolve) 。同样,第 10-11 行确保新的 _feenix_defineClass 方法将始终调用其他 _feenix_defineClass 方法而不是 defineClass 方法。
ClassLoaderClassVisitor 的另一个有趣的部分是 visitEnd 方法:
public Object invoke(int methodId, Object invoker, Object args[]);
此方法读取 ClassLoaderTargeted 中定义的所有方法,并将我们想要的方法(有些方法就在那里以便编译)添加到我们的 java.lang.ClassLoader 中。我们想要的方法都是 defineClass 、 loadClass 和 defineMyClass 方法。它们只有一个问题:这些类中的一些方法指令将在 ClassLoaderTargeted 上运行,而不是 java.lang.ClassLoader ,因此我们需要扫描每个方法指令并相应地进行调整。您会注意到在第 6 行中我们使用 UpdateMethodInvocationsClassNode 对象来读取 ClassLoaderTargeted 字节码。此类将根据需要更新方法说明。
类重载实战
要亲自试用 Feenix 2.0(顺便说一句,我称它为 2.0 是为了将它与原始的 1.0 版本区分开来,但绝不应将其视为功能齐全的最终发行版),请执行以下操作:
- 下载 Feenix 2.0 发行版 并解压缩 zip。假设您将它放在 /projects/feenix-2.0 中。
- 假设您的目标 JVM 位于 /java/jdk1.7.0 。执行以下命令在 /projects/feenix-2.0 目录下生成 feenix-classloader.jar 文件:
public Object invoke(int methodId, Object invoker, Object args[]);
- 将 示例项目 下载到目录 /projects/feenix-example 中并解压到该目录中。
- 在您最喜欢的 IDE 中创建一个项目,您将使用它来编辑示例项目代码。
- 配置 /projects/feenix-example/feenix.xml 文件以指向包含项目编译类的目录。如果你是 Eclipse,你可以跳过这一步,因为它已经指向了项目的 bin 目录。
- 使用您的 IDE,使用以下 JVM 选项运行 ca.discotek.feenix.example.Example :
public Object invoke(int methodId, Object invoker, Object args[]);
-
将出现一个带有三个按钮的窗口。单击每个按钮以生成一些基线文本。
- 从现有打印机打印 。演示如何更改现有对象的功能。
- 从新打印机打印 。演示如何更改新对象的功能。
- 打印静态 。演示如何更改静态方法的功能。
- 导航到 ca.discotek.feenix.example.gui.Printer 类并修改 消息 字段的文本。导航到 ca.discotek.feenix.example.gui.ExampleGui 并修改 Printer.printStatic 的 String 参数。保存更改以使 IDE 编译新类。
- 再次单击窗口中的每个按钮并观察您的更改。
我们对类重载的调查到此结束。您应该记住,此演示是概念验证,可能无法按预期使用您自己的项目代码(未经过全面测试)。您还应该记住以下几点:
- 我应该提到,为了允许重新加载构造函数,需要 -noverify JVM 参数。
- 覆盖 java.lang.ClassLoader 的 代码不会覆盖 defineTransformedClass 。
- 还有一些悬而未决的问题(主要与反思有关)。
- 访问仅存在于类的新版本中的字段或方法仍然存在一个主要问题。
- 应考虑对任何生成的字段或方法使用 合成 修饰符。
- Feenix 使用重新捆绑的 ASM 副本。它与 ca.discotek.rebundled 包前缀重新捆绑,以避免当应用程序出于自身目的需要类路径上的 ASM 时发生类冲突。
- 介绍中列出的一些类重载机制目标没有得到解决(不重载非类资源或框架配置文件)。
资源
-
Feenix 2.0 发行版
,其中包括……
- Feenix 罐子
- 源代码
- Javadocs
- 示例项目
- Feenix Javadocs (带有链接的源代码)
系列预告片中的下一篇博客
如果任何关注最新 Java 新闻的人还没有听说过 Plumbr ,我会感到惊讶。 Plumbr 使用 Java 代理来识别应用程序中的内存泄漏。在撰写本文时,Plumbr 是“每个 JVM 每月 139 美元”。哎哟!在我的下一篇字节码工程博客中,我将向您展示如何使用检测和 Phantom References 免费识别代码中的内存泄漏。
如果您喜欢这篇文章,不妨 在 Twitter 上关注 discotek 。
- 查看更多信息:https: //discotek.ca/blog/?p =230