如何编写属于自己的Java / Scala的调试器

译者:赖辉强  原文地址

在本帖中,我们将探讨Java和Scala的调试器是如何编写和工作的;系统自带的调试器,例如:Windows中的WinDbg或者是Linux/Unix中的gdb,会获取操作系统直接提供给他们的链接入口来启动,从而指导和操作外部程序的状态。工作在操作系统顶部抽象层的Java虚拟机对字节码的调试有独立的处理架构。

这个调试的框架和APIs具有全开源、文档化、可扩展的特点,这意味着你可以轻松毫无顾忌的编写自己的调试器。该框架当前的设计由两大部分构成—JDWP协议和JVMTI API层。其中每个都有一系列使性能最佳的优点和使用案例。(你也可以阅读这篇文档:深入 JAVA 调试体系

JDWP协议

JDWP(Java Debugger Wire Protocol)是用来在调试和被调试程序之间通过二进制信息来传递请求和接收事件的(例如:线程中的状态或者异常的变化),这些活动通常是网络上进行。这个架构背后的理念是在两个程序之间尽可能的解耦。旨在减少由编译器更改目标代码在运行期的执行所带来的海森堡效应(Werner是位德国物理学家,不是你喜欢的那个厨师Werner)。

从目标程序中移除多数调试逻辑操作,对检测被调试的虚拟机中状态的改变会有所帮助(例如:GC or OutOfMemoryErrors),这些逻辑是不会调试本身的。为了更加简便,JDK自带了JDI(java调试接口),该接口提供了全面的调试的协议实现,以及对一个目标虚拟机状态的完备的操作能力,包括:连接、断开、指导、处理。

Eclipse的编译器使用的就是JDWP协议,IDE( Integrated Development Environment )调试JAVA程序时,如果你查看当时传递给该程序的命令行参数,你会发现Eclipse会传递额外的参数(-agentlib:jdwp=transport=dt_socket,…)给程序来启动java虚拟机调试,同时也将确定发送请求和事件的端口。

JVMTI编程接口

一系列的原生API是现代JVM中的第二个关键组件,这些API涵盖了广泛关于JVM操作的领域,其中为人所熟知的是 JVM Tooling Interface (i.e. JVMTI)。与JDWP不同的是,JVMTI设计时提供了一系列C/C++ 版的API和一种为JVM动态地加载预编译的库文件(如:.dull等)的机制,而这些库文件会使用由API提供的命令。

JVMTI的使用方式不同于JDWP,实际上,它是在目标程序内执行编译器。这种方式使调试器同时在性能和稳定性方面改善程序代码更加得心应手。然而,最关键的优势是这样一种能够几乎是实时直接地和JVM交互的能力。

从JVMTI提供了一系列功能强、易入门的API中可以看出,JVMTI乐于去深入探究并分析自身的工作原理以及同通过用该些API所能完成的功能。你可以从JDK自带的JVMTI中获取API标头。

编写调试器类库

编写自己的调试器需要用C++创建本地的操作系统类库。你的主方法应该如下:

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm,char*options,void*)

当JVM加载调试代理器的同时,它会调用该方法。传递的Java虚拟机指针是至关重要的,它会提供所有你需要跟JVM打交道的砝码。该指针可以从java虚拟机中引入jvmtiEnv类;你可以使用GetEnv方法利用capabilities (特性)和events(事件)的概念与JVMTI层进行交互。

JVMTI 特性

编写调试器时,最关键的一方面是你对在目标程序中的调试器代码的功能有清晰的认识,特别是运行代码的本地调试器类库和运行程序联系紧密时。为了你更好的控制你的调试器去影响代码的执行,因此JVMTI详解中引入了capabilities(特性)的概念。

当编写自己的编译器时,你可以事先通知java虚拟机你一系列打算使用的API命令或者事件(例如:设置断点、中断线程)。这能够使JVM可以预先为这些命令或者事件做好准备,同时,让你更好的掌控调试器运行期的开销。这种方式也使得出自不同制造商的JVM能够以程序设计的方式告诉你那些API的命令可以在整个JVMTI详解中得以支持。

