简介
断点调试是我们最常使用的调试手段,它可以获取到方法执行过程中的变量信息,并可以观察到方法的执行路径。但断点调试会在断点位置停顿,使得整个应用停止响应。在线上停顿应用是致命的,动态调试技术给了我们创造新的调试模式的想象空间。本文将研究Java语言中的动态调试技术,首先概括Java动态调试所涉及的技术基础,接着介绍我们在Java动态调试领域的思考及实践,通过结合实际业务场景,设计并实现了一种具备动态性的断点调试工具Java-debug-tool,显著提高了故障排查效率。
JVMTI (JVM Tool Interface)是Java虚拟机对外提供的Native编程接口,通过JVMTI,外部进程可以获取到运行时JVM的诸多信息,比如线程、GC等。Agent是一个运行在目标JVM的特定程序,它的职责是负责从目标JVM中获取数据,然后将数据传递给外部进程。加载Agent的时机可以是目标JVM启动之时,也可以是在目标JVM运行时进行加载,而在目标JVM运行时进行Agent加载具备动态性,对于时机未知的Debug场景来说非常实用。下面将详细分析Java Agent技术的实现细节。
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的方法。
通过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上的具体技术细节,将在下文中进行分析。
启动时加载Agent
参数解析
创建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的。
执行加载操作
在创建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/目录下。
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实例来做我们想要做的事情了。
运行时加载Agent
比起JVM启动时加载Agent,运行时加载Agent就比较有诱惑力了,因为运行时加载Agent的能力给我们提供了很强的动态性,我们可以在需要的时候加载Agent来进行一些工作。因为是动态的,我们可以按照需求来加载所需要的Agent,下面来分析一下动态加载Agent的相关技术细节。