在 Java 中运用动态挂载实现 Bug 的热修复

大多数 JVM 具备 Java 的 HotSwap 特性,大部分开发者认为它仅仅是一个调试工具。利用这一特性,有可能在不重启 Java 进程条件下,改变 Java 方法的实现。典型的例子是使用 IDE 来编码。然而 HotSwap 可以在生产环境中实现这一功能。通过这种方式,不用停止运行程序,就可以扩展在线的应用程序,或者在运行的项目上修复小的错误。这篇文章中,我将演示动态绑定、应用运行期代码变化进行绑定、介绍一些工具 API 以及 Byte Buddy 库,这个库提供了一些 API 代码改变更方便。

假设有一个正在运行的应用程序,通过校验 HTTP 请求中的 X-Priority 头部,来执行服务器的特殊处理。该校验使用下面的工具类来实现:

class HeaderUtility {

    static boolean isPriorityCall(HttpServletRequest request) {
        return request.getHeader("X-Pirority") != null;
    }

}

其它翻译版本(1)

你发现错误了吗?这样的错误很常见,尤其是在测试代码中常量值分解为静态字段重用。在不太理想的情况下,这个错误只会在产品被安装的时候才被发现,其中头通过另外一个应用生成并没有拼写错误。

修复这样的错误并不难。在持续交付的时代,重新部署一个新的版本只需要点击一下按钮。但在其他情况下,变更可能就不是那么简单了,重新部署过程可能比较复杂,其中停机是不允许的,带着错误运行可能会比较好。但 HotSwap 给我们提供了另外一种选择:在不重启应用的前提下进行小幅改动。

Attach API:使用动态附件来渗透另外一个 JVM

为了修改一个运行中的 Java 程序,我们首先需要一种可以同处在运行状态的 JVM 进行通信的方式。因为 Java 的虚拟机实现是一个受到管理的系统,因此拥有进行这些操作的标准 API。提问中涉及到的 API 被称作 attachment API,它是官方 Java 工具的一部分。使用这个由运行之中的 JVM 所暴露的 API,能让第二个 Java 进程来同其进行通信。

事实上,我们已经用到了该 API: 它已经由诸如 VisualVM 或者 Java Mission Control 这样的调试和模拟工具进行了应用。应用这些附件的 API 并没有同日常使用的标准 Java API 打包在一起,而是被打包到了一个特殊的文件之中,叫做 tools.jar,它只包含了一个虚拟机的 JDK 打包发布版本。更糟糕的是,这个 JAR 文件的位置并没有进行设置,它在 Windows、Linux,特别是在 Macintosh 上的 VM 都存在差别,不光文件的位置,连文件名也各异,有些发行版上就被叫做 classes.jar。最后,IBM 甚至决定对这个 JAR 中包含的一些类的名称进行修改,将所有 com.sun 类挪到 com.ibm 命名空间之中, 又添了一个乱子。在 Java 9 中,乱糟糟的状态才最终得以清理,tools.jar 被 Jigsaw 的模块 jdk.attach 所替代。

在对 API 的 JAR (或者模块) 进行了定位之后,我们就该让其对附件进程可用。在 OpenJDK 上,被用来连接到另外一个 JVM 的类叫做 VirtualMachine,它向任何由位于同一台物理机器上的 JDK 或者是一个普通的 HtpSpot JVM 所运行的 VM 提供了一个入口点。在通过进程 id 附加到另外一台虚拟机上之后,我们就能够在目标 VM 指定的一个线程中运行一个 JAR 文件:

// the following strings must be provided by us
String processId = processId();
String jarFileName = jarFileName();
VirtualMachine virtualMachine = VirtualMachine.attach(processId);
try {
    virtualMachine.loadAgent(jarFileName, "World!");
} finally {
    virtualMachine.detach();
}

在收到一个 JAR 文件之后,目标虚拟机会查看该 JAR 的程序清单描述文件(manifest),并定位处在 Premain-Class 属性之下的类。这非常类似于 VM 执行一个主方法的方式。有了一个 Java 代理,VM 和指定的进程 id 就可以查找到一个名为 agentmain 的方法,该方法可以由指定线程中的远程进程来执行:

public class HelloWorldAgent {

    public static void agentmain(String arg) {
        System.out.println("Hello, " + arg);
    }

}

使用该 API,只要我们知道一个 JVM 的进程 id,就可以来在其上运行代码,打印出一条 Hello, World! 消息。甚至有可能同并不熟 JDK 发行版一部分的 JVM 进行通信,只要附加的 VM 是一个用来访问 tools.jar 的 JDK 安装程序。

Instrumentation API:修改目标 VM 的程序

到目前来看一切顺利。但是除了成功地同目标 VM 建立起了通信之外,我们还不能够修改目标 VM 上的代码以及 BUG。后续的修改,Java 代理可以定义第二参数来接收一个 Instrumentation 的实例 。稍后要实现的接口提供了向几个底层方法的访问途径,它们中的一个就能够对已经加载的代码进行修改。

为了修正 "X-Pirority" 错字,我们首先来假设为 HeaderUtility 引入了一个修复类,叫做 typo.fix,就在我们下面所开发的 BugFixAgent 后面的代理的 JAR 文件中。此外,我们需要给予代理通过向 manifest 文件添加 Can-Redefine-Classes: true 来替换现有类的能力。有了现在这些东西,我们就可以使用 instrumentation 的 API 来对类进行重新定义,该 API 会接受一对已经加载的类以及用来执行类重定义的字节数组:

public class BugFixAgent {

    public static void agentmain(String arg, Instrumentation inst)
            throws Exception {
        // only if header utility is on the class path; otherwise,
        // a class can be found within any class loader by iterating
        // over the return value of Instrumentation::getAllLoadedClasses
        Class<?> headerUtility = Class.forName("HeaderUtility");

        // copy the contents of typo.fix into a byte array
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        try (InputStream input =
                BugFixAgent.class.getResourceAsStream("/typo.fix")) {
            byte[] buffer = new byte[1024];
            int length;
            while ((length = input.read(buffer)) != -1) {
                output.write(buffer, 0, length);
            }
        }

        // Apply the redefinition
        instrumentation.redefineClasses(
                new ClassDefinition(headerUtility, output.toByteArray()));
    }

}

运行上述代码后,HeaderUtility 类会被重定义以对应其修补的版本。对 isPrivileged 的任何后续调用现在将读取正确的头信息。作为一个小的附加说明,JVM 可能会在应用类重定义时执行完全的垃圾回收,并且会对受影响的代码进行重新优化。 总之,这会导致应用程序性能的短时下降。然而,在大多数情况下,这是较之完全重启进程更好的方式。

当应用代码更改时,要确保新类定义了与它替换的类完全相同的字段、方法和修饰符。 尝试修改任何此类属性的类重定义行为都会导致 UnsupportedOperationException。现在 HotSpot 团队正试图去掉这个限制。此外,基于 OpenJDK 的动态代码演变虚拟机支持预览此功能。

使用 Byte Buddy 来追踪内存泄漏

一个如上述示例的简单的 BUG 修复代理在你熟悉了 instrumentation 的 API 的时候是比较容易实现的。只要更加深入一点,也可以在运行代理的时候,无需手动创建附加的 class 文件,而是通过重写现有的 class 来应用更多通用的代码修改。

字节码操作

编译好的 Java 代码所呈现的是一系列字节码指令。从这个角度来看,一个 Java 方法无非就是一个字节数组,其每一个字节都是在表示一个向运行时发出的指令,或者是最近一个指令的参数。每个字节对应其意义的映射在《Java 虚拟机规范》中进行了定义,例如字节 0xB1 就是在指示 VM 从一个带有 void 返回类型的方法返回。因此,对字节码进行增强就是对一个方法的字节数字进行扩展,将我们想要应用的表示额外的业务逻辑指令包含进去。

当然,逐个字节的操作会特别麻烦,而且容易出错。为了避免手工的处理,许多的库都提供了更高级一点的 API,使用它们不需要我们直接同 Java 字节码打交道。这样的库其中就有一个叫做 Byte Buddy (当然我就是该库的作者)。它的功能之一就是能够定义可以在方法原来的代码之前和之后被执行的模板方法。

分析一个发生泄漏的应用程序

这里的示例假定一个应用程序在生产环境运行了几周之后,被发现发生了泄漏资源的现象。像这样的 BUG 很难去跟踪,因为在一个可观察的测试环境中重现这样一个类似的问题状态比较困难。因此,(目前)要排除这样的问题除了重新启动崩溃的应用程序之外,我们还可以在当前线程中修改应用程序的代码来对泄漏进行跟踪。为了找出哪里才是应用程序泄漏句柄的位置,我们会对任何 Closableobject 的生命周期进行跟踪。使用一个 Java 代理,我们可以修改任何构造,或者关闭任意一个这样的对象。有了这个信息,我们就有希望确认哪个对象在哪种情况下会发生泄漏。

对模板进行操作

为了实现这一行为,我们首先要定义模板,模板里面包含了我们想要添加到任意一个 Closeable 的对象的构造器或者关闭方法调用中去的代码。为了了解发生泄漏的对象是在哪里被创建或者关闭的,我们就会想要在每一个这样的时间发生时将跟踪栈打印出来。针对这一逻辑的模板方法代码如下:

class ConstructionTemplate {

    @Advice.OnMethodExit
    static void exit(@Advice.This Object self) {
        new RuntimeException("Created: " + self).printStackTrace();
    }

}

class CloseTemplate {

    @Advice.OnMethodEnter
    static void enter(@Advice.This Object self) {
        new RuntimeException("Closed: " + self).printStackTrace();
    }

}

模板方法上加上了 OnMethodExit 和 OnMethodEnter 注解,以通知 Byte Buddy 我们会想要它在什么时候被调用。它们的任何参数都会被加上注解,以指定其在被重新定义的方法中所表示的值。当应用了该模板时,Byte Buddy 接着就会将任何针对一个参数的访问映射到对注解的值进行加载,就好像方法实例一般就是由这个来表示的。

为了将该修改应用到任何实现了 Closable 接口的类,Byte Buddy 提供了一种特定领域语言用以创建来匹配类型和方法的 Java 代理,并在其可应用时对上述模板进行应用:

public class TracingAgent {

    public static void agentmain(String arg, Instrumentation inst) {
        new AgentBuilder.Default()
                // by default, JVM classes are not instrumented
                .ignore(none())
                .disableClassFormatChanges()
                .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
                .type(isSubTypeOf(Closable.class))
                .transform((builder, type, loader) -> builder
                        .visit(Advice
                                .to(ConstructionTemplate.class)
                                .on(isConstructor()))
                        .visit(Advice
                                .to(CloseTemplate.class)
                                .on(named("close").and(takesArguments(0)))
                        ))
                .installOn(inst);
    }

}

在默认情况下,Byte Buddy 不会对位于 java.* 命名空间中定义的类进行修改,它们可以通过设置一个无效的忽略匹配器来被修改。此外, Byte Buddy 需要在我们对重新转换进行应用的时候被告知不要去修改我们前面讨论过的 class 文件格式。只有这样做,Byte Buddy 才可以自动地侦测到任何实现了 Closable 接口的现有或者未来将会有的类型,并将上述的模板代码添加到其构造器和关闭方法。

附加代理

Byte Buddy 还提供了在运行时附加一个代理的便利方法。为了应对 tools.jar 位置和虚拟机类型的差异, Byte Buddy 给附件添加了一个抽象层,在这一层可以自动检测到正确的设置。在将上述的代理打包到一个 JAR 文件中之后,就可以通过调用如下代码来附加到发生资源泄漏的进程:

File jarFile = getAgent();
String processId = getProcessId();
ByteBuddyAgent.install(jarFile, processId);

当然,上述代理很快就会为手动处理创建太多的输出。为了提供更多的便利,在释放之前再来收集被创建的对象也许会更有用。通过创建一个 Java 代理,这样也可以同编写一个 Java 应用程序来实现此行为一样的简单。

总结

在本文中,我们深入了解了 attach API,它使得将一个 Java 代理按顺序注入到任意一个运行中的 JVM 进程成为可能。该代理被表示为一个 JAR 文件,里面包含了一个类,类里面有一个远程进程可以在一个指定线程中执行的 agentmain 方法。该方法可以接收一个 Instrumentation 接口的实例作为参数,可以读已经加载的类进行重新定义。对代码的重定义,既可以通过用打了补丁的版本替换整个 class 文件来实现,也可以通过对现有类的字节码进行修改来实现,这样的操作可以比较简单的用诸如 Byte Buddy 这样的库来完成。

时间: 2024-11-06 07:39:50

在 Java 中运用动态挂载实现 Bug 的热修复的相关文章

java中的动态代理机制

在java的动态代理机制中,有两个重要的类或接口,一个是 InvocationHandler(Interface).另一个则是 Proxy(Class),这一个类和接口是实现我们动态代理所必须用到的.下面通过代码来学习java中的动态代理技术. 首先定义一个接口: package com.aop.spring; /** * Created by xinfengyao on 16-2-29. */ public interface Perform { public void play(); } 实

使用Java中的动态代理实现数据库连接池

2002 年 12 月 05 日 作者通过使用JAVA中的动态代理实现数据库连接池,使使用者可以以普通的jdbc连接的使用习惯来使用连接池. 数据库连接池在编写应用服务是经常需要用到的模块,太过频繁的连接数据库对服务性能来讲是一个瓶颈,使用缓冲池技术可以来消除这个瓶颈.我们可以在 互联网上找到很多关于数据库连接池的源程序,但是都发现这样一个共同的问题:这些连接池的实现方法都不同程度地增加了与使用者之间的耦合度.很多的连接池 都要求用户通过其规定的方法获取数据库的连接,这一点我们可以理解,毕竟目前

十分钟理解Java中的动态代理

十分钟帮助大家理解Java中的动态代理,什么是动态代理?感兴趣的小伙伴们可以参考一下 若代理类在程序运行前就已经存在,那么这种代理方式被成为 静态代理 ,这种情况下的代理类通常都是我们在Java代码中定义的. 通常情况下, 静态代理中的代理类和委托类会实现同一接口或是派生自相同的父类. 一.概述1. 什么是代理我们大家都知道微商代理,简单地说就是代替厂家卖商品,厂家"委托"代理为其销售商品.关于微商代理,首先我们从他们那里买东西时通常不知道背后的厂家究竟是谁,也就是说,"委托

Java中的动态代理及反射机制

面向对象的基本原则封装.继承.多态,在java中多态机制,表现为变量多态,方法多态,这都是指的是因对象所属的类不同,而调用不同的类方法:对于对象的方法,还有函数重载,java中的函数的签名是由函数名+参数方法来定的,不能仅由返回值不同来定. 反射Reflect 运行时获取类的类型,域,方法等各种属性. Class是一个类,其实例对应其他不同分 (CalculatorImpl)Class.forName("CalculatorImpl").newInstance(); newInstan

java中的动态代理(一)

今天我们来学习java中的另一个重要的特性叫做动态代理.所谓动态代理是可以在java运行过程中动态的创建一个类去实现一个或多个接口,可以在不修改原有类代码的基础上动态的添加功能和方法.正是因为这个特性使动态代理被java的许多框架中被广泛的使用. 在介绍动态代理之前我需要先介绍一下什么是静态代理.在设计模式那一章我曾经介绍过一种设计模式叫做代理模式.在代理模式中有一个公共的接口,代理对象和实际的对象都需要去实现这个接口.在代理对象中有一个成员属性变量指向实际对象并在代理对象的构造函数中初始化,在

java中的动态代理(二)

上一节我介绍了什么是静态代理.在静态代理中的代理对象是直接定义在代码中的,这样会导致代码不能复用并且工作量也会成倍的增加所以在日常的开发中我们更多使用的是动态代理模式.在动态代理中,代理类在是程序运行中动态生成的,在java中一般有两种方式来实现动态代理模式,它们分别是javaSDK动态代理和第三方类库cglib动态代理. 今天我介绍的是java SDK动态代理.首先我们先来看一下如何使用java SDK实现动态代理模式: public class JavaSDKProxyTest { stat

Java中的动态代理以及Proxy类的偷瞄

动态代理机制 所谓动态代理,即通过代理类Proxy的代理,接口和实现类之间可以不直接发生联系,而可以在运行期(Runtime)实现动态关联. Java动态代理类位于Java.lang.reflect包下,主要涉及到两个类. (1)接口InvocationHandler:该接口中仅定义了一个方法. Object invoke(Object obj, Method method, Object[] args); 在实际使用时,第一个参数obj一般指代理类,method是被代理的方法,args为该方法

java中的动态代理

一.类中实现了接口,使用java.lang.reflect.Proxy类,创建一个代理工厂,可以动态代理类中方法. 说明:被代理的类需要有实现的接口. 例子: 1 实体类代码 实体类代码 public class UserDao implements IUserDao{ public UserDao() { // TODO Auto-generated constructor stub } public void saveUser() { System.out.println("保存用户成功,s

Java 代理模式(二) Java中的动态代理

动态代理类 Java动态代理类位于java.lang.reflect包下,一般主要涉及到以下两个类: 1.Interface InvocationHandler 该接口中仅定义了一个方法: Object invoke(Object proxy, Method method, Object[] args) 在实际使用时,第一个参数obj一般是指代理类,method是被代理的方法,如上例中的request(),args为该方法的参数数组(无参时设置为null). 这个抽象方法在代理类中动态实现. 2