字节码调教的入口 —— JVM 的寄生插件 javaagent 那些事

Java Instrumentation 包

Java Instrumentation 概述

Java Instrumentation 这个技术看起来非常神秘,很少有书会详细介绍。但是有很多工具是基于 Instrumentation 来实现的:

  • APM 产品: pinpoint、skywalking、newrelic、听云的 APM 产品等都基于 Instrumentation 实现
  • 热部署工具:Intellij idea 的 HotSwap、Jrebel 等
  • Java 诊断工具:Arthas、Btrace 等

由于对字节码修改功能的巨大需求,JDK 从 JDK5 版本开始引入了java.lang.instrument 包。它可以通过 addTransformer 方法设置一个 ClassFileTransformer,可以在这个 ClassFileTransformer 实现类的转换。

JDK 1.5 支持静态 Instrumentation,基本的思路是在 JVM 启动的时候添加一个代理(javaagent),每个代理是一个 jar 包,其 MANIFEST.MF 文件里指定了代理类,这个代理类包含一个 premain 方法。JVM 在类加载时候会先执行代理类的 premain 方法,再执行 Java 程序本身的 main 方法,这就是 premain 名字的来源。在 premain 方法中可以对加载前的 class 文件进行修改。这种机制可以认为是虚拟机级别的 AOP,无需对原有应用做任何修改,就可以实现类的动态修改和增强。

从 JDK 1.6 开始支持更加强大的动态 Instrument,在JVM 启动后通过 Attach API 远程加载,后面会详细介绍。

本文会分为 javaagent 和动态 Attach 两个部分来介绍

Java Instrumentation 核心方法

Instrumentation 是 java.lang.instrument 包下的一个接口,这个接口的方法提供了注册类文件转换器、获取所有已加载的类等功能,允许我们在对已加载和未加载的类进行修改,实现 AOP、性能监控等功能。

常用的方法如下:

/**
 * 为 Instrumentation 注册一个类文件转换器,可以修改读取类文件字节码
 */
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

/**
 * 对JVM已经加载的类重新触发类加载
 */
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

/**
 * 获取当前 JVM 加载的所有类对象
 */
Class[] getAllLoadedClasses()

它的 addTransformer 给 Instrumentation 注册一个 transformer,transformer 是 ClassFileTransformer 接口的实例,这个接口就只有一个 transform 方法,调用 addTransformer 设置 transformer 以后,后续JVM 加载所有类之前都会被这个 transform 方法拦截,这个方法接收原类文件的字节数组,返回转换过的字节数组,在这个方法中可以做任意的类文件改写。

下面是一个空的 ClassFileTransformer 的实现:

public class MyClassTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classBytes) throws IllegalClassFormatException {
        // 在这里读取、转换类文件
        return classBytes;
    }
}

接下来我们来介绍本文的主角之一 javaagent。

Javaagent 介绍

Javaagent 是一个特殊的 jar 包,它并不能单独启动的,而必须依附于一个 JVM 进程,可以看作是 JVM 的一个寄生插件,使用 Instrumentation 的 API 用来读取和改写当前 JVM 的类文件。

Agent 的两种使用方式

它有两种使用方式:

  • 在 JVM 启动的时候加载,通过 javaagent 启动参数 java -javaagent:myagent.jar MyMain,这种方式在程序 main 方法执行之前执行 agent 中的 premain 方法
  • 在 JVM 启动后 Attach,通过 Attach API 进行加载,这种方式会在 agent 加载以后执行 agentmain 方法 premain 和 agentmain 方法签名如下:
public static void premain(String agentArgument, Instrumentation instrumentation) throws Exception

public static void agentmain(String agentArgument, Instrumentation instrumentation) throws Exception

这两个方法都有两个参数

  • 第一个 agentArgument 是 agent 的启动参数,可以在 JVM 启动命令行中设置,比如java -javaagent:<jarfile>=appId:agent-demo,agentType:singleJar test.jar的情况下 agentArgument 的值为 "appId:agent-demo,agentType:singleJar"。

  • 第二个 instrumentation 是 java.lang.instrument.Instrumentation 的实例,可以通过 addTransformer 方法设置一个 ClassFileTransformer。

第一种 premain 方式的加载时序如下:

alt

Agent 打包

为了能够以 javaagent 的方式运行 premain 和 agentmain 方法,我们需要将其打包成 jar 包,并在其中的 MANIFEST.MF 配置文件中,指定 Premain-class 等信息,一个典型的生成好的 MANIFEST.MF 内容如下

