写点什么

由 JVM Attach API 看跨进程通信中的信号和 Unix 域套接字

用户头像
AI乔治
关注
发布于: 2021 年 04 月 19 日
由 JVM Attach API 看跨进程通信中的信号和 Unix 域套接字

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

你将获得下面这些相关的知识

  • 信号是什么

  • 如何写一个不能被“轻易”杀死的程序

  • Unix 域套接字的用法

  • 利用神器 strace 来查看黑盒应用的内部调用过程

  • JVM Attach API 的使用和过程详解

信号是什么

信号是某事件发生时对进程的通知机制,也被称为“软件中断”。信号可以看做是一种非常轻量级的进程间通信,信号由一个进程发送给另外一个进程,只不过是经由内核作为一个中间人发出,信号最初的目的是用来指定杀死进程的不同方式。每个信号都一个名字,以 “SIG” 开头,最熟知的信号应该是 SIGINT,我们在终端执行某个应用程序的过程中按下 Ctrl+C 一般会终止正在执行的进程,正是因为按下 Ctrl+C 会发送 SIGINT 信号给目标程序。每个信号都有一个唯一的数字标识,从 1 开始,下面是常见的信号量列表:


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


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 文件

./signal

复制代码

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

^Cinterrupt signal receive: 2     // Ctrl+C12term signal receive: 15           // kill pid345quit signal receive: 3             // kill -3 678[1]    46831 killed     ./signal  // kill -9 成功杀死进程
复制代码

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


"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.01 环回地址也可以通过网络实现同一主机的进程间通信,但 Unix 域套接字更可靠、效率更高。Docker 守护进程(Docker daemon)使用了 Unix 域套接字,容器中的进程可以通过它与 Docker 守护进程进行通信。MySQL 同样提供了域套接字进行访问的方式。

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

srwxrwxr-x. 1 ya ya        0 9月   8 00:26 tmp.sock
复制代码

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

与普通套接字的区别是什么?

  • Unix 域套接字更加高效,Unix 套接字不用进行协议处理,不需要计算序列号,也不需要发送确认报文,只需要复制数据即可

  • Unix 域套接字是可靠的,不会丢失报文,普通套接字是为不可靠通信设计的

  • Unix 域套接字的代码可以非常简单的修改转为普通套接字

域套接字代码示例下面是一个简单的 C 实现的域套接字的例子。注意:为了简化代码,文章中代码省略了错误的处理,完整的包含异常错误处理的代码见:github.com/arthur-zhan…

代码结构如下:

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

复制代码

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


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); }}

复制代码

客户端的代码如下:

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 client.c -o client

复制代码

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

./client

复制代码

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


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

复制代码

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

receive 6 bytes: hello

复制代码

JVM Attach API

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

    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 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 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,

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


100100100agentmain calledReloading: MyTestMain505050
复制代码

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

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 文件,这部分的伪代码如下:

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 这一部分处理的逻辑如下:


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 里面写了什么:


...5841 [pid 3869] socket(AF_LOCAL, SOCK_STREAM, 0) = 55842 [pid 3869] connect(5, {sa_family=AF_LOCAL, sun_path="/tmp/.java_pid12345"}, 110) = 05843 [pid 3869] write(5, "1", 1) = 15844 [pid 3869] write(5, "\0", 1) = 15845 [pid 3869] write(5, "load", 4) = 45846 [pid 3869] write(5, "\0", 1) = 15847 [pid 3869] write(5, "instrument", 10) = 105848 [pid 3869] write(5, "\0", 1) = 15849 [pid 3869] write(5, "false", 5) = 55850 [pid 3869] write(5, "\0", 1) = 15855 [pid 3869] write(5, "/home/ya/agent.jar"..., 18 <unfinished ...>

复制代码

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

\0load\0instrument\0false\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 进行写入和读取数据了。


看完三件事❤️

  • 如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:

  • 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。

  • 关注公众号 『 java 烂猪皮 』,不定期分享原创知识。

  • 同时可以期待后续文章 ing🚀


作者:挖坑的张师傅

出处:https://club.perfma.com/article/2345314


用户头像

AI乔治

关注

分享后端技术干货。公众号【 Java烂猪皮】 2019.06.30 加入

一名默默无闻的扫地僧!

评论

发布
暂无评论
由 JVM Attach API 看跨进程通信中的信号和 Unix 域套接字