前言
在 jdk 1.5 之后引入了 java.lang.instrument 包,该包提供了检测 java 程序的
Api,用于监控、收集性能信息、诊断问题等。通过 java.lang.instrument 实现的工具我们称之为 Java Agent ,Java
Agent 能够在不影响正常编译的情况下来修改字节码,即动态修改已加载或者未加载的类,包括类的属性、方法等。
Java Agent的使用方式有两种(图源先知社区):
premain
方法,在JVM启动前加载。
agentmain
方法,在JVM启动后加载。
在实际环境中,目标的JVM通常都是已经启动的状态,无法预先加载premain。相比之下,孰轻孰重已不言而喻。
premain和agentmain函数声明如下,方法名相同情况下,拥有Instrumentation inst参数的方法优先级更高:
public static void agentmain(String agentArgs, Instrumentation inst) {
...
}
public static void agentmain(String agentArgs) {
...
}
public static void premain(String agentArgs, Instrumentation inst) {
...
}
public static void premain(String agentArgs) {
...
}
JVM 会优先加载带Instrumentation
签名的方法,加载成功则忽略第二种。如果第一种没有,则加载第二种方法。
-
第一个参数
String agentArgs
就是Java agent的参数。 -
Inst
是一个java.lang.instrument.Instrumentation
的实例,可以用来类定义的转换和操作等等。
premain 方式
JVM启动时 会先执行premain
方法,大部分类加载都会通过该方法,注意: 是大部分,不是所有 。遗漏的主要是系统类,因为很多系统类先于
agent 执行,而用户类的加载肯定是会被拦截的。也就是说, 这个方法是在 main 方法启动前拦截大部分类的加载活动
,既然可以拦截类的加载,就可以结合第三方的字节码编译工具,比如ASM,javassist,cglib等来改写实现类。
1)创建应用程序hello.jar
package com.nsfocus.test
public class hello {
public static void main (String[] args) {
System.out.println("hello world");
}
}
将com.nsfocus.test.hello打包成hello.jar后单独执行java -jar hello.jar
2)创建premain方式的Agent
package com.nsfocus.test
import java.lang.instrument.Instrumentation;
public class PreDemo {
public static void premain(String args, Instrumentation inst) throws Exception{
for (int i = 0; i < 10; i++) {
System.out.println("I'm premain agent");
}
}
}
此时项目如果打包成jar包执行,则会因绝少入口main而报错(Java默认main为入口)。故需自定义一个MANIFEST.MF
文件,用于指明premain
的入口:
Manifest-Version: 1.0
Premain-Class: com.nsfocus.test.PreDemo
注:最后一行是 空行,不能省略 。以下是MANIFEST.MF的其他选项:
Premain-Class: 包含 premain 方法的类(类的全路径名)
Agent-Class: 包含 agentmain 方法的类(类的全路径名)
Boot-Class-Path: 设置引导类加载器搜索的路径列表。查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理 JAR 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径。(可选)
Can-Redefine-Classes: true表示能重定义此代理所需的类,默认值为 false(可选)
Can-Retransform-Classes: true 表示能重转换此代理所需的类,默认值为 false (可选)
Can-Set-Native-Method-Prefix: true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)
3)使用premain进行注入
java -javaagent:PreDemo.jar -jar hello.jar
agentmain 方式
写一个agentmain
和premain
差不多,只需要在META-INF/MANIFEST.MF
中加入Agent-Class:
即可。
agent:
package com.nsfocus.test
import java.lang.instrument.Instrumentation;
public class AgentDemo {
public static void agentmain(String agentArgs, Instrumentation inst) {
for (int i = 0; i < 10; i++) {
System.out.println("I'm agentmain agent");
}
}
}
META-INF/MANIFEST.MF:
Manifest-Version: 1.0
Agent-Class: com.nsfocus.agent.AgentDemo
Can-Retransform-Classes: true
Can-Redefine-Classes: true
不同之处在于,这种方法不是在JVM启动前使用参数来指定的。官方为了实现启动后的加载,提供了Attach API
。Attach API 很简单,只有 2
个主要的类,都在com.sun.tools.attach
包里面。需要着重关注的是VitualMachine
这个类,它用来与目标JVM建立连接,从而在启动后加载我们的agentmain。
坑 :Linux、Windows等不同下的Attach API
不尽相同,详见下文。
补 :总的来说,agentmain的实现也并不是很难理解。笔者将其简要概括为三个阶段:
连接(VirtualMachine) => 加载(Instrumentation) => 修改(Javassist),详见下文。
VirtualMachine
字面意义表示虚拟机,也就是Agent程序需要监控的目标JVM。它提供了获取系统信息、loadAgent
,Attach
和Detach
等方法,可以实现的功能非常强大
。该类允许我们给attach方法传入一个JVM的pid,远程连接到目标JVM上
。代理类注入操作只是它众多功能中的一个,我们可以通过loadAgent
方法向JVM注册一个代理程序Agent,在该Agent代理程序中将会得到一个Instrumentation
实例。
VirtualMachine的用法:
// com.sun.tools.attach.VirtualMachine
// 下面的示例演示如何使用VirtualMachine:
// attach to target VM
VirtualMachine vm = VirtualMachine.attach("2177");
// start management agent
Properties props = new Properties();
props.put("com.sun.management.jmxremote.port", "5000");
vm.startManagementAgent(props);
// detach
vm.detach();
// 在此示例中,我们附加到由进程标识符2177标识的Java虚拟机。然后,使用提供的参数在目标进程中启动JMX管理代理。最后,客户端从目标VM分离。
attacher:
package com.nsfocus.attacher;
import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import java.io.IOException;
public class AgentAttach {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
String id = args[0];
String jarName = args[1];
System.out.println("id ==> " + id);
System.out.println("jarName ==> " + jarName);
VirtualMachine virtualMachine = VirtualMachine.attach(id);
virtualMachine.loadAgent(jarName);
virtualMachine.detach();
System.out.println("ends");
}
}
Manifest-Version: 1.0
Main-Class: com.nsfocus.attacher.AgentAttach
过程非常简单:通过pid attach到目标JVM - > 加载agent -> 解除连接。
后话 :在windows下将该项目打包为attacher.jar后,复制到linux下执行报错:
Exception in thread "main" java.lang.UnsatisfiedLinkError: sun.tools.attach.WindowsAttachProvider.tempPath()Ljava/lang/String;
at sun.tools.attach.WindowsAttachProvider.tempPath(Native Method)
at sun.tools.attach.WindowsAttachProvider.isTempPathSecure(WindowsAttachProvider.java:74)
at sun.tools.attach.WindowsAttachProvider.listVirtualMachines(WindowsAttachProvider.java:58)
at com.sun.tools.attach.VirtualMachine.list(VirtualMachine.java:134)
at sun.tools.jconsole.LocalVirtualMachine.getAttachableVMs(LocalVirtualMachine.java:151)
at sun.tools.jconsole.LocalVirtualMachine.getAllVirtualMachines(LocalVirtualMachine.java:110)
...
前面已经提到了,不同的AttachProvider适用于不同的平台,即不同平台下的${JAVA_HOME}/lib/tools.jar
有略微的差别:
[solaris] sun.tools.attach.SolarisAttachProvider
[windows] sun.tools.attach.WindowsAttachProvider
[linux] sun.tools.attach.LinuxAttachProvider
将项目拷贝到linux下再打包,或者将windows下的tools.jar替换为linux下的tools.jar均能解决这个问题。
Instrumentation
通过attacher连接到目JVM后,可以通过instrumentation类和目标JVM上的类进行交互,为动态修改字节码奠定基础。
官方文档:[java.lang.instrument (Java SE 9 & JDK 9 )
(oracle.com)](https://docs.oracle.com/javase/9/docs/api/java/lang/instrument/package-
summary.html)
public interface Instrumentation {
// 增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
void addTransformer(ClassFileTransformer transformer);
// 删除一个类转换器
b