为了能够以 javaagent 的方式运行 premain 和 agentmain 方法,我们需要将其打包成 jar 包,并在其中的 MANIFEST.MF 配置文件中,指定 Premain-class 等信息,一个典型的生成好的 MANIFEST.MF 内容如下

下面是一个可以帮助生成上面 MANIFEST.MF 的 maven 配置

<build>
  <finalName>my-javaagent</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
        <archive>
          <manifestEntries>
            <Agent-Class>me.geek01.javaagent.AgentMain</Agent-Class>
            <Premain-Class>me.geek01.javaagent.AgentMain</Premain-Class>
            <Can-Redefine-Classes>true</Can-Redefine-Classes>
            <Can-Retransform-Classes>true</Can-Retransform-Classes>
          </manifestEntries>
        </archive>
      </configuration>
    </plugin>
  </plugins>
</build>

Agent 使用方式一:JVM 启动参数

下面使用 javaagent 实现简单的函数调用栈跟踪,以下面的代码为例:

public class MyTest {
    public static void main(String[] args) {
        new MyTest().foo();
    }
    public void foo() {
        bar1();
        bar2();
    }

    public void bar1() {
    }

    public void bar2() {
    }
}

通过 javaagent 启动参数的方式在每个函数进入和结束时都打印一行日志,实现调用过程的追踪的效果。

核心的方法 instrument 的逻辑如下:

public static class MyMethodVisitor extends AdviceAdapter {

    @Override
    protected void onMethodEnter() {
        // 在方法开始处插入 <<<enter xxx
        mv.visitFieldInsn(GETSTATIC, "java/lang/System""out""Ljava/io/PrintStream;");
        mv.visitLdcInsn("<<<enter " + this.getName());
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream""println""(Ljava/lang/String;)V"false);
        super.onMethodEnter();
    }

    @Override
    protected void onMethodExit(int opcode) {
        super.onMethodExit(opcode);
        // 在方法结束处插入 <<<exit xxx
        mv.visitFieldInsn(GETSTATIC, "java/lang/System""out""Ljava/io/PrintStream;");
        mv.visitLdcInsn(">>>exit " + this.getName());
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream""println""(Ljava/lang/String;)V"false);
    }
}

把 agent 打包生成 my-trace-agent.jar,添加 agent 启动 MyTest 类

java -javaagent:/path_to/my-trace-agent.jar MyTest

可以看到输出结果如下:

<<<enter main
<<<enter foo
<<<enter bar1
>>>exit bar1
<<<enter bar2
>>>exit bar2
>>>exit foo
>>>exit main

通过上面的方式,我们在不修改 MyTest 类源码的情况下实现了调用链跟踪的效果。更加健壮和完善的调用链跟踪实现会在后面的 APM 章节详细介绍。

Agent 使用方式二:Attach API 使用

在 JDK5 中,开发者只能 JVM 启动时指定一个 javaagent 在 premain 中操作字节码,Instrumentation 也仅限于 main 函数执行前,这样的方式存在一定的局限性。从 JDK6 开始引入了动态 Attach Agent 的方案,除了在命令行中指定 javaagent,现在可以通过 Attach API 远程加载。我们常用的 jstack、arthas 等工具都是通过 Attach 机制实现的。

接下来我们会结合跨进程通信中的信号和 Unix 域套接字来看 JVM Attach API 的实现原理

JVM Attach API 基本使用

下面以一个实际的例子来演示动态 Attach API 的使用,代码中有一个 main 方法,每隔 3s 输出 foo 方法的返回值 100,接下来动态 Attach 上 MyTestMain 进程,修改 foo 的字节码,让 foo 方法返回 50。

public class MyTestMain {
    public static void main(String[] args) throws InterruptedException {
        while (true) {
            System.out.println(foo());
            TimeUnit.SECONDS.sleep(3);
        }
    }

    public static int foo() {
        return 100; // 修改后 return 50;
    }
}

步骤如下:

1、编写 Attach Agent,对 foo 方法做注入,完整的代码见:github.com/arthur-zhan…

动态 Attach 的 agent 与通过 JVM 启动 javaagent 参数指定的 agent jar 包的方式有所不同,动态 Attach 的 agent 会执行 agentmain 方法,而不是 premain 方法。

public class AgentMain {
    public static void agentmain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException {
        System.out.println("agentmain called");
        inst.addTransformer(new MyClassFileTransformer(), true);
        Class classes[] = inst.getAllLoadedClasses();
        for (int i = 0; i < classes.length; i++) {
            if (classes[i].getName().equals("MyTestMain")) {
                System.out.println("Reloading: " + classes[i].getName());
                inst.retransformClasses(classes[i]);
                break;
            }
        }
    }
}

