原始碼解析Java Attach處理流程

傑哥很忙發表於2021-07-18

前言

當Java程式執行時出現CPU負載高、記憶體佔用大等異常情況時,通常需要使用JDK自帶的工具jstack、jmap檢視JVM的執行時資料,並進行分析。

什麼是Java Attach

那麼JVM自帶的這些工具是如何獲取到JVM的相關資訊呢?
JVM提供了 Java Attach 功能,能夠讓客戶端與目標JVM進行通訊從而獲取JVM執行時的資料,甚至可以通過Java Attach 載入自定義的代理工具,實現AOP、執行時class熱更新等功能。

如果我們通過jstack列印執行緒棧的時候會發現有這麼2個執行緒:Signal DispatcherAttach 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 DispatcherAttach 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 DispatcherAttach 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個主要步驟:

  1. 獲取命令
    獲取命令AttachListener::dequeue()就是通過AttachListener執行緒接收客戶端的命令執行請求。
LinuxAttachOperation* LinuxAttachListener::dequeue() {
  for (;;) {
    ...
	//接收客戶端連線
    RESTARTABLE(::accept(listener(), &addr, &len), s);
    ...
    //讀取命令並轉化為LinuxAttachOperation
    LinuxAttachOperation* op = read_request(s);
    ...
	return op;
  }
}
  1. 通過命令名從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
  1. 執行命令
    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);
        }
    }

處理流程如下:

  1. 查詢/tmp/.java_pid<pid>檔案。
  • 若檔案已存在,則表示JVM已經初始化了Attach Listener執行緒,則可以直接連線到JVM。
  • 若檔案不存在則表示JVM還沒有啟用Attach Listener執行緒。此時需要通過傳送SIGQUIT訊號量給JVM啟用Attach Listener執行緒
  1. 建立/proc/<pid>/cwd/.attach_pid<pid>/tmp/.attach_pid<pid>,這個檔案僅僅時用於attach機制的握手,服務端會檢查該檔案是否存在,用來確認是Attach機制是JVM啟動觸發的還是客戶端觸發的。
  2. 獲取JVM的程式id
  • linux作業系統會程式的組ID,通過組ID獲取到所有執行緒併傳送SIGQUIT訊號,只有Signal Dispatcher執行緒會處理SIGQUIT訊號。從而啟用Attach Listener執行緒。

linux是不區分程式和執行緒的,通過講使用者級執行緒對映到輕量級程式。組成一個使用者級程式的多使用者級執行緒被對映到共享同一個組ID的多個Linux核心級程式上。《作業系統精髓與設計原理》-4.6.2Linux執行緒

  • 其他作業系統當前執行緒的程式id就是程式id
  1. JVM收到訊號後會判斷若未啟動Attach Listener執行緒,就會啟動Attach Listener執行緒。

這是一種懶載入機制,只有在需要的時候才啟動。

  1. 前面講過。當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件事:

  1. 獲取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());
		...
    }
  1. 執行remoteDataDump,實際就是通過socket與目標JVM進行通訊並執行相關的命令。
public InputStream remoteDataDump(Object ... args) throws IOException {
        return executeCommand("threaddump", args);
    }
  1. 呼叫detach與目標虛擬機器斷開。實際每次執行命令會重新建立連線,執行完就會關閉連線。這裡僅僅把path置空而已,並沒有做其他什麼工作。

結語

本文對JVM之間使用過Java Attach的互動流程進行了梳理。一開始也提到,Java Attach並不只是在JVM之間獲取執行時資訊那麼簡單,load命令讓JVM在執行時也能被代理,通過ASM、等位元組碼修改技術,在執行時對類進行修改。

相關文章