转自云栖社区:https://yq.aliyun.com/articles/56?spm=5176.100239.blogcont59193.11.jOh3ZG#
摘要: 该文章来自于阿里巴巴技术协会(ATA)精选文章。 Java调试概述 程序猿都调式或者debug过Java代码吧?都体会过被PM,PD,测试,业务同学们围观debug吧?说调试,先看看调试严格定义是什么。引用Wikipedia定义: 调试(De-bug),又称除错,是发现和减少计
该文章来自于阿里巴巴技术协会(ATA)精选文章。
Java调试概述
程序猿都调式或者debug过Java代码吧?都体会过被PM,PD,测试,业务同学们围观debug吧?说调试,先看看调试严格定义是什么。引用Wikipedia定义:
调试(De-bug),又称除错,是发现和减少计算机程序或电子仪器设备中程序错误的一个过程。调试的基本步骤:
1. 发现程序错误的存在
2. 以隔离、消除的方式对错误进行定位
3. 确定错误产生的原因
4. 提出纠正错误的解决办法
5. 对程序错误予以改正,重新测试
用调试的好处是我们就无需每次新测试都要重新编译了,不用copy-paste一堆的System.out.println(很low但很多时候很管用有没有?)。
更多时候我们调试最直接简单的办法就是IDE,Java程序员用的最多的必然是Eclipse,Netbeans和IntelliJ也有各自忠实的粉丝,各有优劣。关于用IDE如何调试可以另起一个话题再讨论。
除了IDE之外,JDK也自带了一些命令行调试工具也很方便。大家用的比较多的如下表所示:
命令 | 描述 |
---|---|
jdb | 命令行调试工具 |
jps | 列出所有Java进程的PID |
jstack | 列出虚拟机进程的所有线程运行状态 |
jmap | 列出堆内存上的对象状态 |
jstat | 记录虚拟机运行的状态,监控性能 |
jconsole | 虚拟机性能/状态检查可视化工具 |
具体用法可以参考JDK文档,这些大家在线上调试应用的时候用的也不少,比如一般线上load高的问题排查步骤是
- 先用top找到耗资源的进程
- ps+grep找到对应的java进程/线程
- jstack分析哪些线程阻塞了,阻塞在哪里
- jstat看看FullGC频率
- jmap看看有没有内存泄露
但这个也不是今天的重点,那么问题来了(blue fly is the strongest):这些工具如何能获取远程Java进程的信息的?又是如何远程控制Java进程的运行的? 相信有不少人和我一样对这些工具的 实现原理 很好奇,本文就尝试介绍下各中缘由。
Java调试体系JPDA简介
Java虚拟机设计了专门的API接口供调试和监控虚拟机使用,被称为Java平台调试体系即Java Platform Debugger Architecture(JPDA)。JPDA按照抽象层次,又分为三层,分别是
- JVM TI - Java VM Tool Interface
- 虚拟机对外暴露的接口,包括debug和profile
- JDWP - Java Debug Wire Protocol
- 调试器和应用之间通信的协议
- JDI - Java Debug Interface
- Java库接口,实现了JDWP协议的客户端,调试器可以用来和远程被调试应用通信
用一个不是特别准确但是比较容易理解的类比,大家可以和HTTP做比较,可以推断他就是一个典型的C/S应用,所以也可以很自然的想到,JDI是用TCP Socket和虚拟机通信的,后面会详细再介绍。
- IDE+JDI = 浏览器
- JDWP = HTTP
- JVMTI = RESTful接口
- Debugee虚拟机= REST服务端
和其他的Java模块一样,Java只定义了Spec规范,也提供了参考实现(Reference Implementation),但是第三方完全可以参照这个规范,按照自己的需要去实现其中任意一个组件,原则上除了规范上没有定义的功能,他们应该能正常的交互,比如Eclipse就没有用Sun/Oracle的JDI,而是自己实现了一套(由于开源license的兼容原因),因为直接用JDWP协议调用JVMTI是不会受GPL“污染”的。的确有第三方调试工具基于JVMTI做了一套调试工具,这样效率更高,功能更丰富,因为JDI出于远程调用的安全考虑,做了一些功能的限制。用户还可以不用JDI,用自己熟悉的C或者脚本语言开发客户端,远程调试Java虚拟机,所以JPDA真个架构是非常灵活的。
JVMTI
JVMTI是整个JPDA中最中要的API,也是虚拟机对外暴露的接口,掌握了JVMTI,你就可以真正完全掌控你的虚拟机,因为必须通过本地加载,所以暴露的丰富功能在安全上也没有太大问题。更完整的API内容可以参考JVMTI SPEC:
- 虚拟机信息
- 堆上的对象
- 线程和栈信息
- 所有的类信息
- 系统属性,运行状态
- 调试行为
- 设置断点
- 挂起现场
- 调用方法
- 事件通知
- 断点发生
- 异步调用
在JPDA的这个图里,agent是其中很重要的一个模块,正是他把JDI,JDWP,JVMTI三部分串联成了一个整体。简单来说agent的特性有
- C/C++实现的
- 被虚拟机以动态库的方式加载
- 能调用本地JVMTI提供的调试能力
- 实现JDWP协议服务器端
- 与JDI(作为客户端)通信(socket/shmem等方式)
Code speak louder than words. 上个代码加注释来解释:
// Agent_OnLoad必须是入口函数,类似于main函数,规范规定
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved)
{
....
MethodTraceAgent* agent = new MethodTraceAgent();
agent->Init(vm);
agent->AddCapability();
agent->RegisterEvent();
...
}
/****** AddCapability(): init(): 初始化jvmti函数指针,所有功能的函数入口 *****/
jvmtiEnv* MethodTraceAgent::m_jvmti = 0;
jint ret = (vm)->GetEnv(reinterpret_cast<void**>(&jvmti), JVMTI_VERSION_1_0);
/****** AddCability(): 确认agent能访问的虚拟机接口 *****/
jvmtiCapabilities caps;
memset(&caps, 0, sizeof(caps));
caps.can_generate_method_entry_events = 1;
// 设置当前环境
m_jvmti->AddCapabilities(&caps);
/****** RegisterEvent(): 创建一个新的回调函数 *****/
jvmtiEventCallbacks callbacks;
memset(&callbacks, 0, sizeof(callbacks));
callbacks.MethodEntry = &MethodTraceAgent::HandleMethodEntry;
// 设置回调函数
m_jvmti->SetEventCallbacks(&callbacks, static_cast<jint>(sizeof(callbacks)));
// 开启事件监听
m_jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_METHOD_ENTRY, 0);
/****** HandleMethodEntry: 注册的回调,获取对应的信息 *****/
// 获得方法对应的类
m_jvmti->GetMethodDeclaringClass(method, &clazz);
// 获得类的签名
m_jvmti->GetClassSignature(clazz, &signature, 0);
// 获得方法名字
m_jvmti->GetMethodName(method, &name, NULL, NULL);
写好agent后,需要编译,并在启动Java进程时指定加载路径
// 编译动态链接库
g++ -w -I${JAVA_HOME}/include/ -I${JAVA_HOME}/include/linux MethodTraceAgent.cpp Main.cpp -fPIC -shared -o libAgent.so
// 拷贝到 LD_LIBRARY_PATH
export LD_LIBRARY_PATH=/home/xiaoxia/lib
cp libAgent.so ~/lib
// 运行测试效果,记得load编译的动态库
javac MethodTraceTest.java
java -agentlib:Agent=first MethodTraceTest
Agent实现的动态链接库其实有两种加载方式:
- 虚拟机启动初期加载 这个链接库必须实现Agent_OnLoad作为函数入口。这种方式可以利用的接口和功能更多,因为他在被调式虚拟机运行的应用初始化之前就被调用了,但是限制是必须以显示的参数指定启动方式,这在线上环境上是不大现实的。
java -agentlib:<agent-lib-name>=<options> JavaClass
//Linux从LD_LIBRARY_PATH找so文件, Windows从PATH找该DLL文件。
java -agentpath:<path-to-agent>=<options> JavaClass
//直接从绝对路径查找
- 动态加载 这是更灵活的方式,Java进程可以正常启动,如果需要,通过Sun/Orale提供的私有Attach API可以连上对应的虚拟机,再通过JPDA方式控制,不过因为虚拟机已经开始运行了,所以功能上会有限制。我们比较熟悉的jstack等jdk工具就是通过这种方式做的,动态库必须实现Agent_OnAttach作为函数入口。如果有兴趣理解Attach机制细节的话,可以参考这个blog,简单来说,就是虚拟机默认起了一个线程(没错,就是jstack时看到Signal Dispatcher这货),专门接受处理进程间singal通知,当他收到SIGQUIT时,就会启动一个新的socket监听线程(就是jstack看到的Attach Listener线程)来接收命令,Attach Listener就是一个agent实现,他能处理很多dump命令,更重要的是他能再加载其他agent,比如jdwp agent。
通过Attach机制,我们能自己非常方便的实现一个jinfo或者其他jdk tools,只需通过JPS获取pid,在通过attach api去load我们提供的agent,完整的jinfo例子也在附件里。
import java.io.IOException;
import com.sun.tools.attach.VirtualMachine;
public class JInfo {
public static void main(String[] args) throws Exception {
String pid = args[0];
String agentName = "JInfoAgent";
System.out.printf("Atach to Pid %s, dynamic load agent %s
", pid, agentName);
VirtualMachine virtualMachine = com.sun.tools.attach.VirtualMachine.attach(pid);
virtualMachine.loadAgentLibrary(agentName, null);
virtualMachine.detach();
}
}
JDWP
JDWP 是 Java Debug Wire Protocol 的缩写,它定义了调试器(debugger)和被调试的 Java 虚拟机(debugee)之间的通信协议。他就是同过JVMTI Agent实现的,简单来说,他就是对JVMTI调用(输入和输出,事件)的通信定义。
JDWP 有两种基本的包(packet)类型:命令包(command packet)和回复包(reply packet)。JDWP 本身是无状态的,因此对 命令出现的顺序并不受限制。而且,JDWP 可以是异步的,所以命令的发送方不需要等待接收到回复就可以继续发送下一个命令。Debugger 和 Debugee 虚拟机都有可能发送命令:
- Debugger 通过发送命令获取Debugee虚拟机的信息以及控制程序的执行。Debugger虚拟机通过发送 命令通知 Debugger 某些事件的发生,如到达断点或是产生异常。
- 回复是用来确认对应的命令是否执行成功(在包定义有一个flag字段对应),如果成功,回复还有可能包含命令请求的数据,比如当前的线程信息或者变量的值。从 Debugee虚拟机发送的事件消息是不需要回复的。
下图展示了一个可能的实现方式,再次强调下,Java的世界里只定义了规范(Spec),很多实现细节可以自己提供,比如虚拟机就有很多中实现(Sun HotSpot,IBM J9,Google Davik)。
一般我们启动远程调试时,都会看到如下参数,其实表面了JDWP Agent就是通过启动一个socket监听来接受JDWP命令和发送事件信息的,而且,这个TCP连接可以是双向的:
// debugge是server先启动监听,ide是client发起连接
agentlib:jdwp=transport=dt_socket,server=y,address=8000
// debugger ide是server,通过JDI监听,JDWP Agent作为客户端发起连接
agentlib:jdwp=transport=dt_socket,address=myhost:8000
JDI
JDI属于JPDA中最上层接口,也是Java程序员接触的比较多的。他用起来也比较简单,参考JDI的API Doc即可。所有的功能都和JVMTI提供的调试功能一一对应的(JVMTI还包括很多非调式接口,JDK5以前JVMTI是分为JVMDI和JVMPI的,分别对应调试debug和调优profile)。
还是用一个例子来解释最直接,大家可以看到基本的流程都是类似的,真个JPDA调试的核心就是通过JVMTI的 调用 和事件 两个方向的沟通实现的。
import java.util.List;
import java.util.Map;
import com.sun.jdi.*;
import com.sun.jdi.connect.*;
import com.sun.jdi.event.*;
import com.sun.jdi.request.*;
public class MethodTrace {
private VirtualMachine vm;
private Process process;
private EventRequestManager eventRequestManager;
private EventQueue eventQueue;
private EventSet eventSet;
private boolean vmExit = false;
//write your own testclass
private String className = "MethodTraceTest";
public static void main(String[] args) throws Exception {
MethodTrace trace = new MethodTrace();
trace.launchDebugee();
trace.registerEvent();
trace.processDebuggeeVM();
// Enter event loop
trace.eventLoop();
trace.destroyDebuggeeVM();
}
public void launchDebugee() {
LaunchingConnector launchingConnector = Bootstrap
.virtualMachineManager().defaultConnector();
// Get arguments of the launching connector
Map<String, Connector.Argument> defaultArguments = launchingConnector
.defaultArguments();
Connector.Argument mainArg = defaultArguments.get("main");
Connector.Argument suspendArg = defaultArguments.get("suspend");
// Set class of main method
mainArg.setValue(className);
suspendArg.setValue("true");
try {
vm = launchingConnector.launch(defaultArguments);
} catch (Exception e) {
// ignore
}
}
public void processDebuggeeVM() {
process = vm.process();
}
public void destroyDebuggeeVM() {
process.destroy();
}
public void registerEvent() {
// Register ClassPrepareRequest
eventRequestManager = vm.eventRequestManager();
MethodEntryRequest entryReq = eventRequestManager.createMethodEntryRequest();
entryReq.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
entryReq.addClassFilter(className);
entryReq.enable();
MethodExitRequest exitReq = eventRequestManager.createMethodExitRequest();
exitReq.addClassFilter(className);
exitReq.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
exitReq.enable();
}
private void eventLoop() throws Exception {
eventQueue = vm.eventQueue();
while (true) {
if (vmExit == true) {
break;
}
eventSet = eventQueue.remove();
EventIterator eventIterator = eventSet.eventIterator();
while (eventIterator.hasNext()) {
Event event = (Event) eventIterator.next();
execute(event);
if (!vmExit) {
eventSet.resume();
}
}
}
}
private void execute(Event event) throws Exception {
if (event instanceof VMStartEvent) {
System.out.println("VM started");
} else if (event instanceof MethodEntryEvent) {
Method method = ((MethodEntryEvent) event).method();
System.out.printf("Enter -> Method: %s, Signature:%s
",method.name(),method.signature());
System.out.printf(" ReturnType:%s
", method.returnTypeName());
} else if (event instanceof MethodExitEvent) {
Method method = ((MethodExitEvent) event).method();
System.out.printf("Exit -> method: %s
",method.name());
} else if (event instanceof VMDisconnectEvent) {
vmExit = true;
}
}
}
总结
整个JDPA有非常清晰的分层,各司其职,让整个调式过程简单可以扩展,而这一切其实都是构建在高司令巨牛逼的Java虚拟机抽象之上的,通过JVMTI将抽象良好的虚拟机控制暴露出来,让开发者可以自由的掌控被调试的虚拟机。有兴趣的同学可以运行下附近中的几个例子,应该会有更充分的了解。
而且由于规范的灵活性,如果有特殊需求,完全可以自己去重新实现和扩展,而且不限于Java,举个例子,我们可以通过agent去加密解密加载的类,保护知识产权;我们可以记录虚拟机运行过程,作为自动化测试用例; 我们还可以把线上问题的诊断实践自动化下来,做一个快速预判 ,争取最宝贵的时间。