通过这篇文章,我们将探讨Java / Scala调试器的编写和工作方式。 诸如Windows的WinDbg或Linux / Unix的gdb之类的本机调试器通过操作系统直接提供给它们的钩子来获取其强大功能,以监视和操纵外部进程的状态。 JVM充当OS之上的抽象层,它提供了自己的独立体系结构来调试字节码。
该框架及其API是完全开放的,有文档的和可扩展的,这意味着您可以轻松编写自己的调试器。 该框架的当前设计由两个主要部分组成-JDWP协议和JVMTI API层。 每种方法都有其自己的一套优点和最佳用例。
JDWP协议
Java调试器有线协议通常用于在网络上使用二进制消息在调试器和被调试进程之间传递请求和接收事件(例如线程状态或异常的更改)。 该体系结构背后的概念是在两者之间建立尽可能多的隔离。 这是为了减少让调试器在目标代码运行时改变其执行的海森堡效应(物理学家维尔纳,而不是友好的Meth Cooking Walt )。
从目标进程中删除尽可能多的调试器逻辑也有助于确保已调试VM状态的更改(例如“停止世界” GC或OutOfMemoryErrors)不会影响调试器本身。 为了简化操作,JDK附带了JDI (Java调试器接口),该接口提供了协议的完整调试器端实现,并具有连接,分离,监视和操纵目标VM的状态的能力。
例如,该协议与Eclipse的调试器使用的协议相同。 如果查看在IDE调试时传递给Java进程的命令行参数,您会注意到Eclipse传递给它的其他参数(-agentlib:jdwp = transport = dt_socket,…)来启用JVM调试,并且还会建立发送请求和事件的端口。
JVMTI API
现代JVM调试器体系结构中的第二个关键组件是一组本机API,涵盖了与JVM操作相关的广泛领域,称为JVM工具接口 (即JVMTI)。 与JDWP不同,JVMTI被设计为一组C / C ++ API,并且具有JVM动态加载利用API提供的命令的预编译库(例如.dll或.so)的机制。
这种方法与JDWP的不同之处在于,它实际上在目标进程内部执行调试器。 这增加了调试器在性能和稳定性方面影响应用程序代码的可能性。 但是,关键优势在于能够以近乎实时的方式直接与JVM交互。
由于JVMTI提供了一组功能强大的低级API集,所以我认为有必要进一步深入研究并解释其工作原理,以及可以使用它进行哪些出色的工作。 可通过JDK随附的jvmti.h获得API标头。
编写调试器库
编写自己的调试器需要使用C ++创建本机OS库。 在这种情况下,您的“主要”功能看起来像–
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void*)
当您的调试器代理由JVM加载时,该函数将由JVM调用。 传递给您的日益重要的JavaVM指针将为您提供与JVM对话所需的一切。 它引入了JavaVM :: GetEnv方法中可用的jvmtiEnv类,该类使您能够通过功能和事件的概念与JVMTI层进行交互。
JVMTI功能
编写调试器的关键方面之一是要特别注意调试器代码对目标进程的影响。 这对于本机调试器库尤其重要,在该库中,代码与应用程序非常紧密地运行。 为了帮助您更好地控制调试器如何影响代码的执行,JVMTI规范引入了功能概念。
在编写调试器时,您可以提前告诉JVM您打算使用哪些API命令或事件集(即设置断点,挂起线程等)。 这使JVM可以提前为此做准备,并使您可以更好地控制调试器的运行时开销。 这种方法还使来自不同供应商的JVM能够以编程方式告诉您整个JVMTI规范中当前支持哪些API命令。
并非所有能力都是平等的 。 某些功能的性能开销相对较小。 其他有趣的,如can_generate_exception_events接收回调时异常的代码抛出,或用于接收回调时获取锁定can_generate_monitor_events,付出更高的成本。 原因是它们阻止JVM在JIT编译期间完全优化代码,并可能迫使JVM在运行时进入解释模式。
其他功能,例如can_generate_field_modification_events用于在设置目标对象字段(即设置监视)时接收通知,其成本甚至更高,从而使代码执行速度大大降低。 即使JVM支持同时加载多个本机库,HotSpot中的某些功能(例如can_suspend用于挂起和恢复线程)也只能一次声明一个库。
构建Takipi的生产调试器时,我们面临的最困难的部分之一就是提供类似的功能,而又不会产生这种开销(在以后的文章中会介绍更多)。
设置回调 。 收到一组功能后,下一步就是设置回调,JVM将调用这些回调以让您知道实际发生的时间。 这些回调中的每一个都会提供有关发生的事件的相当深的信息。 例如,对于异常回调,此信息将包括引发异常的字节码位置,线程,异常对象以及是否以及将在何处捕获该异常。
void JNICALL 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 Native Interface代码,则应该很熟悉)。 提供了诸如GetMethodName和GetClassSignature之类的附加功能,以帮助您从类的常量池中获取实际的符号名称。 然后,您可以使用它们以可读格式记录数据或将其呈现在UI中,就像我们每天在IDE中看到的那样。
附加调试器
编写调试器库后,下一步就是将其附加到JVM。 有几种方法可以做到–
1.连接JDWP 。 如果要编写基于JDWP的调试器,则需要以– agentlib:jdwp = transport = dt_socket,suspend = y,address = localhost:<port>的形式向调试对象添加启动参数以通过线路启用调试。 这些参数详细说明了调试器和目标(在本例中为套接字)之间的通信形式,以及是否以挂起模式启动调试对象。
2.附加JVMTI库 。 JVM通过传递给debuggee进程并指向您的库在磁盘上的位置的agentpath命令行参数加载JVMTI库。
另一种方法是将代理程序命令行参数附加到全局JAVA_TOOL_OPTIONS环境变量,该环境变量将由每个新JVM拾取,并且其值会自动附加到其现有参数列表中。
3.远程连接 。 附加调试器的另一种方法是使用远程附加API 。 这个简单而强大的API使您可以将代理附加到正在运行的JVM进程,而无需使用任何命令行参数启动它们。 不利的一面是您将无法使用通常需要的某些功能,例如can_generate_exception_events ,因为这些功能仅在VM启动时才需要-遗憾的是,调试器有些麻烦了。
您可以下载Takipi的生产调试器,以在此处查看其中的一些方法。
翻译自: https://www.javacodegeeks.com/2013/09/how-to-write-your-own-java-scala-debugger.html