在 JDK5 中,开发者只能 JVM 启动时指定一个 javaagent 在 premain 中操作字节码,Instrumentation 也仅限于 main 函数执行前,这样的方式存在一定的局限性。从 JDK6 开始引入了动态 Attach Agent 的方案,除了在命令行中指定 javaagent,现在可以通过 Attach API 远程加载。我们常用的 jstack、arthas 等工具都是通过 Attach 机制实现的。
这篇会结合跨进程通信中的信号和 Unix 域套接字来看 JVM Attach API 的实现原理,
信号是某事件发生时对进程的通知机制,也被称为“软件中断”。信号可以看做是一种非常轻量级的进程间通信,信号由一个进程发送给另外一个进程,只不过是经由内核作为一个中间人发出,信号最初的目的是用来指定杀死进程的不同方式。
每个信号都一个名字,以 "SIG" 开头,最熟知的信号应该是 SIGINT
,我们在终端执行某个应用程序的过程中按下 Ctrl+C
一般会终止正在执行的进程,正是因为按下 Ctrl+C
会发送 SIGINT
信号给目标程序。
每个信号都有一个唯一的数字标识,从 1 开始,下面是常见的信号量列表:
信号名 | 编号 | 描述 |
---|---|---|
SIGINT | 2 | 键盘中断信号(Ctrl+C) |
SIGQUIT | 3 | 键盘退出信号(Ctrl+/) |
SIGKILL | 9 | “必杀”(sure kill) 信号,应用程序无法忽略或者捕获,总会被杀死 |
SIGTERM | 15 | 终止信号 |
在 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) 复制代码
使用 TCP 和 UDP 进行 socket 通信是一种广为人知的 socket 使用方式,除了这种方式还有一种称为 Unix 域套接字的方式,可以实现同一主机上的进程间通信。虽然使用 127.0.01 环回地址也可以通过网络实现同一主机的进程间通信,但 Unix 域套接字更可靠、效率更高。Docker 守护进程(Docker daemon)使用了 Unix 域套接字,容器中的进程可以通过它与Docker 守护进程进行通信。MySQL 同样提供了域套接字进行访问的方式。
Unix 域套接字是一个文件,通过 ls 命令可以看到
ls -l srwxrwxr-x. 1 ya ya 0 9月 8 00:26 tmp.sock 复制代码
两个进程通过读写这个文件就实现了进程间的信息传递。文件的拥有者和权限决定了谁可以读写这个套接字。
下面是一个简单的 C 实现的域套接字的例子。注意:为了简化代码,文章中代码省略了错误的处理,完整的包含异常错误处理的代码见: github.com/arthur-zhan…
代码结构如下:
. ├── 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 复制代码
下面以一个实际的例子来演示动态 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 复制代码
执行 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 文件是否存在。
源码中 /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 相当于四次挥手断开连接。
这个过程如下图所示:
这篇文章介绍了同一主机进程间通信的两种方式,信号和 Unix 域套接字,JVM 的 Attach 机制充分利用了信号和域套接字提供的功能,先创建一个临时文件,表示这是一个 attach 操作,然后发送SIGQUIT信号给目标进程,目标进程发现存在 attach 临时文件,则创建监听 Unix 域套接字文件,Attach 发起端就可以通过 socket 的 API 进行写入和读取数据了。
这篇文章还没有解读的是 JVMTI 机制,有机会在后面的文章中,我们会继续结合案例讲讲。
可以扫描下面的二维码关注我的公众号: