字节码Java 动态调试技术原理及实践

Posted 九师兄

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字节码Java 动态调试技术原理及实践相关的知识,希望对你有一定的参考价值。

1.概述

转载:Java 动态调试技术原理及实践

一、动态调试要解决的问题

断点调试是我们最常使用的调试手段,它可以获取到方法执行过程中的变量信息,并可以观察到方法的执行路径。但断点调试会在断点位置停顿,使得整个应用停止响应。在线上停顿应用是致命的,动态调试技术给了我们创造新的调试模式的想象空间。本文将研究Java语言中的动态调试技术,首先概括Java动态调试所涉及的技术基础,接着介绍我们在Java动态调试领域的思考及实践,通过结合实际业务场景,设计并实现了一种具备动态性的断点调试工具Java-debug-tool,显著提高了故障排查效率。

二、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:[=]“,具体的使用方法可以使用“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, 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::以上是关于字节码Java 动态调试技术原理及实践的主要内容,如果未能解决你的问题,请参考以下文章

Java 虚拟机原理动态字节码技术 | Dalvik & ART 虚拟机 | Android 字节码打包过程

字节码JVM CPU Profiler技术原理及源码深度解析

java基本功 | Java即时编译器原理解析及实践

CGLib动态代理原理及实现

CGLib动态代理原理及实现

CGLib动态代理原理及实现