
您想向JVM添加一些有用的功能吗? 从理论上讲,每个开发人员都可以为OpenJDK做出贡献,但是,实际上,从侧面来讲,对HotSpot进行的任何不重要的更改都不是很受欢迎,即使当前发行周期缩短了,JDK用户也可能需要数年才能看到您的功能。
但是,在某些情况下,甚至无需触及其代码即可扩展虚拟机的功能。 JVM工具接口(与JVM交互的标准API)会有所帮助。
在本文中,我将通过具体示例展示如何使用它,告诉Java 9和11中发生了什么变化,并诚实地警告遇到的困难(破坏者:我必须处理C ++)。
我还谈到了有关JPoint的材料。 如果您喜欢视频,则可以观看
视频报告。
参赛作品
我担任首席工程师的社交网络Odnoklassniki几乎完全用Java编写。 但是今天,我将告诉您另一部分,而这并不完全是Java。
如您所知,Java开发人员中最受欢迎的问题是NullPointerException。 有一次,我在门户网站上值班时,还遇到了NPE的生产环境。 该错误伴随着这样的堆栈跟踪:

当然,在堆栈跟踪中,您可以跟踪异常发生的位置,直到代码中的特定行。 只是在这种情况下,它并没有让我感觉好些,因为在这里NPE可以满足很多地方:

如果JVM准确建议此错误的位置,例如这样,那将是很好的:
java.lang.NullPointerException: Called 'getUsers()' method on null object
但是,不幸的是,NPE现在不包含任何此类内容。 尽管他们一直在要求这样做,但至少在Java 1.4中已经提出:
这个错误已经存在16年了。 定期地,关于此主题的错误越来越多,但始终将其关闭为“无法修复”:

这并非在所有地方都发生。 SAP的Volker Simonis
讲述了他们如何长期在SAP JVM中实现此功能,并多次提供了帮助。 另一位SAP员工再次
提交了 OpenJDK中
的错误,并自愿实施与SAP JVM中类似的机制。 而且,瞧,这次错误没有关闭-该功能有可能进入JDK 14。
但是JDK 14何时发布,何时切换到它? 如果您现在想调查问题该怎么办?
当然,您可以维护OpenJDK的分支。 NPE报告功能本身并不复杂,我们可以很好地实现它。 但是同时,将存在支持您自己的装配的所有问题。 一次实现该功能,然后将其作为插件简单地连接到任何版本的JVM,将是很棒的。 这确实有可能! JVM有一个特殊的API(最初是为各种调试器和分析器开发的):JVM Tool Interface。
最重要的是,此API是标准的。 他有一个严格的
规范 ,并且在按照它实现功能时,可以确保它可以在JVM的新版本中使用。
要使用此接口,您需要编写一个小型(或大型,取决于您的任务)程序。 本机的:通常是用C或C ++编写的。 标准JDK
jdk/include/jvmti.h
具有要
jdk/include/jvmti.h
的
jdk/include/jvmti.h
头文件。
程序被编译到动态库中,并在JVM启动期间通过
-agentpath
参数连接。 重要的是不要将其与另一个类似的参数
-javaagent
混淆。 实际上,Java代理是JVM TI代理的特例。 此外,在本文中,单词“代理”下的确切含义是本地代理。
从哪里开始
让我们在实践中看看如何编写最简单的JVM TI代理,一种“ hello world”。
#include <jvmti.h> #include <stdio.h> JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM* vm, char* options, void* reserved) { jvmtiEnv* jvmti; vm->GetEnv((void**) &jvmti, JVMTI_VERSION_1_0); char* vm_name = NULL; jvmti->GetSystemProperty("java.vm.name", &vm_name); printf("Agent loaded. JVM name = %s\n", vm_name); fflush(stdout); return 0; }
第一行包含相同的头文件。 接下来是需要在代理中实现的主要功能:
Agent_OnLoad()
。 代理启动时,虚拟机本身会调用它,并将指针传递给
JavaVM*
对象。
使用它,您可以获得一个指向JVM TI环境的指针:
jvmtiEnv*
。 并且通过它,已经调用了JVM TI函数。 例如,使用
GetSystemProperty读取系统属性的值。
如果现在运行此“ hello world”,并将已编译的dll文件传递给
-agentpath
,则在Java程序开始运行之前,控制台中将显示由代理打印的行:

