Java 動態調試技術原理及實踐

  • 2019 年 11 月 10 日
  • 筆記

調試是發現和減少電腦程式或電子儀器設備中程式錯誤的一個過程。最常用的斷點調試技術會在斷點位置停頓,導致應用停止響應。本文將介紹一種Java動態調試技術,希望能對大家有幫助。同時也歡迎讀者朋友們一起交流,繼續探索動態化調試技術。

1. 動態調試要解決的問題

斷點調試是我們最常使用的調試手段,它可以獲取到方法執行過程中的變數資訊,並可以觀察到方法的執行路徑。但斷點調試會在斷點位置停頓,使得整個應用停止響應。在線上停頓應用是致命的,動態調試技術給了我們創造新的調試模式的想像空間。本文將研究Java語言中的動態調試技術,首先概括Java動態調試所涉及的技術基礎,接著介紹我們在Java動態調試領域的思考及實踐,通過結合實際業務場景,設計並實現了一種具備動態性的斷點調試工具Java-debug-tool,顯著提高了故障排查效率。

2. Java Agent技術

JVMTI (JVM Tool Interface)是Java虛擬機對外提供的Native編程介面,通過JVMTI,外部進程可以獲取到運行時JVM的諸多資訊,比如執行緒、GC等。Agent是一個運行在目標JVM的特定程式,它的職責是負責從目標JVM中獲取數據,然後將數據傳遞給外部進程。載入Agent的時機可以是目標JVM啟動之時,也可以是在目標JVM運行時進行載入,而在目標JVM運行時進行Agent載入具備動態性,對於時機未知的Debug場景來說非常實用。下面將詳細分析Java Agent技術的實現細節。

2.1 Agent的實現模式

JVMTI是一套Native介面,在Java SE 5之前,要實現一個Agent只能通過編寫Native程式碼來實現。從Java SE 5開始,可以使用Java的Instrumentation介面(java.lang.instrument)來編寫Agent。無論是通過Native的方式還是通過Java Instrumentation介面的方式來編寫Agent,它們的工作都是藉助JVMTI來進行完成,下面介紹通過Java Instrumentation介面編寫Agent的方法。

2.1.1 通過Java Instrumentation API

  • 實現Agent啟動方法

Java Agent支援目標JVM啟動時載入,也支援在目標JVM運行時載入,這兩種不同的載入模式會使用不同的入口函數,如果需要在目標JVM啟動的同時載入Agent,那麼可以選擇實現下面的方法:

[1] public static void premain(String agentArgs, Instrumentation inst);  [2] public static void premain(String agentArgs);

JVM將首先尋找[1],如果沒有發現[1],再尋找[2]。如果希望在目標JVM運行時載入Agent,則需要實現下面的方法:

[1] public static void agentmain(String agentArgs, Instrumentation inst);  [2] public static void agentmain(String agentArgs);

這兩組方法的第一個參數AgentArgs是隨同 「– javaagent」一起傳入的程式參數,如果這個字元串代表了多個參數,就需要自己解析這些參數。inst是Instrumentation類型的對象,是JVM自動傳入的,我們可以拿這個參數進行類增強等操作。

  • 指定Main-Class

Agent需要打包成一個jar包,在ManiFest屬性中指定「Premain-Class」或者「Agent-Class」:

Premain-Class: class  Agent-Class: class
  • 掛載到目標JVM

將編寫的Agent打成jar包後,就可以掛載到目標JVM上去了。如果選擇在目標JVM啟動時載入Agent,則可以使用 "-javaagent:<jarpath>[=<option>]",具體的使用方法可以使用「Java -Help」來查看。如果想要在運行時掛載Agent到目標JVM,就需要做一些額外的開發了。

com.sun.tools.attach.VirtualMachine 這個類代表一個JVM抽象,可以通過這個類找到目標JVM,並且將Agent掛載到目標JVM上。下面是使用com.sun.tools.attach.VirtualMachine進行動態掛載Agent的一般實現:

    private void attachAgentToTargetJVM() throws Exception {          List<VirtualMachineDescriptor> virtualMachineDescriptors = VirtualMachine.list();          VirtualMachineDescriptor targetVM = null;          for (VirtualMachineDescriptor descriptor : virtualMachineDescriptors) {              if (descriptor.id().equals(configure.getPid())) {                  targetVM = descriptor;                  break;              }          }          if (targetVM == null) {              throw new IllegalArgumentException("could not find the target jvm by process id:" + configure.getPid());          }          VirtualMachine virtualMachine = null;          try {              virtualMachine = VirtualMachine.attach(targetVM);              virtualMachine.loadAgent("{agent}", "{params}");          } catch (Exception e) {              if (virtualMachine != null) {                  virtualMachine.detach();              }          }      }

首先通過指定的進程ID找到目標JVM,然後通過Attach掛載到目標JVM上,執行載入Agent操作。VirtualMachine的Attach方法就是用來將Agent掛載到目標JVM上去的,而Detach則是將Agent從目標JVM卸載。關於Agent是如何掛載到目標JVM上的具體技術細節,將在下文中進行分析。

2.2 啟動時載入Agent

2.2.1 參數解析

創建JVM時,JVM會進行參數解析,即解析那些用來配置JVM啟動的參數,比如堆大小、GC等;本文主要關註解析的參數為-agentlib、 -agentpath、 -javaagent,這幾個參數用來指定Agent,JVM會根據這幾個參數載入Agent。下面來分析一下JVM是如何解析這幾個參數的。

  // -agentlib and -agentpath    if (match_option(option, "-agentlib:", &tail) ||            (is_absolute_path = match_option(option, "-agentpath:", &tail))) {        if(tail != NULL) {          const char* pos = strchr(tail, '=');          size_t len = (pos == NULL) ? strlen(tail) : pos - tail;          char* name = strncpy(NEW_C_HEAP_ARRAY(char, len + 1, mtArguments), tail, len);          name[len] = '';          char *options = NULL;          if(pos != NULL) {            options = os::strdup_check_oom(pos + 1, mtArguments);          }  #if !INCLUDE_JVMTI          if (valid_jdwp_agent(name, is_absolute_path)) {            jio_fprintf(defaultStream::error_stream(),              "Debugging agents are not supported in this VMn");            return JNI_ERR;          }  #endif // !INCLUDE_JVMTI          add_init_agent(name, options, is_absolute_path);        }      // -javaagent      } else if (match_option(option, "-javaagent:", &tail)) {  #if !INCLUDE_JVMTI        jio_fprintf(defaultStream::error_stream(),          "Instrumentation agents are not supported in this VMn");        return JNI_ERR;  #else        if (tail != NULL) {          size_t length = strlen(tail) + 1;          char *options = NEW_C_HEAP_ARRAY(char, length, mtArguments);          jio_snprintf(options, length, "%s", tail);          add_init_agent("instrument", options, false);          // java agents need module java.instrument          if (!create_numbered_property("jdk.module.addmods", "java.instrument", addmods_count++)) {            return JNI_ENOMEM;          }        }  #endif // !INCLUDE_JVMTI      }

上面的程式碼片段截取自hotspot/src/share/vm/runtime/arguments.cpp中的 Arguments::parse_each_vm_init_arg(const JavaVMInitArgs* args, bool* patch_mod_javabase, Flag::Flags origin) 函數,該函數用來解析一個具體的JVM參數。這段程式碼的主要功能是解析出需要載入的Agent路徑,然後調用add_init_agent函數進行解析結果的存儲。下面先看一下add_init_agent函數的具體實現:

  // -agentlib and -agentpath arguments    static AgentLibraryList _agentList;    static void add_init_agent(const char* name, char* options, bool absolute_path)      { _agentList.add(new AgentLibrary(name, options, absolute_path, NULL)); }

AgentLibraryList是一個簡單的鏈表結構,add_init_agent函數將解析好的、需要載入的Agent添加到這個鏈表中,等待後續的處理。

這裡需要注意,解析-javaagent參數有一些特別之處,這個參數用來指定一個我們通過Java Instrumentation API來編寫的Agent,Java Instrumentation API底層依賴的是JVMTI,對-JavaAgent的處理也說明了這一點,在調用add_init_agent函數時第一個參數是「instrument」,關於載入Agent這個問題在下一小節進行展開。到此,我們知道在啟動JVM時指定的Agent已經被JVM解析完存放在了一個鏈表結構中。下面來分析一下JVM是如何載入這些Agent的。

2.2.2 執行載入操作

在創建JVM進程的函數中,解析完JVM參數之後,下面的這段程式碼和載入Agent相關:

   // Launch -agentlib/-agentpath and converted -Xrun agents    if (Arguments::init_agents_at_startup()) {      create_vm_init_agents();    }    static bool init_agents_at_startup() {      return !_agentList.is_empty();    }

當JVM判斷出上一小節中解析出來的Agent不為空的時候,就要去調用函數create_vm_init_agents來載入Agent,下面來分析一下create_vm_init_agents函數是如何載入Agent的。

