Java Agent

Java Instrumentation API,提供允许 Java 编程语言代理人(Agent)Instrument 在 JVM 上运行的程序的服务。

Agent:代理,以下文章中的所述代理都是指 Java Agent。

Instrumentation/Instrument:直译仪器,在计算机术语中也有植入、插桩、编排、性能测量的意思。

Instrument 设计的目的是将字节码添加到方法中,收集工具使用的数据。出于这个目的,因为更改纯粹是附加的,因此这些工具不会修改应用程序状态或行为。此类良性工具的示例包括监控代理、分析器、覆盖分析器和事件记录器。

由于这种检测机制提供了修改现有已编译 Java 类的字节码或添加字节码的能力,所以我们也可以用来动态修改运行的程序代码。

注意:开发人员/管理员负责验证他们部署的 Java 代理的内容和结构的可信度,因为它们能够任意转换来自其他 JAR 文件的字节码。由于这是在包含字节码的 Jars 被验证为可信之后发生的,因此 Java 代理的可信度将确定整个程序的可信度。

启动 Java Agent

代理通常部署为 JAR 文件。 JAR 文件清单(MANIFEST.MF)中的属性指定将加载以启动代理的代理类。可以通过多种方式启动代理:

  • 对于支持命令行接口的实现,可以通过在命令行上指定一个选项来启动代理。
  • 也支持在 VM 启动一段时间后启动代理的机制的实现。例如,实现可以提供一种机制,允许工具附加到正在运行的应用程序,并初始化加载工具代理到正在运行的应用程序中。
  • 代理也可以与应用程序一起打包在一个可执行的 JAR 文件中。

下面描述了 Java 11 中这些启动代理的方式:

通过命令行接口启动代理

如果实现提供了从命令行接口启动代理的方法,则可以通过向命令行添加以下选项来启动代理,这种实现一般称为静态加载

1
-javaagent:<jarpath>[=<options>]

其中 是代理 JAR 文件的路径, 是代理选项。

代理 JAR 文件的清单必须在其主清单中包含属性 Premain-Class。该属性的值是代理类的名称。代理类必须实现一个与主应用程序入口点原则相似的 public static premain 方法。在 Java 虚拟机 (JVM) 初始化之后,将调用 premain 方法,然后才是真正的应用程序 main 方法。 premain 方法必须返回才能继续启动。

premain 方法具有两个可能的签名之一。 JVM 首先尝试在代理类上调用以下方法:

1
public static void premain(String agentArgs, Instrumentation inst)

如果代理类未实现此方法,则 JVM 将尝试调用:

1
public static void premain(String agentArgs)

代理类可能还有一个 agentmain 方法,用于在 VM 启动后启动代理时使用(见下文)。当使用命令行选项启动代理时,不会调用 agentmain 方法。

每个代理都通过 agentArgs 参数传递其代理选项。代理选项作为单个字符串传递,任何额外的解析都应该由代理本身执行。

如果无法启动代理(例如,无法加载代理类,或者代理类没有合适的 premain 方法),JVM 将中止。如果 premain 方法抛出未捕获的异常,JVM 将中止。

-javaagent 选项可以在同一命令行上多次使用,从而启动多个代理。premain 方法将按照代理在命令行上指定的顺序调用。多个代理可能使用相同的

代理 premain 方法可以做什么没有建模限制。应用程序 main 可以做的任何事情,包括创建线程,在 premain 中都是合法的。

在 VM 启动后启动代理

实现也可以提供一种机制来在 VM 启动后的某个时间启动代理,一般称为动态加载。关于如何启动的细节是特定于实现的,但通常应用程序已经启动并且它的 main 方法已经被调用。

我们可以使用 Java Attach API 来将代理附加到应用程序:

1
2
3
VirtualMachine jvm = VirtualMachine.attach(jvmPid);
jvm.loadAgent(agentFile.getAbsolutePath());
jvm.detach();

如果实现支持在 VM 启动后启动代理,则以下适用:

  • 代理 JAR 的清单必须在其清单中包含属性 Agent-Class。该属性的值是代理类的名称。
  • 代理类必须实现 public static agentmain 方法。

agentmain 方法具有两种可能的签名之一。 JVM 首先尝试在代理类上调用以下方法:

1
public static void agentmain(String agentArgs, Instrumentation inst)

如果代理类未实现此方法,则 JVM 将尝试调用:

1
public static void agentmain(String agentArgs)

当使用命令行选项启动代理时,代理类也可能有一个 premain 方法。在 VM 启动后启动代理时,不会调用 premain 方法。

代理通过 agentArgs 参数传递其代理选项。代理选项作为单个字符串传递,任何额外的解析都应该由代理本身执行。

agentmain 方法应该执行启动代理所需的任何必要初始化。启动完成后,该方法应返回。如果无法启动代理(例如,因为无法加载代理类,或者因为代理类没有符合的 agentmain 方法),JVM 不会中止。如果 agentmain 方法抛出未捕获的异常,它将被忽略(但可能会被 JVM 记录以进行故障排除)。

在可执行 JAR 文件中包含代理

JAR 文件规范定义了打包为可执行 JAR 文件的独立应用程序的清单属性。

如果实现支持将应用程序作为可执行 JAR 启动的机制,则主清单可能包含 Launcher-Agent-Class 属性,以指定要在调用应用程序主方法之前启动的代理的类名。 Java 虚拟机尝试在代理类上调用以下方法:

1
public static void agentmain(String agentArgs, Instrumentation inst)

如果代理类未实现此方法,则 JVM 将尝试调用:

1
public static void agentmain(String agentArgs)

agentmain 方法应该执行启动代理和返回所需的任何必要初始化。如果无法启动代理,例如无法加载代理类,代理类没有定义符合规范的 agentmain 方法,或者 agentmain 方法抛出未捕获的异常或错误,JVM 将中止

加载代理类和代理类可用的模块/类

这里的模块是指 Java 11 的模块化系统的模块。

从代理 JAR 文件加载的类由系统类加载器加载,并且是系统类加载器的未命名模块的成员。系统类加载器通常也定义包含应用程序 main 方法的类。

代理类可见的类是系统类加载器可见的类,至少包括:

  • 引导层模块导出的包中的类。引导层是否包含所有平台模块将取决于初始模块或应用程序的启动方式。
  • 可以由系统类加载器(通常是类路径)定义为未命名模块成员的类。
  • 代理安排由引导类加载器定义的任何类,作为其未命名模块的成员。

如果代理类需要链接到不在引导层中的平台(或其他)模块中的类,那么应用程序可能需要在启动确保这些模块在引导层中。例如,在 JDK 实现中,--add-modules 命令行选项可用于将模块添加到要在启动时解析的根模块集中。

支持安排由引导类加载器加载的代理类(通过 appendToBootstrapClassLoaderSearch 或下面指定的 Boot-Class-Path 属性),必须只链接到定义到引导类加载器的类。不能保证所有平台类都可以由引导类加载器定义。

如果配置了自定义系统类加载器(通过 getSystemClassLoader 方法中指定的系统属性 java.system.class.loader),则它必须定义 appendToSystemClassLoaderSearch 中指定的 appendToClassPathForInstrumentation 方法。 换句话说,自定义系统类加载器必须支持将代理 JAR 文件添加到系统类加载器搜索的机制。

清单属性(Manifest Attributes)

为代理 JAR 文件定义了以下清单属性:

  • Premain-Class

    在 JVM 启动时指定代理时,此属性指定代理类。也就是说,包含 premain 方法的类。在 JVM 启动时指定代理时,此属性是必需的。如果该属性不存在,JVM 将中止。注意:这是一个类名,而不是文件名或路径。

  • Agent-Class

    如果实现支持在 VM 启动后某个时间启动代理的机制,则该属性指定代理类。即包含 agentmain 方法的类。如果此属性不存在,代理将不会启动。注意:这是一个类名,而不是文件名或路径。

  • Launcher-Agent-Class

    如果实现支持将应用程序作为可执行 JAR 启动的机制,则主清单可能包含此属性,以指定要在调用应用程序主方法之前启动的代理的类名。

  • Boot-Class-Path

    引导类加载器要搜索的路径列表。路径代表目录或库(在许多平台上通常称为 JAR 或 zip 库)。在定位类的平台特定机制失败后,引导类加载器会搜索这些路径。按照列出的顺序搜索路径。列表中的路径由一个或多个空格分隔。路径采用分层 URI 的路径组件的语法。如果路径以斜杠字符 (‘/’) 开头,则该路径是绝对路径,否则它是相对路径。根据代理 JAR 文件的绝对路径解析相对路径。格式错误和不存在的路径将被忽略。在 VM 启动后某个时间启动代理时,不代表 JAR 文件的路径将被忽略。该属性是可选的。

  • Can-Redefine-Classes

    布尔值(true 或 false,大小写无关)。是否能够重新定义此代理所需的类。 true 以外的值被认为是 false。该属性是可选的,默认为 false。

  • Can-Retransform-Classes

    布尔值(true 或 false,大小写无关)。是否能够重新转换此代理所需的类。 true 以外的值被认为是 false。该属性是可选的,默认为 false。

  • Can-Set-Native-Method-Prefix

    布尔值(true 或 false,大小写无关)。是否能够设置此代理所需的本机方法前缀。 true 以外的值被认为是 false。该属性是可选的,默认为 false。

代理 JAR 文件可能在清单中同时具有 Premain-ClassAgent-Class 属性。当使用 -javaagent 选项在命令行上启动代理时,Premain-Class 属性指定代理类的名称,而 Agent-Class 属性将被忽略。同样,如果代理在 VM 启动后的某个时间启动,则 Agent-Class 属性指定代理类的名称(忽略 Premain-Class 属性的值)。

修改代码

要修改已有类的代码,我们主要是通过传入的 Instrumentation 实例注册 ClassFileTransformer 转换器来实现。在转换器实现内我们通过类名和类加载器共同来判断是否是我们需要修改的类,然后去修改字节码,可以使用一些类库轻松实现字节码的修改。如果我们注册的是具有重新转换能力的转换器,则可以使用 retransformClasses 立即修改转换类,否则会在类定义、加载或重新定义时调用。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

也就是说要比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。

这里所指的“相等”,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象的所属关系判定等情况。

Instrumentation

在上面列出的方法签名的参数中,不管是静态加载的 premain 还是动态加载的 agentmain,都传递了一个 Instrumentation 实例给我们。一旦我们的代理获得了 Instrumentation 实例,就可以随时调用该实例上的方法。

该实例主要包含以下方法:

方法名 描述
addTransformer(ClassFileTransformer transformer) 注册提供的转换器。
addTransformer(ClassFileTransformer transformer, boolean canRetransform) 注册提供的转换器,当 canRetransform 为 true 时,代表该转换器具有重新转换能力。
removeTransformer(ClassFileTransformer transformer) 取消注册提供的转换器。
getAllLoadedClasses() 返回 JVM 当前加载的所有类的数组。
getInitiatedClasses(ClassLoader loader) 返回 loader 启动加载器所有类的数组。
isModifiableClass(Class<?> theClass) 测试一个类是否可以通过 retransformationredefinition 来修改。
isRetransformClassesSupported() 返回当前 JVM 配置是否支持类的重新转换。
retransformClasses(Class<?>… classes) 重新转换提供的类集合。
isRedefineClassesSupported() 返回当前 JVM 配置是否支持重新定义类。
redefineClasses(ClassDefinition… definitions) 使用给定的类文件重新定义提供的类集合。
isModifiableModule(Module module) 测试是否可以使用 redefineModule 修改模块。
redefineModule(…) 重新定义模块以扩展它读取的模块集、它导出或打开的包集或其使用或提供的服务。
getObjectSize(Object objectToSize) 返回指定对象消耗的存储量的特定于实现的近似值。
appendToBootstrapClassLoaderSearch(JarFile jarfile) 指定一个 JAR 文件,其中包含要由引导程序类加载器定义的检测类。
appendToSystemClassLoaderSearch(JarFile jarfile) 指定一个 JAR 文件,其中包含要由系统类加载器定义的检测类。