浓缩NPE
由于hello world不是最有趣的示例,因此让我们回到我们的例外情况。 补充NPE报告的完整代理代码
在GitHub上 。
如果我想让虚拟机将所有异常通知我们,这就是
Agent_OnLoad()
样子:
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM* vm, char* options, void* reserved) { jvmtiEnv* jvmti; vm->GetEnv((void**) &jvmti, JVMTI_VERSION_1_0); jvmtiCapabilities capabilities = {0}; capabilities.can_generate_exception_events = 1; jvmti->AddCapabilities(&capabilities); jvmtiEventCallbacks callbacks = {0}; callbacks.Exception = ExceptionCallback; jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_EXCEPTION, NULL); return 0; }
首先,我请JVM TI提供相应的功能(can_generate_exception_events)。 我们将分别讨论功能。
下一步是订阅Exception事件。 每当JVM抛出异常(无论是否捕获到
ExceptionCallback()
都会调用我们的
ExceptionCallback()
函数。
最后一步是调用
SetEventNotificationMode()
以启用通知传递。
在ExceptionCallback中,JVM传递了处理异常所需的一切。 void JNICALL ExceptionCallback(jvmtiEnv* jvmti, JNIEnv* env, jthread thread, jmethodID method, jlocation location, jobject exception, jmethodID catch_method, jlocation catch_location) { jclass NullPointerException = env->FindClass("java/lang/NullPointerException"); if (!env->IsInstanceOf(exception, NullPointerException)) { return; } jclass Throwable = env->FindClass("java/lang/Throwable"); jfieldID detailMessage = env->GetFieldID(Throwable, "detailMessage", "Ljava/lang/String;"); if (env->GetObjectField(exception, detailMessage) != NULL) { return; } char buf[32]; sprintf(buf, "at location %id", (int) location); env->SetObjectField(exception, detailMessage, env->NewStringUTF(buf)); }
这里既有引发异常的线程对象(线程),发生异常的位置(方法,位置),也有异常的对象(异常),甚至是代码中捕获此异常的位置(catch_method,catch_location)。
重要的是:在此回调中,除了指向JVM TI环境的指针外,还传递了JNI环境(env)。 这意味着我们可以使用其中的所有JNI函数。 也就是说,JVM TI和JNI完美地共存,彼此互补。
在我的代理中,我同时使用。 特别是,通过JNI,我检查我的异常是否为
NullPointerException
类型,然后将错误消息替换
detailMessage
字段。
由于JVM本身向我们传递了位置-发生异常的字节码索引,因此我只是将此位置放在消息中:

数字66表示发生此异常的字节码索引。 但是,手动分析字节码很沉闷:您需要反编译类文件,查找第66条指令,尝试了解它在做什么……如果我们的代理本人可以显示出更易于理解的内容,那就太好了。
但是,在这种情况下,JVM TI可以满足您的所有需求。 的确,您必须请求JVM TI的其他功能:获取字节码和常量池方法。
jvmtiCapabilities capabilities = {0}; capabilities.can_generate_exception_events = 1; capabilities.can_get_bytecodes = 1; capabilities.can_get_constant_pool = 1; jvmti->AddCapabilities(&capabilities);
现在,我将扩展ExceptionCallback:通过JVM TI函数
GetBytecodes()
我将获得方法的主体,以通过位置索引检查其中的内容。 接下来是一个大的开关字节码指令:如果这是对数组的访问,则将出现一条错误消息,如果对字段的访问是另一条消息,如果方法调用是第三个,依此类推。
ExceptionCallback代码 jint bytecode_count; u1* bytecodes; if (jvmti->GetBytecodes(method, &bytecode_count, &bytecodes) != 0) { return; } if (location >= 0 && location < bytecode_count) { const char* message = get_exception_message(bytecodes[location]); if (message != NULL) { ... env->SetObjectField(exception, detailMessage, env->NewStringUTF(buf)); } } jvmti->Deallocate(bytecodes);
它仅用于替换字段或方法的名称。 您可以从
常量池中获取它,这要归功于JVM TI。
if (jvmti->GetConstantPool(holder, &cpool_count, &cpool_bytes, &cpool) != 0) { return strdup("<unknown>"); }
接下来是一些魔术,但实际上并没有什么棘手的问题,仅根据类文件格式
规范,我们分析常量池,然后从该行中分离出线(即方法的名称)。
恒定池分析 u1* ref = get_cpool_at(cpool, get_u2(bytecodes + 1));
另一个重要点:某些JVM TI函数(例如
GetConstantPool()
或
GetBytecodes()
在本机内存中分配特定的结构,在完成使用该结构时需要将其释放。
jvmti->Deallocate(cpool);
使用我们的扩展代理运行源程序,这是对该异常的完全不同的描述:它报告我们在null对象上调用了longValue()方法。

其他应用
一般来说,开发人员通常希望以自己的方式处理异常。 例如,如果发生
StackOverflowError
,则自动重启JVM。
可以理解这种需求,因为
StackOverflowError
与
OutOfMemoryError
是相同的致命错误,发生后,就不再可能保证程序的正确运行。 或者,例如,有时为了分析问题,我想在发生异常时接收线程转储或堆转储。

公平地说,IBM JDK开箱即用。 但是现在我们已经知道,使用JVM TI代理,您可以在HotSpot中实现相同的操作。 订阅异常回调并分析异常就足够了。 但是如何从我们的代理中删除线程转储或堆转储? JVM TI具有此情况所需的一切:

实现绕过堆并创建转储的整个机制不是很方便。 但是,我将分享如何使其变得更容易,更快的秘密。 是的,它不再包含在标准JVM TI中,而是Hotspot的私有扩展。
您需要从HotSpot源连接头文件
jmm.h并调用
JVM_GetManagement()
函数:
#include "jmm.h" JNIEXPORT void* JNICALL JVM_GetManagement(jint version); void JNICALL ExceptionCallback(jvmtiEnv* jvmti, JNIEnv* env, ...) { JmmInterface* jmm = (JmmInterface*) JVM_GetManagement(JMM_VERSION_1_0); jmm->DumpHeap0(env, env->NewStringUTF("dump.hprof"), JNI_FALSE); }
它将返回一个指向HotSpot管理接口的指针,该接口在单个调用中将生成一个堆转储或线程转储。 该示例的完整代码可以在
我对Stack Overflow的
回答中找到。
自然地,您不仅可以处理异常,还可以处理与JVM操作相关的许多其他事件:启动/停止线程,加载类,垃圾回收,编译方法,输入/退出方法,甚至访问或修改Java对象的特定字段。
我有另一个
vmtrace代理的示例,该代理订阅许多标准的JVM TI事件并记录它们。 如果使用此代理运行一个简单的程序,我将获得详细的日志,完成后将带有时间戳:

如您所见,要简单地打印hello world,将加载数百个类,生成并编译数十个方法。 很清楚为什么Java需要这么长时间才能运行。 关于一切的一切花费了超过200毫秒的时间。
JVM TI可以做什么
除了事件处理之外,JVM TI还具有许多其他功能。 它们可以分为两组。
一个是强制性的,任何支持JVM TI的JVM都必须实现。 这些包括分析方法,字段,流的操作,向类路径添加新类的能力,等等。
有一些可选功能需要初步功能要求。 不需要JVM支持所有它们,但是HotSpot完全实现了整个规范。 可选功能分为两个子组:只能在JVM开始时连接的子组(例如,设置断点或分析局部变量的能力),以及可以随时连接的子组(尤其是字节码或常量池),上面使用)。

您可能会注意到,功能列表与调试器的功能非常相似。 实际上,Java调试器不过是JVM TI代理的特例,该代理利用了所有这些功能并要求所有功能。
将功能分为可以随时启用的功能和仅在引导时启用的功能是有意完成的。 并非所有功能都是免费的,有些会带来额外的开销。
如果使用该功能所带来的直接开销一切都清楚了,那么即使您不使用该功能,也会出现不那么明显的间接开销,而只是通过功能,您便声明将来会需要它。 这是因为虚拟机可以不同地编译代码或向运行时添加其他检查。
例如,已经考虑的订阅异常的能力(can_generate_exception_events)导致所有抛出异常的过程都会缓慢进行。 原则上,这并不是那么可怕,因为在一个好的Java程序中,异常是很少见的事情。
具有局部变量的情况稍微更糟。 对于can_access_local_variables(它允许您随时获取局部变量的值),您需要禁用一些重要的优化。 尤其是,Escape Analysis完全停止工作,这可能会产生明显的开销:根据应用程序,开销为5-10%。
因此得出结论:如果在打开调试代理的情况下运行Java,甚至没有使用它,应用程序的运行速度就会变慢。 无论如何,在生产中包含调试代理并不是一个好主意。
例如,设置断点或跟踪方法的所有输入/输出等许多功能会带来更严重的开销。 特别是,某些JVM TI事件(FieldAccess,MethodEntry / Exit)仅在解释器中起作用。
一种作用剂好,两种作用剂好
您只需指定几个
-agentpath
参数即可将多个代理连接到单个进程。 每个人都有自己的JVM TI环境。 这意味着每个人都可以订阅自己的功能并独立拦截事件。
并且如果两个代理订阅了Breakpoint事件,并且在一个方法中设置了一个断点,那么当执行此方法时,第二个代理会接收该事件吗?
实际上,这种情况不会发生(至少在HotSpot JVM中)。 因为在某些给定时间只有某些代理可以拥有一些功能。 这些尤其包括breakpoint_events。 因此,如果第二个代理请求相同的功能,它将收到一个错误响应。
这是一个重要的结论:即使您正在HotSpot上运行并且知道所有功能都可用,代理也应始终检查功能请求的结果。 JVM TI规范没有提及专有功能,但是HotSpot具有这样的实现功能。
诚然,代理隔离并非总是完美的。 在开发
async-profiler的过程中,我遇到了这个问题:当我们有两个代理并且一个请求生成方法编译事件时,所有代理都会收到这些事件。 当然,我提交了一个
bug ,但是您应该记住,您的代理中可能会发生意料之外的事件。
在常规程序中的用法
对于调试器和分析器,JVM TI似乎是非常具体的事情,但它也可以在常规Java程序中使用。 考虑一个例子。
当一切都是异步的时,反应式编程范式现在很普遍,但是这种范式存在问题。
public class TaskRunner { private static void good() { CompletableFuture.runAsync(new AsyncTask(GOOD)); } private static void bad() { CompletableFuture.runAsync(new AsyncTask(BAD)); } public static void main(String[] args) throws Exception { good(); bad(); Thread.sleep(200); } }
我运行两个仅在参数上有所不同的异步任务。 如果出现问题,则会引发异常:

从堆栈跟踪中,完全不清楚这些任务中的哪一个导致了问题。 因为异常发生在一个完全不同的线程中,所以我们没有上下文。 如何理解在哪个任务中?
作为解决方案之一,您可以将有关创建位置的信息添加到异步任务的构造函数中:
public AsyncTask(String arg) { this.arg = arg; this.location = getLocation(); }
也就是说,记住位置-代码中的特定位置,一直到调用构造函数的那一行。 并在例外情况下承诺:
try { int n = Integer.parseInt(arg); } catch (Throwable e) { System.err.println("ParseTask failed at " + location); e.printStackTrace(); }
现在,当发生异常时,我们将看到这发生在TaskRunner(创建带有BAD参数的任务的行)的第14行:

但是如何在调用构造函数的代码中获得位置呢? 在Java 9之前,唯一合法的方法是执行此操作:获取堆栈跟踪,跳过一些不相关的帧,然后在堆栈上稍低一些的位置调用我们的代码。
String getLocation() { StackTraceElement caller = Thread.currentThread().getStackTrace()[3]; return caller.getFileName() + ':' + caller.getLineNumber(); }
但是有一个问题。 获取完整的StackTrace相当缓慢。 我有
一份专门的
报告 。
如果这种情况很少发生,那将不是什么大问题。 但是,例如,我们有一个Web服务-接受HTTP请求的前端。 这是一个很棒的应用程序,包含数百万行代码。 为了捕获渲染错误,我们使用了类似的机制:在渲染组件中,我们记住了它们的创建位置。 我们有数百万个这样的组件,因此获取所有堆栈跟踪信息需要花费很长的时间才能启动应用程序,而不仅仅是一分钟。 因此,此功能以前在生产中已禁用,尽管为了分析问题在生产中是必需的。
Java 9引入了一种绕过流堆栈的新方法:StackWalker,它通过Stream API可以按需延迟执行所有这些操作。 也就是说,我们可以跳过正确的帧数,而只获得我们感兴趣的帧数。
String getLocation() { return StackWalker.getInstance().walk(s -> { StackWalker.StackFrame frame = s.skip(3).findFirst().get(); return frame.getFileName() + ':' + frame.getLineNumber(); }); }
它比获取完整的堆栈跟踪要好一些,但幅度不大或什至很多倍。 在我们的例子中,结果快了大约一半半:

最不理想的StackWalker实现存在一个
已知问题 ,很可能甚至会在JDK 13中得到解决。但是,再次,我们现在应该在Java 8中做什么,因为Java 8中StackWalker甚至都不慢?
JVM TI再次进行了救援。 有一个
GetStackTrace()
函数
GetStackTrace()
您的所有需求:从指定的帧开始,获取给定长度的堆栈跟踪的片段,仅执行其他操作。
GetStackTrace(jthread thread, jint start_depth, jint max_frame_count, jvmtiFrameInfo* frame_buffer, jint* count_ptr)
只剩下一个问题:如何从我们的Java程序中调用JVM TI函数? 就像任何其他本机方法一样:使用
System.loadLibrary()
加载本机库,我们的方法的JNI实现将在该库中。
public class StackFrame { public static native String getLocation(int depth); static { System.loadLibrary("stackframe"); } }
不仅可以从Agent_OnLoad()中
获得指向JVM TI环境的指针,而且还可以在程序运行时
获得其指针,并可以通过普通的本机JNI方法继续使用它:
JNIEXPORT jstring JNICALL Java_StackFrame_getLocation(JNIEnv* env, jclass unused, jint depth) { jvmtiFrameInfo frame; jint count; jvmti->GetStackTrace(NULL, depth, 1, &frame, &count);
:

, JDK : - . -. , , , JDK. JDK 8u112, JVM TI-, (GetMethodName, GetMethodDeclaringClass ), .
, , : JVM TI- , , -. , C++,
jvmtiEnter.xsl .
: HotSpot XSLT-. HotSpot.
? , . , - jmethodID , . , .
, JVM TI Java- ,
System.loadLibrary
.
, , JVM TI-
-agentpath
JVM.
: (dynamic attach).
? , - , , JVM TI- .
JDK 9, jcmd:
jcmd <pid> JVMTI.agent_load /path/to/agent.so [arguments]
JDK
jattach . ,
async-profiler , - JVM-, jattach.
JVM TI- , ,
Agent_OnLoad()
,
Agent_OnAttach()
. :
Agent_OnAttach()
capabilities, .
, ,
Agent_OnAttach()
.
. IntelliJ IDEA: Java-, , - .
process ID IDEA, jattach JVM TI- patcher.dll:
jattach 8648 load patcher.dll true
:

? Java- (
javax.swing.AbstractButton
) JNI
setBackground()
.
.
Java 9
JVM TI , , , API, . Java 9.
, Java 9 , . , «» JDK, .
, JDK Direct ByteBuffer. API:

, Cassandra , MappedByteBuffer, , JVM .
JDK 9, IllegalAccessError:

Reflection: .
, Java Linux. -
java.io.FileDescriptor
JNI - . , JDK 9, :

, JVM, . , . , Cassandra Java 11,
:
--add-exports java.base/jdk.internal.misc=ALL-UNNAMED --add-exports java.base/jdk.internal.ref=ALL-UNNAMED --add-exports java.base/sun.nio.ch=ALL-UNNAMED --add-exports java.management.rmi/com.sun.jmx.remote.internal.rmi=ALL-UNNAMED --add-exports java.rmi/sun.rmi.registry=ALL-UNNAMED --add-exports java.rmi/sun.rmi.server=ALL-UNNAMED --add-exports java.sql/java.sql=ALL-UNNAMED --add-opens java.base/java.lang.module=ALL-UNNAMED --add-opens java.base/jdk.internal.loader=ALL-UNNAMED --add-opens java.base/jdk.internal.ref=ALL-UNNAMED --add-opens java.base/jdk.internal.reflect=ALL-UNNAMED --add-opens java.base/jdk.internal.math=ALL-UNNAMED --add-opens java.base/jdk.internal.module=ALL-UNNAMED --add-opens java.base/jdk.internal.util.jar=ALL-UNNAMED --add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED
JVM TI :
- GetAllModules
- AddModuleExports
- AddModuleOpens
- . .
, : JVM, , , .
Direct ByteBuffer:
public static void main(String[] args) { ByteBuffer buf = ByteBuffer.allocateDirect(1024); ((sun.nio.ch.DirectBuffer) buf).cleaner().clean(); System.out.println("Buffer cleaned"); }
, IllegalAccessError. agentpath
antimodule , . .
Java 11
Java 11. , ! :
SampledObjectAlloc
, , .
callback , : , , , , .
SetHeapSampingInterval
, .

? , , . Java Flight Recorder.
, , , , .
Thread Local Allocation Buffer . TLAB , . , .

, TLAB, . JVM runtime .
, , — 5%.
, , JDK 7, Flight Recorder. API async-profiler. , JDK 11, API , JVM TI, . , YourKit . API,
, .
. , , , , .

结论
JVM TI — .
, ++, JVM . , JVM TI .
GitHub . , .