void Threads::create_vm_init_agents() {    AgentLibrary* agent;    for (agent = Arguments::agents(); agent != NULL; agent = agent->next()) {      OnLoadEntry_t  on_load_entry = lookup_agent_on_load(agent);      if (on_load_entry != NULL) {        // Invoke the Agent_OnLoad function        jint err = (*on_load_entry)(&main_vm, agent->options(), NULL);      }    }  }

create_vm_init_agents這個函數通過遍歷Agent鏈表來逐個載入Agent。通過這段程式碼可以看出,首先通過lookup_agent_on_load來載入Agent並且找到Agent_OnLoad函數,這個函數是Agent的入口函數。如果沒找到這個函數,則認為是載入了一個不合法的Agent,則什麼也不做,否則調用這個函數,這樣Agent的程式碼就開始執行起來了。對於使用Java Instrumentation API來編寫Agent的方式來說,在解析階段觀察到在add_init_agent函數裡面傳遞進去的是一個叫做"instrument"的字元串,其實這是一個動態鏈接庫。在Linux裡面,這個庫叫做libinstrument.so,在BSD系統中叫做libinstrument.dylib,該動態鏈接庫在{JAVA_HOME}/jre/lib/目錄下。

2.2.3 Instrument動態鏈接庫

libinstrument用來支援使用Java Instrumentation API來編寫Agent,在libinstrument中有一個非常重要的類稱為:JPLISAgent(Java Programming Language Instrumentation Services Agent),它的作用是初始化所有通過Java Instrumentation API編寫的Agent,並且也承擔著通過JVMTI實現Java Instrumentation中暴露API的責任。

我們已經知道,在JVM啟動的時候,JVM會通過-javaagent參數載入Agent。最開始載入的是libinstrument動態鏈接庫,然後在動態鏈接庫裡面找到JVMTI的入口方法:Agent_OnLoad。下面就來分析一下在libinstrument動態鏈接庫中,Agent_OnLoad函數是怎麼實現的。

JNIEXPORT jint JNICALL  DEF_Agent_OnLoad(JavaVM *vm, char *tail, void * reserved) {      initerror = createNewJPLISAgent(vm, &agent);      if ( initerror == JPLIS_INIT_ERROR_NONE ) {          if (parseArgumentTail(tail, &jarfile, &options) != 0) {              fprintf(stderr, "-javaagent: memory allocation failure.n");              return JNI_ERR;          }          attributes = readAttributes(jarfile);          premainClass = getAttribute(attributes, "Premain-Class");          /* Save the jarfile name */          agent->mJarfile = jarfile;          /*           * Convert JAR attributes into agent capabilities           */          convertCapabilityAttributes(attributes, agent);          /*           * Track (record) the agent class name and options data           */          initerror = recordCommandLineData(agent, premainClass, options);      }      return result;  }

上述程式碼片段是經過精簡的libinstrument中Agent_OnLoad實現的,大概的流程就是:先創建一個JPLISAgent,然後將ManiFest中設定的一些參數解析出來, 比如(Premain-Class)等。創建了JPLISAgent之後,調用initializeJPLISAgent對這個Agent進行初始化操作。跟進initializeJPLISAgent看一下是如何初始化的:

JPLISInitializationError initializeJPLISAgent(JPLISAgent *agent, JavaVM *vm, jvmtiEnv *jvmtienv) {      /* check what capabilities are available */      checkCapabilities(agent);      /* check phase - if live phase then we don't need the VMInit event */      jvmtierror = (*jvmtienv)->GetPhase(jvmtienv, &phase);      /* now turn on the VMInit event */      if ( jvmtierror == JVMTI_ERROR_NONE ) {          jvmtiEventCallbacks callbacks;          memset(&callbacks, 0, sizeof(callbacks));          callbacks.VMInit = &eventHandlerVMInit;          jvmtierror = (*jvmtienv)->SetEventCallbacks(jvmtienv,&callbacks,sizeof(callbacks));      }      if ( jvmtierror == JVMTI_ERROR_NONE ) {          jvmtierror = (*jvmtienv)->SetEventNotificationMode(jvmtienv,JVMTI_ENABLE,JVMTI_EVENT_VM_INIT,NULL);      }      return (jvmtierror == JVMTI_ERROR_NONE)? JPLIS_INIT_ERROR_NONE : JPLIS_INIT_ERROR_FAILURE;  }

這裡,我們關注callbacks.VMInit = &eventHandlerVMInit;這行程式碼,這裡設置了一個VMInit事件的回調函數,表示在JVM初始化的時候會回調eventHandlerVMInit函數。下面來看一下這個函數的實現細節,猜測就是在這裡調用了Premain方法:

void JNICALL  eventHandlerVMInit( jvmtiEnv *jvmtienv,JNIEnv *jnienv,jthread thread) {     // ...     success = processJavaStart( environment->mAgent, jnienv);    // ...  }  jboolean  processJavaStart(JPLISAgent *agent,JNIEnv *jnienv) {      result = createInstrumentationImpl(jnienv, agent);      /*       *  Load the Java agent, and call the premain.       */      if ( result ) {          result = startJavaAgent(agent, jnienv, agent->mAgentClassName, agent->mOptionsString, agent->mPremainCaller);      }      return result;  }  jboolean startJavaAgent( JPLISAgent *agent,JNIEnv *jnienv,const char *classname,const char *optionsString,jmethodID agentMainMethod) {    // ...    invokeJavaAgentMainMethod(jnienv,agent->mInstrumentationImpl,agentMainMethod, classNameObject,optionsStringObject);    // ...  }

看到這裡,Instrument已經實例化,invokeJavaAgentMainMethod這個方法將我們的Premain方法執行起來了。接著,我們就可以根據Instrument實例來做我們想要做的事情了。

2.3 運行時載入Agent

比起JVM啟動時載入Agent,運行時載入Agent就比較有誘惑力了,因為運行時載入Agent的能力給我們提供了很強的動態性,我們可以在需要的時候載入Agent來進行一些工作。因為是動態的,我們可以按照需求來載入所需要的Agent,下面來分析一下動態載入Agent的相關技術細節。

2.3.1 AttachListener

Attach機制通過Attach Listener執行緒來進行相關事務的處理,下面來看一下Attach Listener執行緒是如何初始化的。

// Starts the Attach Listener thread  void AttachListener::init() {    // 創建執行緒相關部分程式碼被去掉了    const char thread_name[] = "Attach Listener";    Handle string = java_lang_String::create_from_str(thread_name, THREAD);    { MutexLocker mu(Threads_lock);      JavaThread* listener_thread = new JavaThread(&attach_listener_thread_entry);      // ...    }  }

我們知道,一個執行緒啟動之後都需要指定一個入口來執行程式碼,Attach Listener執行緒的入口是attach_listener_thread_entry,下面看一下這個函數的具體實現:

static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {    AttachListener::set_initialized();    for (;;) {        AttachOperation* op = AttachListener::dequeue();        // find the function to dispatch too        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;          }}         // dispatch to the function that implements this operation          res = (info->func)(op, &st);        //...      }  }

整個函數執行邏輯,大概是這樣的:

  • 拉取一個需要執行的任務:AttachListener::dequeue。
  • 查詢匹配的命令處理函數。
  • 執行匹配到的命令執行函數。

其中第二步裡面存在一個命令函數表,整個表如下:

static AttachOperationFunctionInfo funcs[] = {    { "agentProperties",  get_agent_properties },    { "datadump",         data_dump },    { "dumpheap",         dump_heap },    { "load",             load_agent },    { "properties",       get_system_properties },    { "threaddump",       thread_dump },    { "inspectheap",      heap_inspection },    { "setflag",          set_flag },    { "printflag",        print_flag },    { "jcmd",             jcmd },    { NULL,               NULL }  };

對於載入Agent來說,命令就是「load」。現在,我們知道了Attach Listener大概的工作模式,但是還是不太清楚任務從哪來,這個秘密就藏在AttachListener::dequeue這行程式碼裡面,接下來我們來分析一下dequeue這個函數:

LinuxAttachOperation* LinuxAttachListener::dequeue() {    for (;;) {      // wait for client to connect      struct sockaddr addr;      socklen_t len = sizeof(addr);      RESTARTABLE(::accept(listener(), &addr, &len), s);      // get the credentials of the peer and check the effective uid/guid      // - check with jeff on this.      struct ucred cred_info;      socklen_t optlen = sizeof(cred_info);      if (::getsockopt(s, SOL_SOCKET, SO_PEERCRED, (void*)&cred_info, &optlen) == -1) {        ::close(s);        continue;      }      // peer credential look okay so we read the request      LinuxAttachOperation* op = read_request(s);      return op;    }  }

這是Linux上的實現,不同的作業系統實現方式不太一樣。上面的程式碼表面,Attach Listener在某個埠監聽著,通過accept來接收一個連接,然後從這個連接裡面將請求讀取出來,然後將請求包裝成一個AttachOperation類型的對象,之後就會從表裡查詢對應的處理函數,然後進行處理。

Attach Listener使用一種被稱為「懶載入」的策略進行初始化,也就是說,JVM啟動的時候Attach Listener並不一定會啟動起來。下面我們來分析一下這種「懶載入」策略的具體實現方案。

  // Start Attach Listener if +StartAttachListener or it can't be started lazily    if (!DisableAttachMechanism) {      AttachListener::vm_start();      if (StartAttachListener || AttachListener::init_at_startup()) {        AttachListener::init();      }    }  // Attach Listener is started lazily except in the case when  // +ReduseSignalUsage is used  bool AttachListener::init_at_startup() {    if (ReduceSignalUsage) {      return true;    } else {      return false;    }  }

上面的程式碼截取自create_vm函數,DisableAttachMechanism、StartAttachListener和ReduceSignalUsage這三個變數默認都是false,所以AttachListener::init();這行程式碼不會在create_vm的時候執行,而vm_start會執行。下面來看一下這個函數的實現細節:

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) {        log_debug(attach)("Failed to remove stale attach pid file at %s", fn);      }    }  }

這是在Linux上的實現,是將/tmp/目錄下的.java_pid{pid}文件刪除,後面在創建Attach Listener執行緒的時候會創建出來這個文件。上面說到,AttachListener::init()這行程式碼不會在create_vm的時候執行,這行程式碼的實現已經在上文中分析了,就是創建Attach Listener執行緒,並監聽其他JVM的命令請求。現在來分析一下這行程式碼是什麼時候被調用的,也就是「懶載入」到底是怎麼載入起來的。

  // Signal Dispatcher needs to be started before VMInit event is posted    os::signal_init();

這是create_vm中的一段程式碼,看起來跟訊號相關,其實Attach機制就是使用訊號來實現「懶載入「的。下面我們來仔細地分析一下這個過程。

void os::signal_init() {    if (!ReduceSignalUsage) {      // Setup JavaThread for processing signals      EXCEPTION_MARK;      Klass* k = SystemDictionary::resolve_or_fail(vmSymbols::java_lang_Thread(), true, CHECK);      instanceKlassHandle klass (THREAD, k);      instanceHandle thread_oop = klass->allocate_instance_handle(CHECK);      const char thread_name[] = "Signal Dispatcher";      Handle string = java_lang_String::create_from_str(thread_name, CHECK);      // Initialize thread_oop to put it into the system threadGroup      Handle thread_group (THREAD, Universe::system_thread_group());      JavaValue result(T_VOID);      JavaCalls::call_special(&result, thread_oop,klass,vmSymbols::object_initializer_name(),vmSymbols::threadgroup_string_void_signature(),                             thread_group,string,CHECK);      KlassHandle group(THREAD, SystemDictionary::ThreadGroup_klass());      JavaCalls::call_special(&result,thread_group,group,vmSymbols::add_method_name(),vmSymbols::thread_void_signature(),thread_oop,CHECK);      os::signal_init_pd();      { MutexLocker mu(Threads_lock);        JavaThread* signal_thread = new JavaThread(&signal_thread_entry);       // ...      }      // Handle ^BREAK      os::signal(SIGBREAK, os::user_handler());    }  }

JVM創建了一個新的進程來實現訊號處理,這個執行緒叫「Signal Dispatcher」,一個執行緒創建之後需要有一個入口,「Signal Dispatcher」的入口是signal_thread_entry:

這段程式碼截取自signal_thread_entry函數,截取中的內容是和Attach機制訊號處理相關的程式碼。這段程式碼的意思是,當接收到「SIGBREAK」訊號,就執行接下來的程式碼,這個訊號是需要Attach到JVM上的訊號發出來,這個後面會再分析。我們先來看一句關鍵的程式碼:AttachListener::is_init_trigger():

bool AttachListener::is_init_trigger() {    if (init_at_startup() || is_initialized()) {      return false;               // initialized at startup or already initialized    }    char fn[PATH_MAX+1];    sprintf(fn, ".attach_pid%d", os::current_process_id());    int ret;    struct stat64 st;    RESTARTABLE(::stat64(fn, &st), ret);    if (ret == -1) {      log_trace(attach)("Failed to find attach file: %s, trying alternate", fn);      snprintf(fn, sizeof(fn), "%s/.attach_pid%d", os::get_temp_directory(), os::current_process_id());      RESTARTABLE(::stat64(fn, &st), ret);    }    if (ret == 0) {      // simple check to avoid starting the attach mechanism when      // a bogus user creates the file      if (st.st_uid == geteuid()) {        init();        return true;      }    }    return false;  }

首先檢查了一下是否在JVM啟動時啟動了Attach Listener,或者是否已經啟動過。如果沒有,才繼續執行,在/tmp目錄下創建一個叫做.attach_pid%d的文件,然後執行AttachListener的init函數,這個函數就是用來創建Attach Listener執行緒的函數,上面已經提到多次並進行了分析。到此,我們知道Attach機制的奧秘所在,也就是Attach Listener執行緒的創建依靠Signal Dispatcher執行緒,Signal Dispatcher是用來處理訊號的執行緒,當Signal Dispatcher執行緒接收到「SIGBREAK」訊號之後,就會執行初始化Attach Listener的工作。

2.3.2 運行時載入Agent的實現

我們繼續分析,到底是如何將一個Agent掛載到運行著的目標JVM上,在上文中提到了一段程式碼,用來進行運行時掛載Agent,可以參考上文中展示的關於「attachAgentToTargetJvm」方法的程式碼。這個方法裡面的關鍵是調用VirtualMachine的attach方法進行Agent掛載的功能。下面我們就來分析一下VirtualMachine的attach方法具體是怎麼實現的。

    public static VirtualMachine attach(String var0) throws AttachNotSupportedException, IOException {          if (var0 == null) {              throw new NullPointerException("id cannot be null");          } else {              List var1 = AttachProvider.providers();              if (var1.size() == 0) {                  throw new AttachNotSupportedException("no providers installed");              } else {                  AttachNotSupportedException var2 = null;                  Iterator var3 = var1.iterator();                  while(var3.hasNext()) {                      AttachProvider var4 = (AttachProvider)var3.next();                      try {                          return var4.attachVirtualMachine(var0);                      } catch (AttachNotSupportedException var6) {                          var2 = var6;                      }                  }                  throw var2;              }          }      }

這個方法通過attachVirtualMachine方法進行attach操作,在MacOS系統中,AttachProvider的實現類是BsdAttachProvider。我們來看一下BsdAttachProvider的attachVirtualMachine方法是如何實現的:

public VirtualMachine attachVirtualMachine(String var1) throws AttachNotSupportedException, IOException {          this.checkAttachPermission();          this.testAttachable(var1);          return new BsdVirtualMachine(this, var1);      }  BsdVirtualMachine(AttachProvider var1, String var2) throws AttachNotSupportedException, IOException {          int var3 = Integer.parseInt(var2);          this.path = this.findSocketFile(var3);          if (this.path == null) {              File var4 = new File(tmpdir, ".attach_pid" + var3);              createAttachFile(var4.getPath());              try {                  sendQuitTo(var3);                  int var5 = 0;                  long var6 = 200L;                  int var8 = (int)(this.attachTimeout() / var6);                  do {                      try {                          Thread.sleep(var6);                      } catch (InterruptedException var21) {                          ;                      }                      this.path = this.findSocketFile(var3);                      ++var5;                  } while(var5 <= var8 && this.path == null);              } finally {                  var4.delete();              }          }          int var24 = socket();          connect(var24, this.path);      }      private String findSocketFile(int var1) {          String var2 = ".java_pid" + var1;          File var3 = new File(tmpdir, var2);          return var3.exists() ? var3.getPath() : null;      }

findSocketFile方法用來查詢目標JVM上是否已經啟動了Attach Listener,它通過檢查"tmp/"目錄下是否存在java_pid{pid}來進行實現。如果已經存在了,則說明Attach機制已經準備就緒,可以接受客戶端的命令了,這個時候客戶端就可以通過connect連接到目標JVM進行命令的發送,比如可以發送「load」命令來載入Agent。如果java_pid{pid}文件還不存在,則需要通過sendQuitTo方法向目標JVM發送一個「SIGBREAK」訊號,讓它初始化Attach Listener執行緒並準備接受客戶端連接。可以看到,發送了訊號之後客戶端會循環等待java_pid{pid}這個文件,之後再通過connect連接到目標JVM上。

2.3.3 load命令的實現

下面來分析一下,「load」命令在JVM層面的實現:

static jint load_agent(AttachOperation* op, outputStream* out) {    // get agent name and options    const char* agent = op->arg(0);    const char* absParam = op->arg(1);    const char* options = op->arg(2);    // If loading a java agent then need to ensure that the java.instrument module is loaded    if (strcmp(agent, "instrument") == 0) {      Thread* THREAD = Thread::current();      ResourceMark rm(THREAD);      HandleMark hm(THREAD);      JavaValue result(T_OBJECT);      Handle h_module_name = java_lang_String::create_from_str("java.instrument", THREAD);      JavaCalls::call_static(&result,SystemDictionary::module_Modules_klass(),vmSymbols::loadModule_name(),                             vmSymbols::loadModule_signature(),h_module_name,THREAD);    }    return JvmtiExport::load_agent_library(agent, absParam, options, out);  }

這個函數先確保載入了java.instrument模組,之後真正執行Agent載入的函數是 load_agent_library ,這個函數的套路就是載入Agent動態鏈接庫,如果是通過Java instrument API實現的Agent,則載入的是libinstrument動態鏈接庫,然後通過libinstrument裡面的程式碼實現運行agentmain方法的邏輯,這一部分內容和libinstrument實現premain方法運行的邏輯其實差不多,這裡不再做分析。至此,我們對Java Agent技術已經有了一個全面而細緻的了解。

3. 動態替換類位元組碼技術

3.1 動態位元組碼修改的限制

上文中已經詳細分析了Agent技術的實現,我們使用Java Instrumentation API來完成動態類修改的功能,在Instrumentation介面中,通過addTransformer方法來增加一個類轉換器,類轉換器由類ClassFileTransformer介面實現。ClassFileTransformer介面中唯一的方法transform用於實現類轉換,當類被載入的時候,就會調用transform方法,進行類轉換。在運行時,我們可以通過Instrumentation的redefineClasses方法進行類重定義,在方法上有一段注釋需要特別注意:

     * The redefinition may change method bodies, the constant pool and attributes.       * The redefinition must not add, remove or rename fields or methods, change the       * signatures of methods, or change inheritance.  These restrictions maybe be       * lifted in future versions.  The class file bytes are not checked, verified and installed       * until after the transformations have been applied, if the resultant bytes are in       * error this method will throw an exception.

這裡面提到,我們不可以增加、刪除或者重命名欄位和方法,改變方法的簽名或者類的繼承關係。認識到這一點很重要,當我們通過ASM獲取到增強的位元組碼之後,如果增強後的位元組碼沒有遵守這些規則,那麼調用redefineClasses方法來進行類的重定義就會失敗。那redefineClasses方法具體是怎麼實現類的重定義的呢?它對運行時的JVM會造成什麼樣的影響呢?下面來分析redefineClasses的實現細節。

3.2 重定義類位元組碼的實現細節

上文中我們提到,libinstrument動態鏈接庫中,JPLISAgent不僅實現了Agent入口程式碼執行的路由,而且還是Java程式碼與JVMTI之間的一道橋樑。我們在Java程式碼中調用Java Instrumentation API的redefineClasses,其實會調用libinstrument中的相關程式碼,我們來分析一下這條路徑。

    public void redefineClasses(ClassDefinition... var1) throws ClassNotFoundException {          if (!this.isRedefineClassesSupported()) {              throw new UnsupportedOperationException("redefineClasses is not supported in this environment");          } else if (var1 == null) {              throw new NullPointerException("null passed as 'definitions' in redefineClasses");          } else {              for(int var2 = 0; var2 < var1.length; ++var2) {                  if (var1[var2] == null) {                      throw new NullPointerException("element of 'definitions' is null in redefineClasses");                  }              }              if (var1.length != 0) {                  this.redefineClasses0(this.mNativeAgent, var1);              }          }      }      private native void redefineClasses0(long var1, ClassDefinition[] var3) throws ClassNotFoundException;

這是InstrumentationImpl中的redefineClasses實現,該方法的具體實現依賴一個Native方法redefineClasses(),我們可以在libinstrument中找到這個Native方法的實現:

JNIEXPORT void JNICALL Java_sun_instrument_InstrumentationImpl_redefineClasses0    (JNIEnv * jnienv, jobject implThis, jlong agent, jobjectArray classDefinitions) {      redefineClasses(jnienv, (JPLISAgent*)(intptr_t)agent, classDefinitions);  }

redefineClasses這個函數的實現比較複雜,程式碼很長。下面是一段關鍵的程式碼片段:

可以看到,其實是調用了JVMTI的RetransformClasses函數來完成類的重定義細節。

// class_count - pre-checked to be greater than or equal to 0  // class_definitions - pre-checked for NULL  jvmtiError JvmtiEnv::RedefineClasses(jint class_count, const jvmtiClassDefinition* class_definitions) {  //TODO: add locking    VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_redefine);    VMThread::execute(&op);    return (op.check_error());  } /* end RedefineClasses */

重定義類的請求會被JVM包裝成一個VM_RedefineClasses類型的VM_Operation,VM_Operation是JVM內部的一些操作的基類,包括GC操作等。VM_Operation由VMThread來執行,新的VM_Operation操作會被添加到VMThread的運行隊列中去,VMThread會不斷從隊列裡面拉取VM_Operation並調用其doit等函數執行具體的操作。VM_RedefineClasses函數的流程較為複雜,下面是VM_RedefineClasses的大致流程:

  • 載入新的位元組碼,合併常量池,並且對新的位元組碼進行校驗工作
  // Load the caller's new class definition(s) into _scratch_classes.    // Constant pool merging work is done here as needed. Also calls    // compare_and_normalize_class_versions() to verify the class    // definition(s).    jvmtiError load_new_class_versions(TRAPS);
  • 清除方法上的斷點
  // Remove all breakpoints in methods of this class    JvmtiBreakpoints& jvmti_breakpoints = JvmtiCurrentBreakpoints::get_jvmti_breakpoints();    jvmti_breakpoints.clearall_in_class_at_safepoint(the_class());
  • JIT逆優化
  // Deoptimize all compiled code that depends on this class    flush_dependent_code(the_class, THREAD);
  • 進行位元組碼替換工作,需要進行更新類itable/vtable等操作
  • 進行類重定義通知
 SystemDictionary::notice_modification();

VM_RedefineClasses實現比較複雜的,詳細實現可以參考 RedefineClasses的實現

4. Java-debug-tool設計與實現

Java-debug-tool是一個使用Java Instrument API來實現的動態調試工具,它通過在目標JVM上啟動一個TcpServer來和調試客戶端通訊。調試客戶端通過命令行來發送調試命令給TcpServer,TcpServer中有專門用來處理命令的handler,handler處理完命令之後會將結果發送回客戶端,客戶端通過處理將調試結果展示出來。下面將詳細介紹Java-debug-tool的整體設計和實現。

4.1 Java-debug-tool整體架構

Java-debug-tool包括一個Java Agent和一個用於處理調試命令的核心API,核心API通過一個自定義的類載入器載入進來,以保證目標JVM的類不會被污染。整體上Java-debug-tool的設計是一個Client-Server的架構,命令客戶端需要完整的完成一個命令之後才能繼續執行下一個調試命令。Java-debug-tool支援多人同時進行調試,下面是整體架構圖:

圖4-1-1

下面對每一層做簡單介紹:

  • 交互層:負責將程式設計師的輸入轉換成調試交互協議,並且將調試資訊呈現出來。
  • 連接管理層:負責管理客戶端連接,從連接中讀調試協議數據並解碼,對調試結果編碼並將其寫到連接中去;同時將那些超時未活動的連接關閉。
  • 業務邏輯層:實現調試命令處理,包括命令分發、數據收集、數據處理等過程。
  • 基礎實現層:Java-debug-tool實現的底層依賴,通過Java Instrumentation提供的API進行類查找、類重定義等能力,Java Instrumentation底層依賴JVMTI來完成具體的功能。

在Agent被掛載到目標JVM上之後,Java-debug-tool會安排一個Spy在目標JVM內活動,這個Spy負責將目標JVM內部的相關調試數據轉移到命令處理模組,命令處理模組會處理這些數據,然後給客戶端返回調試結果。命令處理模組會增強目標類的位元組碼來達到數據獲取的目的,多個客戶端可以共享一份增強過的位元組碼,無需重複增強。下面從Java-debug-tool的位元組碼增強方案、命令設計與實現等角度詳細說明。

4.2 Java-debug-tool的位元組碼增強方案

Java-debug-tool使用位元組碼增強來獲取到方法運行時的資訊,比如方法入參、出參等,可以在不同的位元組碼位置進行增強,這種行為可以稱為「插樁」,每個「樁」用於獲取數據並將他轉儲出去。Java-debug-tool具備強大的插樁能力,不同的樁負責獲取不同類別的數據,下面是Java-debug-tool目前所支援的「樁」:

  • 方法進入點:用於獲取方法入參資訊。
  • Fields獲取點1:在方法執行前獲取到對象的欄位資訊。
  • 變數存儲點:獲取局部變數資訊。
  • Fields獲取點2:在方法退出前獲取到對象的欄位資訊。
  • 方法退出點:用於獲取方法返回值。
  • 拋出異常點:用於獲取方法拋出的異常資訊。

通過上面這些程式碼樁,Java-debug-tool可以收集到豐富的方法執行資訊,經過處理可以返回更加可視化的調試結果。

4.2.1 位元組碼增強

Java-debug-tool在實現上使用了ASM工具來進行位元組碼增強,並且每個插樁點都可以進行配置,如果不想要什麼資訊,則沒必要進行對應的插樁操作。這種可配置的設計是非常有必要的,因為有時候我們僅僅是想要知道方法的入參和出參,但Java-debug-tool卻給我們返回了所有的調試資訊,這樣我們就得在眾多的輸出中找到我們所關注的內容。如果可以進行配置,則除了入參點和出參點外其他的樁都不插,那麼就可以快速看到我們想要的調試數據,這種設計的本質是為了讓調試者更加專註。下面是Java-debug-tool的位元組碼增強工作方式:

圖4-2-1

如圖4-2-1所示,當調試者發出調試命令之後,Java-debug-tool會識別命令並判斷是否需要進行位元組碼增強,如果命令需要增強位元組碼,則判斷當前類+當前方法是否已經被增強過。上文已經提到,位元組碼替換是有一定損耗的,這種具有損耗的操作發生的次數越少越好,所以位元組碼替換操作會被記錄起來,後續命令直接使用即可,不需要重複進行位元組碼增強,位元組碼增強還涉及多個調試客戶端的協同工作問題,當一個客戶端增強了一個類的位元組碼之後,這個客戶端就鎖定了該位元組碼,其他客戶端變成只讀,無法對該類進行位元組碼增強,只有當持有鎖的客戶端主動釋放鎖或者斷開連接之後,其他客戶端才能繼續增強該類的位元組碼。

位元組碼增強模組收到位元組碼增強請求之後,會判斷每個增強點是否需要插樁,這個判斷的根據就是上文提到的插樁配置,之後位元組碼增強模組會生成新的位元組碼,Java-debug-tool將執行位元組碼替換操作,之後就可以進行調試數據收集了。

經過位元組碼增強之後,原來的方法中會插入收集運行時數據的程式碼,這些程式碼在方法被調用的時候執行,獲取到諸如方法入參、局部變數等資訊,這些資訊將傳遞給數據收集裝置進行處理。數據收集的工作通過Advice完成,每個客戶端同一時間只能註冊一個Advice到Java-debug-tool調試模組上,多個客戶端可以同時註冊自己的Advice到調試模組上。Advice負責收集數據並進行判斷,如果當前數據符合調試命令的要求,Java-debug-tool就會卸載這個Advice,Advice的數據就會被轉移到Java-debug-tool的命令結果處理模組進行處理,並將結果發送到客戶端。

4.2.2 Advice的工作方式

Advice是調試數據收集器,不同的調試策略會對應不同的Advice。Advice是工作在目標JVM的執行緒內部的,它需要輕量級和高效,意味著Advice不能做太過於複雜的事情,它的核心介面「match」用來判斷本次收集到的調試數據是否滿足調試需求。如果滿足,那麼Java-debug-tool就會將其卸載,否則會繼續讓他收集調試數據,這種「載入Advice」 -> 「卸載Advice」的工作模式具備很好的靈活性。

關於Advice,需要說明的另外一點就是執行緒安全,因為它載入之後會運行在目標JVM的執行緒中,目標JVM的方法極有可能是多執行緒訪問的,這也就是說,Advice需要有能力處理多個執行緒同時訪問方法的能力,如果Advice處理不當,則可能會收集到雜亂無章的調試數據。下面的圖片展示了Advice和Java-debug-tool調試分析模組、目標方法執行以及調試客戶端等模組的關係。

圖4-2-2

Advice的首次掛載由Java-debug-tool的命令處理器完成,當一次調試數據收集完成之後,調試數據處理模組會自動卸載Advice,然後進行判斷,如果調試數據符合Advice的策略,則直接將數據交由數據處理模組進行處理,否則會清空調試數據,並再次將Advice掛載到目標方法上去,等待下一次調試數據。非首次掛載由調試數據處理模組進行,它藉助Advice按需取數據,如果不符合需求,則繼續掛載Advice來獲取數據,否則對調試數據進行處理並返回給客戶端。

4.3 Java-debug-tool的命令設計與實現

4.3.1 命令執行

上文已經完整的描述了Java-debug-tool的設計以及核心技術方案,本小節將詳細介紹Java-debug-tool的命令設計與實現。首先需要將一個調試命令的執行流程描述清楚,下面是一張用來表示命令請求處理流程的圖片:

圖4-3-1

圖4-3-1簡單的描述了Java-debug-tool的命令處理方式,客戶端連接到服務端之後,會進行一些協議解析、協議認證、協議填充等工作,之後將進行命令分發。服務端如果發現客戶端的命令不合法,則會立即返回錯誤資訊,否則再進行命令處理。命令處理屬於典型的三段式處理,前置命令處理、命令處理以及後置命令處理,同時會對命令處理過程中的異常資訊進行捕獲處理,三段式處理的好處是命令處理被拆成了多個階段,多個階段負責不同的職責。前置命令處理用來做一些命令許可權控制的工作,並填充一些類似命令處理開始時間戳等資訊,命令處理就是通過位元組碼增強,掛載Advice進行數據收集,再經過數據處理來產生命令結果的過程,後置處理則用來處理一些連接關閉、位元組碼解鎖等事項。

Java-debug-tool允許客戶端設置一個命令執行超時時間,超過這個時間則認為命令沒有結果,如果客戶端沒有設置自己的超時時間,就使用默認的超時時間進行超時控制。Java-debug-tool通過設計了兩階段的超時檢測機制來實現命令執行超時功能:首先,第一階段超時觸發,則Java-debug-tool會友好的警告命令處理模組處理時間已經超時,需要立即停止命令執行,這允許命令自己做一些現場清理工作,當然需要命令執行執行緒自己感知到這種超時警告;當第二階段超時觸發,則Java-debug-tool認為命令必須結束執行,會強行打斷命令執行執行緒。超時機制的目的是為了不讓命令執行太長時間,命令如果長時間沒有收集到調試數據,則應該停止執行,並思考是否調試了一個錯誤的方法。當然,超時機制還可以定期清理那些因為未知原因斷開連接的客戶端持有的調試資源,比如位元組碼鎖。

4.3.4 獲取方法執行視圖

Java-debug-tool通過下面的資訊來向調試者呈現出一次方法執行的視圖:

  • 正在調試的方法資訊。
  • 方法調用堆棧。
  • 調試耗時,包括對目標JVM造成的STW時間。
  • 方法入參,包括入參的類型及參數值。
  • 方法的執行路徑。
  • 程式碼執行耗時。
  • 局部變數資訊。
  • 方法返回結果。
  • 方法拋出的異常。
  • 對象欄位值快照。

圖4-3-2展示了Java-debug-tool獲取到正在運行的方法的執行視圖的資訊。

圖4-3-2

4.4 Java-debug-tool與同類產品對比分析

Java-debug-tool的同類產品主要是greys,其他類似的工具大部分都是基於greys進行的二次開發,所以直接選擇greys來和Java-debug-tool進行對比。

5. 總結

本文詳細剖析了Java動態調試關鍵技術的實現細節,並介紹了我們基於Java動態調試技術結合實際故障排查場景進行的一點探索實踐;動態調試技術為研發人員進行線上問題排查提供了一種新的思路,我們基於動態調試技術解決了傳統斷點調試存在的問題,使得可以將斷點調試這種技術應用在線上,以線下調試的思維來進行線上調試,提高問題排查效率。

6. 參考文獻

7. 作者簡介

胡健,美團到店餐飲研發中心研發工程師。

———- END ———-