Java:JavaAgent技术(java.instrument和java.attach)

目录

java.instrument和java.attach

本文档详细阐述 java.instrumentjava.attach模块的的机制、API 以及基于 ASM 的字节码操作实战。

核心架构与工作机制

Instrumentation 定义

java.lang.instrument 包提供的接口允许开发者在 Java 虚拟机(JVM)加载类文件(.class)时,或者在类已加载后,拦截并修改类的字节码(bytecode)。这是实现 Java 性能监控(APM)、全链路追踪、动态 AOP 以及热部署的基础设施。

核心组件

该体系主要由以下三个核心组件构成:

  1. Agent(代理)
    • 这是一个特殊的 JAR 包,包含代理入口类。
    • 它依赖于 JVM 的 Instrumentation 接口来操作类。
    • 启动方式分为:启动时加载(premain)、运行时加载(agentmain)以及内嵌可执行 JAR 加载(Launcher-Agent-Class)。
  2. ClassFileTransformer(类文件转换器)
    • 用户需实现此接口,定义具体的字节码修改逻辑。
    • 它接收原始的字节码数组,处理完成后返回修改后的字节数组。
  3. 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)或监控工具。

  • 命令格式

    bash 复制代码
    java -javaagent:<jarpath>[=<options>] -jar <application.jar>
  • 清单文件 (MANIFEST.MF) 配置

    text 复制代码
    Premain-Class: com.example.AgentMain
    Can-Redefine-Classes: true
    Can-Retransform-Classes: true
  • 入口方法
    JVM 将在应用程序 main 方法执行前调用 premain

    java 复制代码
    public static void premain(String agentArgs, Instrumentation inst);
    // 或重载版本(若上述不存在,JVM 尝试调用此版本)
    public static void premain(String agentArgs);

启动方式二:动态附加 (Attach API)

在 JVM 启动后,通过 Attach API 动态连接并加载 Agent。此方式常用于线上诊断工具(如 Arthas、Btrace)。

  • 清单文件 (MANIFEST.MF) 配置

    text 复制代码
    Agent-Class: com.example.AgentMain
    Can-Retransform-Classes: true
  • 入口方法

    java 复制代码
    public static void agentmain(String agentArgs, Instrumentation inst);
    // 或重载版本
    public static void agentmain(String agentArgs);

启动方式三:可执行 JAR 内嵌 (Launcher-Agent-Class)

这种方式允许将 Agent 逻辑直接内嵌在可执行 JAR 文件中,适用于应用程序自身需要进行自监控或自我增强的场景。

  • 场景限制 :仅适用于通过 java -jar 启动的应用程序。

  • 清单文件 (MANIFEST.MF) 配置
    需同时配置 Main-ClassLauncher-Agent-Class

    text 复制代码
    Main-Class: com.example.MainApplication
    Launcher-Agent-Class: com.example.AgentMain
    Can-Retransform-Classes: true
  • 入口方法
    注意 :尽管发生在应用启动前,JVM 调用的是 agentmain 方法签名。

    java 复制代码
    public static void agentmain(String agentArgs, Instrumentation inst);
  • 特殊限制

    1. 无参数支持 :通过此方式启动时,agentArgs 永远为 null
    2. 严格故障模式 :如果 Agent 加载失败或 agentmain 抛出异常,JVM 将直接终止,应用程序不会启动(与 Attach 方式的容错性不同)。

类加载与模块可见性

了解类是如何加载的对开发代理很重要:

  1. 加载器 :从代理 JAR 文件加载的类由系统类加载器 加载,属于系统类加载器的未命名模块
  2. 可见性 :代理类能看到的类包括:
    • 启动层中模块导出的包中的类。
    • 系统类加载器(通常是类路径)能定义的类。
    • 代理安排由引导类加载器定义的类。
关于模块的特别说明

如果代理需要链接到不在启动层中的平台模块,你可能需要在启动时使用类似 --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 入口类

为了适配多种启动方式,在同一个类中同时实现 premainagentmain

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 本质上是一个客户端-服务端架构:

  1. 服务端
    • 任何正在运行的 Java 应用程序,只要在启动时包含 JDK 的 tools.jar(或在 JDK 9+ 中自动包含),就是一个潜在的 Attach 目标。
    • 目标 JVM 内部监听特定的通信端口(基于本地文件系统或 socket)。
  2. 客户端
    • 独立运行的 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 客户端时,需添加启动参数:

    bash 复制代码
    java --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 中这些限制进一步强化:

  1. 同一用户 :Attach 客户端进程必须与目标 JVM 进程属于同一个操作系统用户 。Linux/Windows 下跨用户 Attach 会直接抛出 IOException
  2. JDK 版本兼容性 :Client JDK 的版本应大于或等于 Target JDK 的版本。JDK 17 的客户端无法 Attach 到 JDK 21 的目标 JVM,反之亦然。
  3. Linux 限制
    • Attach 依赖 /tmp 目录下的 .java_pid<pid> 文件和 /tmp/hsperfdata_<user> 目录。JDK 17 中这些文件的权限被限制为仅当前用户可读写。
    • 如果 /tmp 挂载为 noexecnosuidnodev,会导致 Attach 失败,需修改挂载参数。
  4. 模块权限 :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
  • 流程
    1. JVM 获取该类当前的字节码。
    2. 按顺序调用所有支持 retransform 的 Transformer。
    3. 使用 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>
相关推荐
摸鱼仙人~2 小时前
一文详解PyTorch DDP
人工智能·pytorch·python
天天向上10242 小时前
go 配置热更新
开发语言·后端·golang
甜鲸鱼2 小时前
【Spring AOP】操作日志的完整实现与原理剖析
java·spring boot·spring
狗头大军之江苏分军2 小时前
年底科技大考:2025 中国前端工程师的 AI 辅助工具实战盘点
java·前端·后端
晨晖22 小时前
顺序查找:c语言
c语言·开发语言·算法
wadesir2 小时前
C++非对称加密实战指南(从零开始掌握RSA加密算法)
开发语言·c++
一 乐2 小时前
酒店客房预订|基于springboot + vue酒店客房预订系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端
计算机毕设指导62 小时前
基于Spring Boot的防诈骗管理系统【源码文末联系】
java·spring boot·后端·spring·tomcat·maven·intellij-idea
a程序小傲3 小时前
饿了吗Java面试被问:Redis的持久化策略对比(RDBVS AOF)
java·redis·面试