字节码Java 动态调试技术原理及实践
Posted 九师兄
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字节码Java 动态调试技术原理及实践相关的知识,希望对你有一定的参考价值。
1.概述
一、动态调试要解决的问题
断点调试是我们最常使用的调试手段,它可以获取到方法执行过程中的变量信息,并可以观察到方法的执行路径。但断点调试会在断点位置停顿,使得整个应用停止响应。在线上停顿应用是致命的,动态调试技术给了我们创造新的调试模式的想象空间。本文将研究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 AP
I来编写的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 字节码打包过程