关于后端:字节码调教的入口-JVM-的寄生插件-javaagent-那些事

8次阅读

共计 12584 个字符,预计需要花费 32 分钟才能阅读完成。

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 形式的加载时序如下:

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 开始,上面是常见的信号量列表:

在 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 相当于四次挥手断开连接。

这个过程如下图所示:

小结

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

  • 第一,javaagent 是一个应用 instrumentation 的 API 用来改写类文件的 jar 包,能够看作是 JVM 的一个寄生插件。
  • 第二,javaagent 有两个重要的入口类:Premain-Class 和 Agent-Class,别离对应入口函数 premain 和 agentmain,其中 agentmain 能够采纳近程 attach API 的形式近程挂载另一个 JVM 过程。

本文由 mdnice 多平台公布

正文完
 0