2、因为是跨进程通信,Attach 的发起端是一个独立的 java 程序,这个 java 程序会调用 VirtualMachine.attach 方法开始和目标 JVM 进行跨进程通信。

public class MyAttachMain {
    public static void main(String[] args) throws Exception {
        VirtualMachine vm = VirtualMachine.attach(args[0]);
        try {
            vm.loadAgent("/path/to/agent.jar");
        } finally {
            vm.detach();
        }
    }
}

使用 jps 查询到 MyTestMain 的进程 id,

java -cp /path/to/your/tools.jar:. MyAttachMain pid

可以看到 MyTestMain 的输出的 foo 方法已经返回了 50。

java -cp . MyTestMain

100
100
100
agentmain called
Reloading: MyTestMain
50
50
50

JVM Attach API 的底层原理

JVM Attach API 的实现主要基于信号和 Unix 域套接字,接下来详细介绍这两部分的内容。

信号是什么

信号是某事件发生时对进程的通知机制,也被称为“软件中断”。信号可以看做是一种非常轻量级的进程间通信,信号由一个进程发送给另外一个进程,只不过是经由内核作为一个中间人发出,信号最初的目的是用来指定杀死进程的不同方式。

每个信号都有一个名字,以 "SIG" 开头,最熟知的信号应该是 SIGINT,我们在终端执行某个应用程序的过程中按下 Ctrl+C 一般会终止正在执行的进程,正是因为按下 Ctrl+C 会发送 SIGINT 信号给目标程序。

每个信号都有一个唯一的数字标识,从 1 开始,下面是常见的信号量列表:

alt

在 Linux 中,一个前台进程可以使用 Ctrl+C 进行终止,对于后台进程需要使用 kill 加进程号的方式来终止,kill 命令是通过发送信号给目标进程来实现终止进程的功能。默认情况下,kill 命令发送的是编号为 15 的 SIGTERM 信号,这个信号可以被进程捕获,选择忽略或正常退出。目标进程如果没有自定义处理这个信号,就会被终止。对于那些忽略 SIGTERM 信号的进程,则需要编号为 9 的 SIGKILL 信号强行杀死进程,SIGKILL 信号不能被忽略也不能被捕获和自定义处理。

下面写了一段 C 代码,自定义处理了 SIGQUIT、SIGINT、SIGTERM 信号

signal.c

static void signal_handler(int signal_no) {
    if (signal_no == SIGQUIT) {
        printf("quit signal receive: %d\n", signal_no);
    } else if (signal_no == SIGTERM) {
        printf("term signal receive: %d\n", signal_no);
    } else if (signal_no == SIGINT) {
        printf("interrupt signal receive: %d\n", signal_no);
    }
}

int main() {
    signal(SIGQUIT, signal_handler);
    signal(SIGINT, signal_handler);
    signal(SIGTERM, signal_handler);
    for (int i = 0;; i++) {
        printf("%d\n", i);
        sleep(3);
    }
}

编译运行上面的 signal.c 文件

gcc signal.c -o signal
./signal

这种情况下,在终端中Ctrl+C,kill -3,kill -15都没有办法杀掉这个进程,只能用kill -9

0
^Cinterrupt signal receive: 2     // Ctrl+C
1
2
term signal receive: 15           // kill pid
3
4
5
quit signal receive: 3             // kill -3 
6
7
8
[1]    46831 killed     ./signal  // kill -9 成功杀死进程

JVM 对 SIGQUIT 的默认行为是打印所有运行线程的堆栈信息,在类 Unix 系统中,可以通过使用命令 kill -3 pid 来发送 SIGQUIT 信号。运行上面的 MyTestMain,使用 jps 找到整个 JVM 的进程 id,执行 kill -3 pid,在终端就可以看到打印了所有的线程的调用栈信息:

Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.51-b03 mixed mode):

"Service Thread" #8 daemon prio=9 os_prio=31 tid=0x00007fe060821000 nid=0x4403 runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE
...
"Signal Dispatcher" #4 daemon prio=9 os_prio=31 tid=0x00007fe061008800 nid=0x3403 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE
"main" #1 prio=5 os_prio=31 tid=0x00007fe060003800 nid=0x1003 waiting on condition [0x000070000d203000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
 at java.lang.Thread.sleep(Native Method)
 at java.lang.Thread.sleep(Thread.java:340)
 at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
 at MyTestMain.main(MyTestMain.java:10)

