由 JVM Attach API 看跨進程通訊中的信號和 Unix 域套接字

在 JDK5 中,開發者只能 JVM 啓動時指定一個 javaagent 在 premain 中操做字節碼,Instrumentation 也僅限於 main 函數執行前,這樣的方式存在必定的侷限性。從 JDK6 開始引入了動態 Attach Agent 的方案,除了在命令行中指定 javaagent,如今能夠經過 Attach API 遠程加載。咱們經常使用的 jstack、arthas 等工具都是經過 Attach 機制實現的。java

這篇會結合跨進程通訊中的信號和 Unix 域套接字來看 JVM Attach API 的實現原理,git

你將得到下面這些相關的知識

  • 信號是什麼
  • 如何寫一個不能被「輕易」殺死的程序
  • Unix 域套接字的用法
  • 利用神器 strace 來查看黑盒應用的內部調用過程
  • JVM Attach API 的使用和過程詳解

信號是什麼

信號是某事件發生時對進程的通知機制,也被稱爲「軟件中斷」。信號能夠看作是一種很是輕量級的進程間通訊,信號由一個進程發送給另一個進程,只不過是經由內核做爲一箇中間人發出,信號最初的目的是用來指定殺死進程的不一樣方式。github

每一個信號都一個名字,以 "SIG" 開頭,最熟知的信號應該是 SIGINT,咱們在終端執行某個應用程序的過程當中按下 Ctrl+C 通常會終止正在執行的進程,正是由於按下 Ctrl+C 會發送 SIGINT 信號給目標程序。bash

每一個信號都有一個惟一的數字標識,從 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 信號dom

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

gcc signal.c -o signal
./signal
複製代碼

這種狀況下,在終端中Ctrl+Ckill -3kill -15都沒有辦法殺掉這個進程,只能用kill -9socket

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,在終端就能夠看到打印了全部的線程的調用棧信息:ide

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.01 環回地址也能夠經過網絡實現同一主機的進程間通訊,但 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 實現的域套接字的例子。注意:爲了簡化代碼,文章中代碼省略了錯誤的處理,完整的包含異常錯誤處理的代碼見: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
複製代碼

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;
    }
}
複製代碼

步驟以下:

一、編寫 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;
            }
        }
    }
}
複製代碼

二、由於是跨進程通訊,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 的原理分析

執行 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,這部分的詳細的過程以下:

一、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());
}
複製代碼

二、Attach 端檢查若是沒有 .java_pid12345 文件,建立完 .attach_pid12345 文件之後發送 SIGQUIT 信號給目標 JVM。而後每隔 200ms 檢查一次 socket 文件是否已經生成,5s 之後尚未生成則退出,若是有生成則進行 socket 通訊

三、對於目標 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 至關於四次揮手斷開鏈接。

這個過程以下圖所示:

Attach API 過程

小結

這篇文章介紹了同一主機進程間通訊的兩種方式,信號和 Unix 域套接字,JVM 的 Attach 機制充分利用了信號和域套接字提供的功能,先建立一個臨時文件,表示這是一個 attach 操做,而後發送SIGQUIT信號給目標進程,目標進程發現存在 attach 臨時文件,則建立監聽 Unix 域套接字文件,Attach 發起端就能夠經過 socket 的 API 進行寫入和讀取數據了。

後記

這篇文章尚未解讀的是 JVMTI 機制,有機會在後面的文章中,咱們會繼續結合案例講講。

能夠掃描下面的二維碼關注個人公衆號:

相關文章
相關標籤/搜索