ClassFileTransformer

如果我们要转换一个类,则可以通过注册一个自定义的 ClassFileTransformer 来实现,Java 虚拟机会在加载重新定义重新转换类时调用该实例的 transform 方法。转换器是在 Java 虚拟机定义类之前被调用。

ClassFileTransformer 可以实现为具有重新转换能力的转换器,通过在注册时将 canRetransform 参数传入为 true 告诉注册器自己有重新转换的能力。

一旦使用 addTransformer 注册了转换器,将在每个新的类定义时和每个类重新定义时调用转换器。在每个类重新转换时,也将调用具有重新转换能力的转换器。对新类定义的请求是使用 ClassLoader.defineClass 或其原生等效方法进行的。类重新定义的请求是使用 Instrumentation.redefineClasses 或其原生等效方法进行的。类重新转换的请求是使用 Instrumentation.retransformClasses 或其原生等效方法进行的。在处理请求期间,转换器是在类的文件字节被验证和应用之前调用。当有多个转换器时,转换操作是通过转换器调用链组成的。也就是说,一次调用返回的字节数组成为下一次调用的输入(通过 classfileBuffer 参数)。

转换器将按以下顺序生效:

  • 没有再转换能力的转换器
  • 没有再转换能力的原生转换器
  • 有再转换能力的转换器
  • 有再转换能力的原生转换器

对于再转换,不调用没有再转换能力的转换器,而是重用前一次转换的结果。其他情况,该转换方式始终被调用。在这些所有分组中,转换器都是按照注册的顺序被调用。本机转换器由 Java 虚拟机工具接口(JVMTI)中的 ClassFileLoadHook 事件提供)。

传给第一个转换器的输入(classfileBuffer 参数)是:

  • 对于新的类定义,传递其 ClassLoader.defineClass 的字节
  • 对于类重新定义,是 Instrumentation.redefineClasses 时传入的参数 ClassDefinition 实例的 getDefinitionClassFile() 返回结果
  • 对于类重新转换,传递的是新类定义的字节。或者,如果是重新定义,则是最后一次重新定义的字节。

如果实现方法确定不需要转换,则应返回 null。否则,它应该创建一个新的 byte[] 数组,将输入的 classfileBuffer 连同所有所需的转换复制到其中,并返回新数组。不得修改输入的 classfileBuffer

在重新转换和重新定义的情况下,转换器必须支持重新定义语义:如果转换器在初始定义期间更改的类后来被重新转换或重新定义,则转换器必须确保第二个类输出类文件是第一个输出类文件的合法重新定义。

如果转换器抛出异常(它没有捕获),后续转换器仍将被调用,并且仍将尝试加载、重新定义或重新转换。因此,抛出异常与返回 null 具有相同的效果。为了防止在转换器代码中生成未经检查的异常时出现意外行为,转换器可以捕获 Throwable。如果转换器认为 classFileBuffer 不代表有效格式化的类文件,它应该抛出一个 IllegalClassFormatException;虽然这与返回 null 具有相同的效果。它有助于记录或调试格式损坏。

字节码操作库

字节码的操作库有的偏重性能,有的偏重更友好的 API 操作,下面介绍几种常见的字节码操作库:

Byte Buddy

Byte Buddy 是一个代码生成和操作库,用于在 Java 应用程序运行时创建和修改 Java 类,无需编译器的帮助。

Byte Buddy 是一个相当新的库,但提供了 CGLIB 或 Javassist 提供的任何功能等等。Byte Buddy 可以完全定制到字节码级别,并带有一个富有表现力的领域特定语言(DSL),在操作字节码时,它可能是最安全、最合理的选择,而且代码可读性很高。