特性的性能是大不相同的。有些特性使用的性能开销较低,但是有些较有意思的特性则是相反,例如:在代码中抛出异常来接收回调的特性—can_generate_exception_events或者是需要加锁来接收回调的特性—can_generate_monitor_events。原因在于这些特性会在 JIT全范围的编译时阻碍JVM优化代码,与此同时,迫使JVM在运行期降到解释模式。

其它一些特性,如:每当设定一个目标对象域时,用来接收通知的特性—can_generate_field_modification_events,会产生更大的开销,导致代码运行极慢。尽管JVM支持同时加载多个本地类库,遗憾是一些 HotSpot的特性,如:用来挂起和唤醒线程的特性—can_suspend,只能每次地被一个类库调用。

当我们搭建Takipi’s production debugger时,我们需攻坚的问题之一是提供类似的特性且不能引起大的开销(在之后的版本中更是这样)。

设置回调。一旦你接收到一系列的特性后,你随即设置好会被JVM调用的回调,这会让你知道实际发生过的操作。每个回调都将会完全地提供关于已经发生过的事件的深层次信息。举个例子,一则异常回调信息会包括抛出异常的字节码位置、线程、异常对象、异常是否将被捕获以及将被捕获的位置。

voidJNICALL ExceptionCallback(jvmtiEnv *jvmti,JNIEnv *jni, jthread thread, jmethodID method,

jlocation location, jobject exception,jmethodID catch_method, jlocation catch_location)

值得注意的是特性的开销通常分为两个部分,第一部分开销来自驱动它工作,因为它需要使JIT编译器不同地编译事务,从而能够访问代码。另外一部分来自当你启用一个回调功能时,此时这会引起JVM在执行期选择低性能的执行路径,通过这些路径,特性可以访问代码,期间压缩和传递重要数据会产生额外的开销

断点和检查。你的编译器能够提供熟知的用来检查在运行期所处的特定状态的特性,如:SetBreakpoint,通知JVM通过某个具体的字节码来中断执行,或者每当某个区域更改时,通过设置SetFieldModificationWatch中断执行。针对这点,你可以使用其它一些补充性的功能,例如:GetStackTrace 和GetThreadInfo ,用来知晓当前你所在代码中的位置并报告当前位置。

大多数JVMTI 的功能都涉及到一些使用抽象处理的类和方法,较为熟知的是jmethodID 和 jclass(如果你曾经编写过java本地接口代码,这是)。其中也提供了额外的一些功能,如:GetMethodName 和 GetClassSignature,来帮助你从类常量池中获取实际的符号名。之后,你就可以使用这些符号名以可读的方式记录为日志文件或者以界面的方式展示它们,就如同日常在IDE中所见的一样。

连接调试器

一旦你已经开始编写调试器类库,你的下一步任务就是将它连接到JVM上,下面是几种连接的方式:

1. 连接JDWP。倘若你编写的是一个以JDWP为基础的调试器,你需要向被调试对象增加一个启动参数,就可以在线上进行调试,该参数的形式如下:agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:<port>。这些参数详细反映了调试器和目标程序之间传递信息的方式,以及在挂起模式中是否启用被调试对象。

2. 连接JVMTI 类库.通过传递给被调试程序的代理路径命令行,同时指向类库所在硬盘上的位置,此时,JVM将会加载JVMTI类库。

另外一种可行的方式是:将命令行参数追加到全局环境变量JAVA_TOOL_OPTIONS 后面,每个新的JVM会接收该变量,并且该变量的值会自动地追加到现存参数列表之后。

3. 远程连接.还有一种通过使用远程连接API来连接调试器的方式,使用这个简单而功能强大的API能够在没有使用任何命令行参数来开始程序的情况下连接代理器来运行JVM程序。这个的不足在于你不能获取通常本可以获得的特性,如:can_generate_exception_events,因为这些特性只能在虚拟机启动时获取。

原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: 如何编写属于自己的Java / Scala的调试器


FavoriteLoading添加本文到我的收藏
  • Trackback 关闭
  • 评论 (0)
  1. 暂无评论

您必须 登陆 后才能发表评论

return top