在 JDK5 中,開發者只能 JVM 啓動時指定一個 javaagent 在 premain 中操做字節碼,Instrumentation 也僅限於 main 函數執行前,這樣的方式存在必定的侷限性。從 JDK6 開始引入了動態 Attach Agent 的方案,除了在命令行中指定 javaagent,如今能夠經過 Attach API 遠程加載。咱們經常使用的 jstack、arthas 等工具都是經過 Attach 機制實現的。java
這篇會結合跨進程通訊中的信號和 Unix 域套接字來看 JVM Attach API 的實現原理,git
信號是某事件發生時對進程的通知機制,也被稱爲「軟件中斷」。信號能夠看作是一種很是輕量級的進程間通訊,信號由一個進程發送給另一個進程,只不過是經由內核做爲一箇中間人發出,信號最初的目的是用來指定殺死進程的不一樣方式。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+C
,kill -3
,kill -15
都沒有辦法殺掉這個進程,只能用kill -9
socket
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)
複製代碼
使用 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;
}
}
複製代碼
步驟以下:
一、編寫 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
複製代碼
執行 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 文件是否存在。
源碼中 /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 機制,有機會在後面的文章中,咱們會繼續結合案例講講。
能夠掃描下面的二維碼關注個人公衆號: