前言
當Java程式執行時出現CPU負載高、記憶體佔用大等異常情況時,通常需要使用JDK自帶的工具jstack、jmap檢視JVM的執行時資料,並進行分析。
什麼是Java Attach
那麼JVM自帶的這些工具是如何獲取到JVM的相關資訊呢?
JVM提供了 Java Attach
功能,能夠讓客戶端與目標JVM進行通訊從而獲取JVM執行時的資料,甚至可以通過Java Attach
載入自定義的代理工具,實現AOP、執行時class熱更新等功能。
如果我們通過jstack列印執行緒棧的時候會發現有這麼2個執行緒:Signal Dispatcher
和Attach Listener
。
"Signal Dispatcher" #4 daemon prio=9 os_prio=2 cpu=0.00ms elapsed=917.19s tid=0x00000164ff377000 nid=0x4ba0 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
"Attach Listener" #5 daemon prio=5 os_prio=2 cpu=0.00ms elapsed=917.19s tid=0x000001648f4d1800 nid=0x1fc0 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
Signal Dispatcher
用於處理作業系統訊號(軟中斷訊號),Attach Listener
執行緒用於JVM程式間的通訊。
作業系統支援的訊號可以通過
kill -l
檢視。比如我們平時殺程式用kill -9
可以看到9對應的訊號就是SIGKILL
。
其他的訊號並不會殺掉JVM程式,而是通知到程式, 具體程式如何處理根據Signal Dispatcher
執行緒處理邏輯決定。
root@DESKTOP-45K54QO:~# kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
執行緒初始化
在虛擬機器初始完成後,Signal Dispatcher
和Attach Listener
執行緒會根據配置進行必要的初始化。
jint Threads::create_vm(JavaVMInitArgs* args, bool* canTryAgain) {
...
//記錄虛擬機器初始化完成時間
Management::record_vm_init_completed();
...
// 初始化Signal Dispatcher
os::signal_init();
// 當設定了StartAttachListener或者無法懶載入時啟動Attach Listener
if (!DisableAttachMechanism) {
AttachListener::vm_start();
if (StartAttachListener || AttachListener::init_at_startup()) {
AttachListener::init();
}
}
...
// 通知所有的 JVMTI agents 虛擬機器初始化完成
JvmtiExport::post_vm_initialized();
...
}
相關JVM引數
JVM相關引數如下,預設都是false
JVM引數 | 預設值 |
---|---|
DisableAttachMechanism | false |
StartAttachListener | false |
ReduceSignalUsage | false |
除了這三個引數以外,我們可以看到AttachListener::init_at_startup()
也是用於控制Attach Listener
是否初始化。
JDK設計的時候根據不同的作業系統設計了不同的初始化方式。
- linux支援作業系統訊號通知
- 預設情況下,
ReduceSignalUsage
配置的是false
,初始化完Signal Dispatcher
執行緒就不需要立即初始化Attach Listener
執行緒。而是在收到作業系統通知的時候,去觸發Attach Listener
執行緒初始化。 - 如果
ReduceSignalUsage
配置的是true,那JVM啟動時就不會啟動Signal Dispatcher
執行緒。也就無法接收並處理作業系統的訊號通知。這時就需要在JVM啟動的時候需要立即初始化Attach Listener
執行緒。
- 預設情況下,
bool AttachListener::init_at_startup() {
if (ReduceSignalUsage) {
return true;
} else {
return false;
}
}
- windows雖然也有作業系統的訊號通知,不過訊號通知型別並沒有linux那麼多,JDK也並沒有實現windows下的作業系統訊號處理邏輯,因此windows下在JVM啟動時就需要直接初始化
Attach Listener
執行緒。
// always startup on Windows NT/2000/XP
bool AttachListener::init_at_startup() {
return os::win32::is_nt();
}
Signal Dispatcher 執行緒初始化
根據配置ReduceSignalUsage
配置決定是否啟動Signal Dispatcher
執行緒。
void os::signal_init() {
if (!ReduceSignalUsage) {
...
JavaThread* signal_thread = new JavaThread(&signal_thread_entry);
}
}
Signal Dispatcher
執行緒啟動後會通過os::signal_wait()
等待作業系統訊號量。當收到作業系統訊號量,且訊號量為SIGBREAK
時會觸發初始化Attach Listener
。
Attach Listener
執行緒只會初始化一次,如果已初始化過,不會重複初始化。
JavaThread* signal_thread = new JavaThread(&signal_thread_entry);
static void signal_thread_entry(JavaThread* thread, TRAPS) {
os::set_priority(thread, NearMaxPriority);
while (true) {
int sig;
{
sig = os::signal_wait();
}
...
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;
}
...
}
需要補充說明的是SIGBREAK
實際就是SIGQUIT
訊號。
#define SIGBREAK SIGQUIT
Attach Listener 執行緒初始化
...
if (!DisableAttachMechanism) {
AttachListener::vm_start();
if (StartAttachListener || AttachListener::init_at_startup()) {
AttachListener::init();
}
}
根據DisableAttachMechanism
配置決定是否啟動Attach Listener
執行緒;
void AttachListener::vm_start() {
char fn[UNIX_PATH_MAX];
struct stat64 st;
int ret;
int n = snprintf(fn, UNIX_PATH_MAX, "%s/.java_pid%d",
os::get_temp_directory(), os::current_process_id());
assert(n < (int)UNIX_PATH_MAX, "java_pid file name buffer overflow");
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == 0) {
ret = ::unlink(fn);
if (ret == -1) {
debug_only(warning("failed to remove stale attach pid file at %s", fn));
}
}
}
首先會建立/tmp/.java_pid<pid>
檔案,該檔案用於與socket進行繫結,實現程式間通訊。
這種通訊方式被稱為UNIX domain socket,只能用於本機的程式間通訊。
根據StartAttachListener
配置決定是否初始化Attach Listener
,在初始化時會啟動Attach Listener
執行緒
前面說過,具體還是要看作業系統是否支援系統級別的訊號通知,如果不支援還是會立即初始化。
AttachListener::init();
void AttachListener::init() {
...
JavaThread* listener_thread = new JavaThread(&attach_listener_thread_entry);
...
}
static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
os::set_priority(thread, NearMaxPriority);
thread->record_stack_base_and_size();
if (AttachListener::pd_init() != 0) {
return;
}
...
AttachListener::pd_init()
初始化邏輯根據實際的作業系統決定。在linux上,最終的初始化工作是由LinuxAttachListener::init()
完成。
AttachListener::pd_init()
int AttachListener::pd_init() {
...
int ret_code = LinuxAttachListener::init();
...
}
int LinuxAttachListener::init() {
...
::atexit(listener_cleanup);
int n = snprintf(path, UNIX_PATH_MAX, "%s/.java_pid%d",
os::get_temp_directory(), os::current_process_id());
...
listener = ::socket(PF_UNIX, SOCK_STREAM, 0);
...
int res = ::bind(listener, (struct sockaddr*)&addr, sizeof(addr));
...
}
LinuxAttachListener::init()
主要做了2件事:
- 註冊清理回撥函式,在JVM退出的時候進行資源釋放(主要是
/tmp/.java_pid<pid>
檔案的清理)。 - 將socket繫結到
/tmp/.java_pid<pid>
使用者程式間通訊。
Attach Listener執行緒啟動的兩種方式
現在我們基本上搞清楚了Signal Dispatcher
和Attach Listener
執行緒啟動的情況了。我們再來總結一下。
預設情況下JVM啟動的時候並不會立即啟動Attach Listener
執行緒。在客戶端傳送SIGQUIT
訊號時會啟動Attach Listener
執行緒。
或者我們可以通過引數配置在JVM啟動時直接啟動Attach Listener
執行緒。
Attach Listener執行命令
前面我們已經瞭解了Attach Listener
啟動時會在AttachListener::pd_init()
方法中建立socket並監聽。接下來我們簡單看下Attach Listener
是如何執行命令的。
static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
...
if (AttachListener::pd_init() != 0) {
return;
}
...
for (;;) {
//獲取命令
AttachOperation* op = AttachListener::dequeue();
...
AttachOperationFunctionInfo* info = NULL;
for (int i=0; funcs[i].name != NULL; i++) {
const char* name = funcs[i].name;
...
if (strcmp(op->name(), name) == 0) {
//查詢命令
info = &(funcs[i]);
break;
}
}
...
//執行命令
res = (info->func)(op, &st);
// operation complete - send result and output to client
op->complete(res, &st);
}
}
執行命令有3個主要步驟:
- 獲取命令
獲取命令AttachListener::dequeue()
就是通過AttachListener執行緒接收客戶端的命令執行請求。
LinuxAttachOperation* LinuxAttachListener::dequeue() {
for (;;) {
...
//接收客戶端連線
RESTARTABLE(::accept(listener(), &addr, &len), s);
...
//讀取命令並轉化為LinuxAttachOperation
LinuxAttachOperation* op = read_request(s);
...
return op;
}
}
- 通過命令名從funcs查詢需要執行的命令函式,linux支援的命令如下:
static AttachOperationFunctionInfo funcs[] = {
{ "agentProperties", get_agent_properties },
{ "datadump", data_dump },
{ "dumpheap", dump_heap },
{ "load", JvmtiExport::load_agent_library },
{ "properties", get_system_properties },
{ "threaddump", thread_dump },
{ "inspectheap", heap_inspection },
{ "setflag", set_flag },
{ "printflag", print_flag },
{ "jcmd", jcmd },
{ NULL, NULL }
};
這些命令實際就與JDK自帶的異常排查工具相對應。相關命令和函式對應關係如下。
命令 | 函式名 |
---|---|
jstack -l | threaddump |
jmap -dump:file=XXX | dumpheap |
jmap -histo:live | inspectheap |
jcmd | jcmd |
jinfo -flag | setflag |
jinfo flag | printflag |
- 執行命令
Attach Listener
執行緒主要用於JVM之間的通訊,部分命令的實際操作最終還是有虛擬機器執行緒完成。比如threaddump
函式,實際由vmthread完成命令的執行。
static jint thread_dump(AttachOperation* op, outputStream* out) {
bool print_concurrent_locks = false;
if (op->arg(0) != NULL && strcmp(op->arg(0), "-l") == 0) {
print_concurrent_locks = true;
}
// thread stacks
VM_PrintThreads op1(out, print_concurrent_locks);
VMThread::execute(&op1);
// JNI global handles
VM_PrintJNI op2(out);
VMThread::execute(&op2);
// Deadlock detection
VM_FindDeadlocks op3(out);
VMThread::execute(&op3);
return JNI_OK;
}
LinuxVirtualMachine
搞清楚了Java Attach服務端的處理邏輯,接下來我們看下客戶端是如何連線並執行命令的。
還是以linux環境下客戶端的程式碼在jdk\src\solaris\classes\sun\tools\attach\LinuxVirtualMachine.java
。
其他作業系統客戶端程式碼在
jdk\src\solaris\classes\sun\tools\attach\
下也能找到。
LinuxVirtualMachine(AttachProvider provider, String vmid)
throws AttachNotSupportedException, IOException
{
...
path = findSocketFile(pid);
if (path == null) {
File f = createAttachFile(pid);
...
if (isLinuxThreads) {
...
mpid = getLinuxThreadsManager(pid);
...
sendQuitToChildrenOf(mpid);
} else {
sendQuitTo(pid);
}
...
int i = 0;
long delay = 200;
int retries = (int)(attachTimeout() / delay);
do {
try {
Thread.sleep(delay);
} catch (InterruptedException x) { }
path = findSocketFile(pid);
i++;
} while (i <= retries && path == null);
if (path == null) {
throw new AttachNotSupportedException(
"Unable to open socket file: target process not responding " +
"or HotSpot VM not loaded");
}
} finally {
f.delete();
}
}
...
int s = socket();
try {
connect(s, path);
} finally {
close(s);
}
}
處理流程如下:
- 查詢
/tmp/.java_pid<pid>
檔案。
- 若檔案已存在,則表示JVM已經初始化了
Attach Listener
執行緒,則可以直接連線到JVM。 - 若檔案不存在則表示JVM還沒有啟用
Attach Listener
執行緒。此時需要通過傳送SIGQUIT
訊號量給JVM啟用Attach Listener
執行緒
- 建立
/proc/<pid>/cwd/.attach_pid<pid>
或/tmp/.attach_pid<pid>
,這個檔案僅僅時用於attach機制的握手,服務端會檢查該檔案是否存在,用來確認是Attach機制是JVM啟動觸發的還是客戶端觸發的。 - 獲取JVM的程式id
- linux作業系統會程式的組ID,通過組ID獲取到所有執行緒併傳送
SIGQUIT
訊號,只有Signal Dispatcher
執行緒會處理SIGQUIT
訊號。從而啟用Attach Listener
執行緒。
linux是不區分程式和執行緒的,通過講使用者級執行緒對映到輕量級程式。組成一個使用者級程式的多使用者級執行緒被對映到共享同一個組ID的多個Linux核心級程式上。《作業系統精髓與設計原理》-4.6.2Linux執行緒
- 其他作業系統當前執行緒的程式id就是程式id
- JVM收到訊號後會判斷若未啟動
Attach Listener
執行緒,就會啟動Attach Listener
執行緒。
這是一種懶載入機制,只有在需要的時候才啟動。
- 前面講過。當JVM啟動
Attach Listener
執行緒後,會建立tmp/java_pid<pid>
檔案,客戶端就通過該檔案與服務端進行網路通訊。
預設情況下
attachTimeout()
為5秒,若JVM 5秒鐘沒有建立java_pid
檔案就認為超時了。
那麼LinuxVirtualMachine
是如何被執行的呢?我們以jstack
為例。
jstack
程式碼在jdk\src\share\classes\sun\tools\jstack\JStack.java
當我們通過命令列呼叫jstack列印執行緒棧時。若不是SA模式,則會呼叫到runThreadDump
SA(ServiceAbility)提供了虛擬機器除錯快照的功能,它內部提供了一些jstack,jmap的一些工具也可以獲取到相關的JVM引數。但是如果除錯的是執行程式,則會使除錯的目標程式完全暫停。
public static void main(String[] args) throws Exception {
...
if (useSA) {
...
runJStackTool(mixed, locks, params);
} else {
...
runThreadDump(pid, params);
}
}
private static void runThreadDump(String pid, String args[]) throws Exception {
...
vm = VirtualMachine.attach(pid);
...
InputStream in = ((HotSpotVirtualMachine)vm).remoteDataDump((Object[])args);
...
//和attach相反
vm.detach();
}
這裡做了3件事:
- 獲取
VirtualMachine
,並attach到目標JVM
public static VirtualMachine attach(String id)
throws AttachNotSupportedException, IOException
{
...
List<AttachProvider> providers = AttachProvider.providers();
...
AttachNotSupportedException lastExc = null;
for (AttachProvider provider: providers) {
return provider.attachVirtualMachine(id);
}
}
在linux下,provider
使用的是LinuxAttachProvider
,建立的是LinuxVirtualMachine
物件。
public VirtualMachine attachVirtualMachine(VirtualMachineDescriptor vmd)
throws AttachNotSupportedException, IOException
{
...
return new LinuxVirtualMachine(this, vmd.id());
...
}
- 執行
remoteDataDump
,實際就是通過socket與目標JVM進行通訊並執行相關的命令。
public InputStream remoteDataDump(Object ... args) throws IOException {
return executeCommand("threaddump", args);
}
- 呼叫
detach
與目標虛擬機器斷開。實際每次執行命令會重新建立連線,執行完就會關閉連線。這裡僅僅把path
置空而已,並沒有做其他什麼工作。
結語
本文對JVM之間使用過Java Attach
的互動流程進行了梳理。一開始也提到,Java Attach
並不只是在JVM之間獲取執行時資訊那麼簡單,load
命令讓JVM在執行時也能被代理,通過ASM、等位元組碼修改技術,在執行時對類進行修改。