Unix 域套接字(Unix Domain Socket)

使用 TCP 和 UDP 进行 socket 通信是一种广为人知的 socket 使用方式,除了这种方式还有一种称为 Unix 域套接字的方式,可以实现同一主机上的进程间通信。虽然使用 127.0.0.1 环回地址也可以通过网络实现同一主机的进程间通信,但 Unix 域套接字更可靠、效率更高。Docker 守护进程(Docker daemon)使用了 Unix 域套接字,容器中的进程可以通过它与Docker 守护进程进行通信。MySQL 同样提供了域套接字进行访问的方式。

Unix 域套接字是什么?

Unix 域套接字是一个文件,通过 ls 命令可以看到

ls -l
srwxrwxr-x. 1 ya ya        0 9月   8 00:26 tmp.sock

两个进程通过读写这个文件就实现了进程间的信息传递。文件的拥有者和权限决定了谁可以读写这个套接字。

与普通套接字的区别是什么?
  • Unix 域套接字更加高效,Unix 套接字不用进行协议处理,不需要计算序列号,也不需要发送确认报文,只需要复制数据即可
  • Unix 域套接字是可靠的,不会丢失报文,普通套接字是为不可靠通信设计的
  • Unix 域套接字的代码可以非常简单的修改转为普通套接字

下面是一个简单的 C 实现的域套接字的例子

.
├── client.c
└── server.c

server.c 充当 Unix 域套接字服务器,启动后会在当前目录生成一个名为 tmp.sock 的 Unix 域套接字文件,它读取客户端写入的内容并输出。

server.c

int main() {
    int fd = socket(AF_UNIX, SOCK_STREAM, 0);
    struct sockaddr_un addr;
    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strcpy(addr.sun_path, "tmp.sock");
    int ret = bind(fd, (struct sockaddr *) &addr, sizeof(addr));
    listen(fd, 5)
    
    int accept_fd;
    char buf[100];
    while (1) {
        accept_fd = accept(fd, NULL, NULL)) == -1);
        while ((ret = read(accept_fd, buf, sizeof(buf))) > 0) {
            // 输出客户端传过来的数据
            printf("receive %u bytes: %s\n", ret, buf);
        }
}

客户端的代码如下:

client.c
int main() {
    int fd = socket(AF_UNIX, SOCK_STREAM, 0);
    struct sockaddr_un addr;
    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strcpy(addr.sun_path, "tmp.sock");

    connect(fd, (struct sockaddr *) &addr, sizeof(addr)) == -1
    
    int rc;
    char buf[100];
    // 读取终端标准输入的内容,写入到 Unix 域套接字文件中
    while ((rc = read(STDIN_FILENO, buf, sizeof(buf))) > 0) {
        write(fd, buf, rc);
    }
}

在命令行中进行编译和执行

gcc server.c -o server
gcc client.c -o client

启动两个终端,一个启动 server 端,一个启动 client 端

./server
./client

可以看到当前目录生成了一个 "tmp.sock" 文件

ls -l

srwxrwxr-x. 1 ya ya    0 9月   8 00:08 tmp.sock

在 client 输入 hello,在 server 的终端就可以看到

./server
receive 6 bytes: hello

JVM Attach 过程分析

执行 MyAttachMain,当指定一个不存在的 JVM 进程时,会出现如下的错误:

java -cp /path/to/your/tools.jar:. MyAttachMain 1234
Exception in thread "main" java.io.IOException: No such process
 at sun.tools.attach.LinuxVirtualMachine.sendQuitTo(Native Method)
 at sun.tools.attach.LinuxVirtualMachine.<init>(LinuxVirtualMachine.java:91)
 at sun.tools.attach.LinuxAttachProvider.attachVirtualMachine(LinuxAttachProvider.java:63)
 at com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:208)
 at MyAttachMain.main(MyAttachMain.java:8)

可以看到 VirtualMachine.attach 最终调用了 sendQuitTo 方法,这是一个 native 的方法,底层就是发送了 SIGQUIT 信号给目标 JVM 进程。

前面信号部分我们介绍过,JVM 对 SIGQUIT 的默认行为是 dump 当前的线程堆栈,那为什么调用 VirtualMachine.attach 没有输出调用栈堆栈呢?

对于 Attach 的发起方,假设目标进程为 12345,这部分的详细的过程如下:

1、Attach 端检查临时文件目录是否有 .java_pid12345 文件

