目录
- java.instrument和java.attach
-
- 核心架构与工作机制
-
- [Instrumentation 定义](#Instrumentation 定义)
- 核心组件
- 类加载时机与修改能力边界
- [Agent 启动方式详解](#Agent 启动方式详解)
-
- 启动方式一:命令行启动 (`-javaagent`)
- 启动方式二:动态附加 (`Attach API`)
- [启动方式三:可执行 JAR 内嵌 (`Launcher-Agent-Class`)](#启动方式三:可执行 JAR 内嵌 (
Launcher-Agent-Class)) - 类加载与模块可见性
- 清单属性速查表
- [基于 ASM 的字节码操作实战](#基于 ASM 的字节码操作实战)
- [JDK Attach API](#JDK Attach API)
-
- 核心概念
- 核心类与接口
- [典型操作流程(JDK 17 适配)](#典型操作流程(JDK 17 适配))
-
- [1. 列出当前可用的 Java 虚拟机](#1. 列出当前可用的 Java 虚拟机)
- [2. 连接到目标虚拟机](#2. 连接到目标虚拟机)
- [3. 动态加载 Agent (`loadAgent`)](#3. 动态加载 Agent (
loadAgent)) - [4. 获取系统属性与 Agent 列表](#4. 获取系统属性与 Agent 列表)
- [5. 热加载本地库 (`loadAgentLibrary` / `loadAgentPath`)](#5. 热加载本地库 (
loadAgentLibrary/loadAgentPath))
- 权限与安全
- [高级 Instrumentation API](#高级 Instrumentation API)
-
- [1. retransformClasses](#1. retransformClasses)
- [2. redefineClasses](#2. redefineClasses)
- [3. getObjectSize](#3. getObjectSize)
- 打包
java.instrument和java.attach
本文档详细阐述 java.instrument,java.attach模块的的机制、API 以及基于 ASM 的字节码操作实战。
核心架构与工作机制
Instrumentation 定义
java.lang.instrument 包提供的接口允许开发者在 Java 虚拟机(JVM)加载类文件(.class)时,或者在类已加载后,拦截并修改类的字节码(bytecode)。这是实现 Java 性能监控(APM)、全链路追踪、动态 AOP 以及热部署的基础设施。
核心组件
该体系主要由以下三个核心组件构成:
- Agent(代理) :
- 这是一个特殊的 JAR 包,包含代理入口类。
- 它依赖于 JVM 的
Instrumentation接口来操作类。 - 启动方式分为:启动时加载(
premain)、运行时加载(agentmain)以及内嵌可执行 JAR 加载(Launcher-Agent-Class)。
- ClassFileTransformer(类文件转换器) :
- 用户需实现此接口,定义具体的字节码修改逻辑。
- 它接收原始的字节码数组,处理完成后返回修改后的字节数组。
- Instrumentation 实例 :
- JVM 运行时提供的单例对象,用于注册转换器、触发类重定义/重转换以及获取 JVM 状态。
Instrumentation ClassFileTransformer
类加载时机与修改能力边界
根据 Agent 拦截类文件的时刻不同,其能力边界存在显著差异:
| 拦截时机 | 触发方式 | 能力范围 | 技术限制 |
|---|---|---|---|
| Class Load(类加载前) | 注册 ClassFileTransformer,JVM 加载类时自动触发 |
完全控制 • 修改方法体 • 增加/删除 字段 • 增加/删除 方法 • 修改继承/接口关系 | 无技术限制,仅需保证生成字节码符合 JVM 规范。 |
| Redefine/Retransform(类重定义) | 调用 retransformClasses() 或 redefineClasses() |
受限修改 • 仅允许修改方法体 • 严禁 修改类结构(Schema) | 1. 不允许增加/删除字段或方法。 2. 不允许修改父类、接口或方法签名。 3. 已存在的对象内存布局不可变。 |
Agent 启动方式详解
启动方式一:命令行启动 (-javaagent)
在 JVM 启动时通过 -javaagent 参数指定 Agent JAR。这是最常见的方式,常用于 AOP 框架(如 SkyWalking)或监控工具。
-
命令格式 :
bashjava -javaagent:<jarpath>[=<options>] -jar <application.jar> -
清单文件 (MANIFEST.MF) 配置 :
textPremain-Class: com.example.AgentMain Can-Redefine-Classes: true Can-Retransform-Classes: true -
入口方法 :
JVM 将在应用程序main方法执行前调用premain。javapublic static void premain(String agentArgs, Instrumentation inst); // 或重载版本(若上述不存在,JVM 尝试调用此版本) public static void premain(String agentArgs);
启动方式二:动态附加 (Attach API)
在 JVM 启动后,通过 Attach API 动态连接并加载 Agent。此方式常用于线上诊断工具(如 Arthas、Btrace)。
-
清单文件 (MANIFEST.MF) 配置 :
textAgent-Class: com.example.AgentMain Can-Retransform-Classes: true -
入口方法 :
javapublic static void agentmain(String agentArgs, Instrumentation inst); // 或重载版本 public static void agentmain(String agentArgs);
启动方式三:可执行 JAR 内嵌 (Launcher-Agent-Class)
这种方式允许将 Agent 逻辑直接内嵌在可执行 JAR 文件中,适用于应用程序自身需要进行自监控或自我增强的场景。
-
场景限制 :仅适用于通过
java -jar启动的应用程序。 -
清单文件 (MANIFEST.MF) 配置 :
需同时配置Main-Class和Launcher-Agent-Class。textMain-Class: com.example.MainApplication Launcher-Agent-Class: com.example.AgentMain Can-Retransform-Classes: true -
入口方法 :
注意 :尽管发生在应用启动前,JVM 调用的是agentmain方法签名。javapublic static void agentmain(String agentArgs, Instrumentation inst); -
特殊限制 :
- 无参数支持 :通过此方式启动时,
agentArgs永远为null。 - 严格故障模式 :如果 Agent 加载失败或
agentmain抛出异常,JVM 将直接终止,应用程序不会启动(与 Attach 方式的容错性不同)。
- 无参数支持 :通过此方式启动时,
类加载与模块可见性
了解类是如何加载的对开发代理很重要:
- 加载器 :从代理 JAR 文件加载的类由系统类加载器 加载,属于系统类加载器的未命名模块。
- 可见性 :代理类能看到的类包括:
- 启动层中模块导出的包中的类。
- 系统类加载器(通常是类路径)能定义的类。
- 代理安排由引导类加载器定义的类。
关于模块的特别说明
如果代理需要链接到不在启动层中的平台模块,你可能需要在启动时使用类似 --add-modules 的选项来确保这些模块被解析。
自定义类加载器
如果你配置了自定义系统类加载器(通过 java.system.class.loader),它必须 实现 appendToClassPathForInstrumentation 方法,以支持将代理 JAR 添加到搜索路径中。
清单属性速查表
以下是代理 JAR 文件中可用的关键清单属性:
| 属性名 | 用途 | 必需/可选 | 说明 |
|---|---|---|---|
| Premain-Class | 命令行启动时的代理类 | 命令行启动时必需 | 包含 premain 方法的类名。 |
| Agent-Class | 动态启动时的代理类 | 动态启动时必需 | 包含 agentmain 方法的类名。 |
| Launcher-Agent-Class | 可执行 JAR 中的代理类 | 可选 | 应用程序 main 方法前启动的代理。 |
| Boot-Class-Path | 引导类加载器搜索路径 | 可选 | 由空格分隔的路径列表。 |
| Can-Redefine-Classes | 是否支持重定义类 | 可选 | 布尔值,默认 false。 |
| Can-Retransform-Classes | 是否支持重转换类 | 可选 | 布尔值,默认 false。 |
| Can-Set-Native-Method-Prefix | 是否支持设置本地方法前缀 | 可选 | 布尔值,默认 false。 |
基于 ASM 的字节码操作实战
ASM 是一个底层、高性能的 Java 字节码操作和分析框架。相比于高阶框架,ASM 更接近字节码底层,提供了最大的灵活性和控制力。
ASM 核心概念
- ClassReader :负责解析字节码,将
.class文件内容解析为内存中的结构。它可以顺序遍历类的各个部分(版本、字段、方法等)。 - ClassVisitor :访问者模式的抽象类。当
ClassReader遍历到某个结构(如方法)时,会回调ClassVisitor中对应的方法(如visitMethod)。 - ClassWriter :继承自
ClassVisitor,专门用于将修改后的结构重新编译成二进制字节码。 - MethodVisitor:用于访问和修改方法内部的指令。
实战目标
功能 :拦截目标类 com.example.UserService 的所有方法,在方法入口处插入记录当前时间戳的代码,并在方法出口处计算并打印方法名和耗时(包含异常场景处理)。
项目依赖
xml
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.5</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-commons</artifactId>
<version>9.5</version>
</dependency>
代码实现
步骤 1:定义 Agent 入口类
为了适配多种启动方式,在同一个类中同时实现 premain 和 agentmain。
java
package com.example;
import java.lang.instrument.Instrumentation;
public class AgentMain {
// 方式一入口:命令行启动
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] Agent starting via premain...");
registerTransformer(inst, agentArgs);
}
// 方式二 & 方式三入口:动态附加 或 可执行JAR内嵌
public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] Agent starting via agentmain (Attach or Launcher)...");
registerTransformer(inst, agentArgs);
}
/**
* 注册转换器(抽离为独立方法,支持传入参数)
* @param inst Instrumentation 实例
* @param agentArgs 代理参数(可传入目标类名)
*/
private static void registerTransformer(Instrumentation inst, String agentArgs) {
// 注册自定义的 Transformer,支持传入目标类名(默认 com/example/UserService)
String targetClass = agentArgs == null ? "com/example/UserService" : agentArgs.replace('.', '/');
inst.addTransformer(new TimeCostTransformer(targetClass), true);
}
}
步骤 2:实现 ClassFileTransformer
java
package com.example;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
public class TimeCostTransformer implements ClassFileTransformer {
private final String targetClass;
public TimeCostTransformer(String targetClass) {
this.targetClass = targetClass;
}
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
// 仅拦截目标类(className 格式为 java/lang/Object)
if (className == null || !className.equals(targetClass)) {
return null;
}
System.out.println("[Agent] Transforming class: " + className);
try {
ASMTimeCostClassVisitor classVisitor = new ASMTimeCostClassVisitor();
return classVisitor.transform(classfileBuffer);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
步骤 3:实现 ASM 修改逻辑
java
package com.example;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.commons.AdviceAdapter;
public class ASMTimeCostClassVisitor {
public byte[] transform(byte[] srcClass) {
ClassReader cr = new ClassReader(srcClass);
// COMPUTE_MAXS:自动计算栈大小和局部变量表大小;COMPUTE_FRAMES:自动计算栈帧
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new TimeClassVisitor(cw);
// EXPAND_FRAMES:展开栈帧,兼容旧版字节码
cr.accept(cv, ClassReader.EXPAND_FRAMES);
return cw.toByteArray();
}
static class TimeClassVisitor extends ClassVisitor {
public TimeClassVisitor(ClassVisitor cv) {
super(Opcodes.ASM9, cv);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
// 跳过构造方法和静态初始化方法(可选,根据需求调整)
if ("<init>".equals(name) || "<clinit>".equals(name)) {
return mv;
}
// 创建增强的方法访问器
return new TimeAdviceAdapter(Opcodes.ASM9, mv, access, name, descriptor);
}
}
static class TimeAdviceAdapter extends AdviceAdapter {
private final String methodName;
private int startTimeVarIndex; // 存储开始时间的局部变量索引
protected TimeAdviceAdapter(int api, MethodVisitor mv, int access, String name, String descriptor) {
super(api, mv, access, name, descriptor);
this.methodName = name;
}
@Override
protected void onMethodEnter() {
// 1. 调用 System.currentTimeMillis()
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
// 2. 分配局部变量存储开始时间(自动计算索引,线程安全)
startTimeVarIndex = newLocal(Type.LONG_TYPE);
mv.visitVarInsn(LSTORE, startTimeVarIndex);
}
@Override
protected void onMethodExit(int opcode) {
// 1. 计算耗时:currentTimeMillis() - startTime
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
mv.visitVarInsn(LLOAD, startTimeVarIndex);
mv.visitInsn(LSUB); // 栈顶:耗时(long)
// 2. 拼接日志信息:"Method [methodName] cost: [耗时] ms"
// 压入方法名字符串
mv.visitLdcInsn("Method [" + methodName + "] cost: ");
// 交换栈顶:字符串在下,耗时在上
mv.visitInsn(SWAP);
// 调用 String.valueOf(long) 将耗时转为字符串
mv.visitMethodInsn(INVOKESTATIC, "java/lang/String", "valueOf", "(J)Ljava/lang/String;", false);
// 拼接字符串:"Method [name] cost: " + 耗时
mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "concat", "(Ljava/lang/String;)Ljava/lang/String;", false);
// 追加 " ms"
mv.visitLdcInsn(" ms");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "concat", "(Ljava/lang/String;)Ljava/lang/String;", false);
// 3. 处理异常场景:如果是异常退出,追加异常提示
if (opcode == ATHROW) {
mv.visitLdcInsn(" (threw exception)");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "concat", "(Ljava/lang/String;)Ljava/lang/String;", false);
}
// 4. 调用 System.out.println 打印日志
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitInsn(SWAP);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
// 注意:如果是异常退出,需要保留原异常抛出逻辑(ASM 会自动处理)
}
}
}
JDK Attach API
JDK Attach API 是 Java 提供的一套用于运行时 连接(附加)到目标 Java 虚拟机并进行操作的工具 API。它位于 com.sun.tools.attach 包下。这是实现 Java 诊断工具(如 Arthas、JProfiler、VisualVM)动态注入 Agent 的核心技术。
核心概念
Attach API 本质上是一个客户端-服务端架构:
- 服务端 :
- 任何正在运行的 Java 应用程序,只要在启动时包含 JDK 的
tools.jar(或在 JDK 9+ 中自动包含),就是一个潜在的 Attach 目标。 - 目标 JVM 内部监听特定的通信端口(基于本地文件系统或 socket)。
- 任何正在运行的 Java 应用程序,只要在启动时包含 JDK 的
- 客户端 :
- 独立运行的 Java 进程(诊断工具)。
- 通过
VirtualMachine类根据目标进程 ID(PID)建立连接。
核心类与接口
Attach API 主要包含以下关键类:
| 类名 | 描述 |
|---|---|
com.sun.tools.attach.VirtualMachine |
代表一个目标 Java 虚拟机。提供连接、加载 Agent、获取系统属性等核心方法。 |
com.sun.tools.attach.VirtualMachineDescriptor |
目标 JVM 的描述符,包含了 PID、标识符等信息,用于发现可用的 JVM。 |
com.sun.tools.attach.AttachNotSupportedException |
异常类,当目标 JVM 不支持 Attach 操作或版本不兼容时抛出。 |
com.sun.tools.attach.spi.AttachProvider |
服务提供者接口(SPI),用于扩展 Attach 机制的实现(通常开发者无需直接接触)。 |
典型操作流程(JDK 17 适配)
1. 列出当前可用的 Java 虚拟机
在使用 Attach API 前,通常需要知道目标应用程序的 PID。你可以通过 jps 命令查看,也可以通过代码列出本机所有 JVM:
java
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
import java.util.List;
public class ListVMs {
public static void main(String[] args) {
// JDK 17 中需确保模块访问权限,启动时添加 --add-modules jdk.attach
// 获取当前系统中所有可 Attach 的 JVM 描述符
List<VirtualMachineDescriptor> list = VirtualMachine.list();
System.out.println("Available Java Virtual Machines:");
for (VirtualMachineDescriptor vmd : list) {
System.out.printf("PID: %s\tDisplayName: %s\tProvider: %s%n",
vmd.id(), vmd.displayName(), vmd.provider().type());
}
}
}
2. 连接到目标虚拟机
获取 PID 后,使用 VirtualMachine.attach(String pid) 建立连接:
java
import com.sun.tools.attach.VirtualMachine;
public class AttachExample {
public static void main(String[] args) throws Exception {
String pid = "12345"; // 目标应用程序的进程 ID
// 尝试连接到目标 JVM(JDK 17 需保证同一用户权限)
VirtualMachine vm = VirtualMachine.attach(pid);
try {
System.out.println("Attached to VM: " + pid);
// ... 执行操作 ...
} finally {
// 操作完成后,必须断开连接以释放资源
vm.detach();
}
}
}
3. 动态加载 Agent (loadAgent)
这是 Attach API 最常用的功能,对应前文提到的 启动方式二。它允许你在不重启目标应用的情况下,向其注入代码。
java
public class LoadAgentExample {
public static void main(String[] args) throws Exception {
VirtualMachine vm = VirtualMachine.attach("54321");
try {
String agentPath = "/path/to/your-agent.jar"; // 目标 JVM 所在机器的绝对路径
String agentArgs = "com.example.UserService"; // 传入 Agent 参数(目标类名)
// 加载 Agent JAR 到目标 JVM
// 这会触发目标 JVM 中 Agent-Class 配置类的 agentmain 方法
vm.loadAgent(agentPath, agentArgs);
System.out.println("Agent loaded successfully.");
} finally {
vm.detach();
}
}
}
注意事项(JDK 17 强化):
-
JAR 路径 :
agentPath必须是目标 JVM 所在机器上的文件系统绝对路径。Docker 容器中需注意路径映射,可将 Agent JAR 挂载到容器内。 -
MANIFEST.MF :Agent JAR 必须包含
Agent-Class属性,且类名是全限定名。 -
模块权限 :JDK 17 中运行 Attach 客户端时,需添加启动参数:
bashjava --add-modules jdk.attach --add-opens jdk.attach/sun.tools.attach=ALL-UNNAMED LoadAgentExample
4. 获取系统属性与 Agent 列表
Attach API 还允许查询目标 JVM 的基本信息:
java
import com.sun.tools.attach.VirtualMachine;
import java.util.Properties;
import java.util.List;
public class InspectVM {
public static void main(String[] args) throws Exception {
VirtualMachine vm = VirtualMachine.attach("9999");
try {
// 获取目标 JVM 的系统属性(如 user.dir, java.version)
Properties props = vm.getSystemProperties();
System.out.println("Target Java Home: " + props.get("java.home"));
System.out.println("Target Java Version: " + props.get("java.version"));
// 获取已加载的 Agent 列表(本地路径)
List<String> agents = vm.getAgentProperties().entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.toList();
System.out.println("Loaded Agents: " + agents);
} finally {
vm.detach();
}
}
}
5. 热加载本地库 (loadAgentLibrary / loadAgentPath)
除了加载 Java Agent (jar),Attach API 也支持加载本地库(.so / .dll),这对于开发 JVMTI Agent(C++ 编写的高性能 Agent)非常重要。
java
// 加载本地库(通常用于 JVMTI 开发,JDK 17 需保证库文件权限)
// vm.loadAgentLibrary("instrument"); // 加载名为 libinstrument.so/dll 的系统库
// vm.loadAgentPath("/usr/lib/libcustom.so"); // 加载指定路径的本地库(绝对路径)
权限与安全
Attach API 具有较强的安全限制,以防止恶意程序随意操作生产环境的 JVM,JDK 17 中这些限制进一步强化:
- 同一用户 :Attach 客户端进程必须与目标 JVM 进程属于同一个操作系统用户 。Linux/Windows 下跨用户 Attach 会直接抛出
IOException。 - JDK 版本兼容性 :Client JDK 的版本应大于或等于 Target JDK 的版本。JDK 17 的客户端无法 Attach 到 JDK 21 的目标 JVM,反之亦然。
- Linux 限制 :
- Attach 依赖
/tmp目录下的.java_pid<pid>文件和/tmp/hsperfdata_<user>目录。JDK 17 中这些文件的权限被限制为仅当前用户可读写。 - 如果
/tmp挂载为noexec、nosuid或nodev,会导致 Attach 失败,需修改挂载参数。
- Attach 依赖
- 模块权限 :JDK 17 中,
com.sun.tools.attach属于jdk.attach模块,默认不对外暴露,需通过--add-modules和--add-opens显式开放。
高级 Instrumentation API
1. retransformClasses
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException
- 用途 :对已经加载的类进行重新转换。
- 前提 :注册 Transformer 时,
canRetransform参数必须为true。 - 流程 :
- JVM 获取该类当前的字节码。
- 按顺序调用所有支持 retransform 的 Transformer。
- 使用 Transformer 返回的新字节码更新类。
2. redefineClasses
void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException
- 用途:使用提供的字节码直接重新定义类。
- 区别 :此操作不经过已注册的 Transformer,是直接替换操作。
- 限制:与 retransform 相同,受类结构不变限制。
3. getObjectSize
long getObjectSize(Object objectToSize)
- 用途:返回指定对象在堆内存中占用的字节大小(浅大小)。
- 应用:内存分析,排查对象占用过大问题。
打包
xml
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
</plugins>
</build>