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

調試是發現和減少計算機程序或電子儀器設備中程序錯誤的一個過程。最常用的斷點調試技術會在斷點位置停頓,導致應用停止響應。本文將介紹一種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,那麼可以選擇實現下面的方法:

[1public static void premain(String agentArgs, Instrumentation inst);
[2public static void premain(String agentArgs);
JVM將首先尋找[1],如果沒有發現[1],再尋找[2]。如果希望在目標JVM運行時加載Agent,則需要實現下面的方法:
[1public static void agentmain(String agentArgs, Instrumentation inst);
[2public 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] = '\0';
        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 VM\n");
          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 VM\n");
      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, 0sizeof(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

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

這段代碼截取自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這個函數的實現比較複雜,代碼很長。下面是一段關鍵的代碼片段:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

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

// 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支持多人同時進行調試,下面是整體架構圖:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

圖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的字節碼增強工作方式:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

圖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調試分析模塊、目標方法執行以及調試客戶端等模塊的關係。

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

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

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

4.3.1 命令執行

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

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

圖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獲取到正在運行的方法的執行視圖的信息。

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

圖4-3-2

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

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

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

5. 總結

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

6. 參考文獻

點擊關注,互聯網架構師

圖片.png


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章