这个文件是一个 UNIX 域套接字文件,由 Attach 成功以后的目标 JVM 进程生成。如果这个文件存在,说明正在 Attach 中,可以用这个 socket 进行下一步的通信。如果这个文件不存在则创建一个 .attach_pid12345 文件,这部分的伪代码如下:

String tmpdir = "/tmp";
File socketFile = new File(tmpdir,  ".java_pid" + pid);
if (socketFile.exists()) {
    File attachFile = new File(tmpdir, ".attach_pid" + pid);
    createAttachFile(attachFile.getPath());
}

2、Attach 端检查如果没有 .java_pid12345 文件,创建完 .attach_pid12345 文件以后发送 SIGQUIT 信号给目标 JVM。然后每隔 200ms 检查一次 socket 文件是否已经生成,5s 以后还没有生成则退出,如果有生成则进行 socket 通信

3、对于目标 JVM 进程而言,它的 Signal Dispatcher 线程收到 SIGQUIT 信号以后,会检查 .attach_pid12345 文件是否存在。

  • 目标 JVM 如果发现 .attach_pid12345 不存在,则认为这不是一个 attach 操作,执行默认行为,输出当前所有线程的堆栈
  • 目标 JVM 如果发现 .attach_pid12345 存在,则认为这是一个 attach 操作,会启动 Attach Listener 线程,负责处理 Attach 请求,同时创建名为 .java_pid12345 的 socket 文件,监听 socket。 源码中 /hotspot/src/share/vm/runtime/os.cpp 这一部分处理的逻辑如下:
#define SIGBREAK SIGQUIT

static void signal_thread_entry(JavaThread* thread, TRAPS) {
  while (true) {
    int sig;
    {
    switch (sig) {
      case SIGBREAK: { 
        // Check if the signal is a trigger to start the Attach Listener - in that
        // case don't print stack traces.
        if (!DisableAttachMechanism && AttachListener::is_init_trigger()) {
          continue;
        }
        ...
        // Print stack traces
    }
}

AttachListener 的 is_init_trigger 在 .attach_pid12345 文件存在的情况下会新建 .java_pid12345 套接字文件,同时监听此套接字,准备 Attach 端发送数据。

那 Attach 端和目标进程用 socket 传递了什么信息呢?可以通过 strace 的方式看到 Attach 端究竟往 socket 里面写了什么:

sudo strace -f java -cp /usr/local/jdk/lib/tools.jar:. MyAttachMain 12345  2> strace.out

...
5841 [pid  3869] socket(AF_LOCAL, SOCK_STREAM, 0) = 5
5842 [pid  3869] connect(5, {sa_family=AF_LOCAL, sun_path="/tmp/.java_pid12345"}, 110)      = 0
5843 [pid  3869] write(5, "1", 1)            = 1
5844 [pid  3869] write(5, "\0", 1)           = 1
5845 [pid  3869] write(5, "load", 4)         = 4
5846 [pid  3869] write(5, "\0", 1)           = 1
5847 [pid  3869] write(5, "instrument", 10)  = 10
5848 [pid  3869] write(5, "\0", 1)           = 1
5849 [pid  3869] write(5, "false", 5)        = 5
5850 [pid  3869] write(5, "\0", 1)           = 1
5855 [pid  3869] write(5, "/home/ya/agent.jar"..., 18 <unfinished ...>

可以看到往 socket 写入的内容如下:

1
\0
load
\0
instrument
\0
false
\0
/home/ya/agent.jar
\0

数据之间用 \0 字符分隔,第一行的 1 表示协议版本,接下来是发送指令 "load instrument false /home/ya/agent.jar" 给目标 JVM,目标 JVM 收到这些数据以后就可以加载相应的 agent jar 包进行字节码的改写。

如果从 socket 的角度来看,VirtualMachine.attach 方法相当于三次握手建连,VirtualMachine.loadAgent 则是握手成功之后发送数据,VirtualMachine.detach 相当于四次挥手断开连接。

这个过程如下图所示:

alt

小结

本文讲解了 javaagent,一起来回顾一下要点:

  • 第一,javaagent 是一个使用 instrumentation 的 API 用来改写类文件的 jar 包,可以看作是 JVM 的一个寄生插件。
  • 第二,javaagent 有两个重要的入口类:Premain-Class 和 Agent-Class,分别对应入口函数 premain 和 agentmain,其中 agentmain 可以采用远程 attach API 的方式远程挂载另一个 JVM 进程。

本文由 mdnice 多平台发布

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值