Byte Buddy 也提供了一个方便的 API 来定义 Java 代理(agent),比如下面这个统计代码时间的 Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TimerAgent {
public static void premain(String arguments,
Instrumentation instrumentation) {
new AgentBuilder.Default()
.type(ElementMatchers.nameEndsWith("Timed"))
.transform((builder, type, classLoader, module) ->
builder.method(ElementMatchers.any())
.intercept(MethodDelegation.to(TimingInterceptor.class))
).installOn(instrumentation);
}
}

public class TimingInterceptor {
@RuntimeType
public static Object intercept(@Origin Method method,
@SuperCall Callable<?> callable) {
long start = System.currentTimeMillis();
try {
return callable.call();
} finally {
System.out.println(method + " took " + (System.currentTimeMillis() - start));
}
}
}

Javassist

它是 Java 中用于编辑字节码的类库;它使 Java 程序能够在运行时定义一个新类,并在 JVM 加载类文件时修改它。与其他类似的字节码编辑器不同,Javassist 提供了两个级别的 API:源代码级和字节码级。如果用户使用源代码级 API,他们可以在不了解 Java 字节码规范的情况下编辑类文件。

CGLIB

CGLIB 速度非常快,这是它仍然存在的主要原因之一。

一般来说,允许在运行时重写类的库必须避免在重写相应的类之前加载任何类型。 因此,它们不能使用 Java 反射 API 来加载反射中使用的任何类型,所以他们必须通过 IO(这是一个性能破坏者)读取类文件。 这使得 Javassist 或 Proxetta 比 Cglib 慢得多,CGLIB 只是通过反射 API 读取方法并覆盖它们。

ASM

CGLIB、Byte Buddy 和几乎所有其他库都建立在 ASM 之上,ASM 本身在非常低的级别上操作字节码。这对大多数人来说是个障碍,因为您必须了解字节码和一点点 JVMS 才能正确使用它。

但是掌握 ASM 无疑是非常有趣的。 但是请注意,虽然有一个很棒的 ASM 4 指南,但在 API 的某些部分中,javadoc 文档可能非常简洁,ASM 正在改进它的文档。

它紧跟 JVM 版本以支持新功能。

Java Agent 和 JVM Native Agent

虽然两者都以几乎相同的方式加载到 JVM 中(使用特殊的 JVM 启动参数),但它们的构建方式几乎完全不同。

native agent 一般使用 -agentlib-agentpath 命令行参数指定,Java agent 静态加载是通过 -javaagent 指定。

Java Agent 底层也是通过 JVMTI 接口来驱动的。

Native Agents

Native 代理是完全不同的野兽。如果您认为 Java 代理可以让您做很酷的事情,那么请抓紧时间,因为本地代理在完全不同的层面上运行。本机代理不是用 Java 编写的,而是主要用 C++ 编写的,并且不受普通 Java 代码运行的规则和限制的约束。不仅如此,它们还提供了一组极其强大的功能,称为 JVM 工具接口 (JVMTI)。

它能做什么

jvmti.h 公开的这组 API 使 JVM 动态加载的 C++ 库能够获得对 JVM 实时工作的极高级别的可见性。这跨越了广泛的领域,包括 GC、locking、代码操作、同步、线程管理、编译调试等等。JVMTI 旨在使 JVM 尽可能透明,同时仍保持设计灵活性,以允许 JVM 供应商提供不同的底层实现。这组 API 非常广泛,包含数百个关于 JVM 的回调和函数。您可以使用这些来做 Java 代理无法做的非常强大的事情,例如编写自己的调试器,或者构建低级、实时错误分析工具,比如 JRebel 和 JProfiler 等。

JRebel 通过 JVMTI 构建一种在运行时提供类的平滑热交换而无需重新启动 JVM 的技术。

JProfiler 通过 JVMTI 修改目标框架的代码以注入收集性能指标的新代码。