跳到主要内容

JEP 331:低开销堆分析器

QWen Max 中英对照 JEP 331 Low-Overhead Heap Profiling

概述

提供一种通过 JVMTI 访问的低开销 Java 堆分配采样方法。

目标

提供一种从 JVM 获取有关 Java 对象堆分配的信息的方法,要求如下:

  • 是否开销足够低,可以默认持续启用,
  • 是否可以通过定义明确的编程接口访问,
  • 能否对所有分配进行采样(即,不限于特定堆区域中的分配或以特定方式分配的内存),
  • 能否以独立于实现的方式定义(即,不依赖任何特定的垃圾回收算法或虚拟机实现),以及
  • 能否提供关于存活和已销毁 Java 对象的信息。

动机

用户对于理解其堆的内容有着深切的需求。糟糕的堆管理可能会导致诸如堆耗尽和 GC 颠簸之类的问题。因此,开发了许多工具以允许用户对其堆进行内省,例如 Java Flight Recorder、jmap、YourKit 和 VisualVM 等工具。

大多数现有工具缺乏的一条信息是特定分配的调用点。堆转储和堆直方图不包含此信息。此信息对于调试内存问题可能至关重要,因为它告诉开发人员其代码中特定(尤其是特别糟糕的)分配发生的确切位置。

目前有两种方法可以从 HotSpot 中获取此信息:

  • 首先,你可以使用字节码重写工具(例如 Allocation Instrumenter)对应用程序中的所有分配进行检测。然后,你可以在需要时通过检测获取堆栈跟踪。

  • 其次,你可以使用 Java Flight Recorder,它会在 TLAB 重新填充以及直接分配到老年代时捕获堆栈跟踪。这种方式的缺点是:a) 它与特定的分配实现(TLAB)绑定,并且会遗漏不符合该模式的分配;b) 它不允许用户自定义采样间隔;c) 它仅记录分配,因此无法区分存活对象和已死亡对象。

该提案通过提供一个可扩展的 JVMTI 接口来缓解这些问题,该接口允许用户定义采样间隔,并返回一组活动的堆栈跟踪。

描述

新的 JVMTI 事件和方法

本文提出的堆采样功能的用户端 API 由 JVMTI 的一个扩展组成,该扩展允许进行堆分析。以下系统依赖于一个事件通知系统,该系统将提供如下回调:

void JNICALL
SampledObjectAlloc(jvmtiEnv *jvmti_env,
JNIEnv* jni_env,
jthread thread,
jobject object,
jclass object_klass,
jlong size)

其中:

  • thread 是分配 jobject 的线程,
  • object 是采样的 jobject 的引用,
  • object_klassjobject 的类,以及
  • size 是分配的大小。

新的 API 还包括一个全新的 JVMTI 方法:

jvmtiError  SetHeapSamplingInterval(jvmtiEnv* env, jint sampling_interval)

其中 sampling_interval 是采样之间分配的平均字节数。该方法的规范为:

  • 如果非零,将更新采样间隔,并通过回调通知用户新的平均采样间隔为 sampling_interval 字节。
    • 例如,如果用户希望每兆字节获取一个样本,sampling_interval 应为 1024 * 1024。
  • 如果传递零给该方法,采样器会在考虑新间隔后,每次分配时进行采样,这可能需要一定数量的分配次数。

请注意,采样间隔并不精确。每次采样发生时,到下一次采样之前选择的字节数将是给定平均间隔下的伪随机数。这是为了避免采样偏差;例如,如果每 512 KB 都发生相同的分配,那么 512 KB 的采样间隔将始终采样到相同的分配。因此,尽管采样间隔并不总是选定的间隔,但在大量采样后,它会趋于接近该间隔。

使用案例示例

为此,用户可以使用常规的事件通知调用来:

jvmti->SetEventNotificationMode(jvmti, JVMTI_ENABLE, JVMTI_EVENT_SAMPLED_OBJECT_ALLOC, NULL)

该事件将在分配初始化并正确设置后发送,因此实际代码执行分配操作后会稍有延迟。默认情况下,平均采样间隔为 512 KB。

启用采样事件系统的最低要求是使用 JVMTI_ENABLE 和事件类型 JVMTI_EVENT_SAMPLED_OBJECT_ALLOC 调用 SetEventNotificationMode。要修改采样间隔,用户可以调用 SetHeapSamplingInterval 方法。

要禁用系统,

jvmti->SetEventNotificationMode(jvmti, JVMTI_DISABLE, JVMTI_EVENT_SAMPLED_OBJECT_ALLOC, NULL)

禁用事件通知并自动禁用采样器。

通过 SetEventNotificationMode 再次调用采样器将会以当前设置的采样间隔重新启用采样器(默认为 512 KB 或用户通过 SetHeapSamplingInterval 传递的最后一个值)。

新能力

为了保护新特性,并使其对 VM 实现来说是可选的,jvmtiCapabilities 中引入了一项名为 can_generate_sampled_object_alloc_events 的新能力。

全局 / 线程级采样

使用通知系统提供了一种直接的方法,仅针对特定线程发送事件。这是通过 SetEventNotificationMode 并提供一个第三参数来指定需要修改的线程实现的。

一个完整的示例

以下部分提供了代码片段以说明采样器的 API。首先,启用功能和事件通知:

jvmtiEventCallbacks callbacks;
memset(&callbacks, 0, sizeof(callbacks));
callbacks.SampledObjectAlloc = &SampledObjectAlloc;

jvmtiCapabilities caps;
memset(&caps, 0, sizeof(caps));
caps.can_generate_sampled_object_alloc_events = 1;
if (JVMTI_ERROR_NONE != (*jvmti)->AddCapabilities(jvmti, &caps)) {
return JNI_ERR;
}

if (JVMTI_ERROR_NONE != (*jvmti)->SetEventNotificationMode(jvmti, JVMTI_ENABLE,
JVMTI_EVENT_SAMPLED_OBJECT_ALLOC, NULL)) {
return JNI_ERR;
}

if (JVMTI_ERROR_NONE != (*jvmti)->SetEventCallbacks(jvmti, &callbacks, sizeof(jvmtiEventCallbacks)) {
return JNI_ERR;
}

// Set the sampler to 1MB.
if (JVMTI_ERROR_NONE != (*jvmti)->SetHeapSamplingInterval(jvmti, 1024 * 1024)) {
return JNI_ERR;
}

要禁用采样器(禁用事件和采样器):

if (JVMTI_ERROR_NONE != (*jvmti)->SetEventNotificationMode(jvmti, JVMTI_DISABLE,
JVMTI_EVENT_SAMPLED_OBJECT_ALLOC, NULL)) {
return JNI_ERR;
}

要以 1024 * 1024 字节的采样间隔重新启用采样器,需要简单调用启用事件:

if (JVMTI_ERROR_NONE != (*jvmti)->SetEventNotificationMode(jvmti, JVMTI_ENABLE,
JVMTI_EVENT_SAMPLED_OBJECT_ALLOC, NULL)) {
return JNI_ERR;
}

用户存储的采样分配

当生成一个事件时,回调可以使用 JVMTI 的 GetStackTrace 方法捕获堆栈跟踪。回调获取的 jobject 引用还可以包装为 JNI 弱引用,以帮助确定对象何时被垃圾回收。这种方法允许用户收集有关采样了哪些对象以及哪些对象仍然被视为存活的数据,这是理解作业行为的一个好方法。

例如,可以完成如下操作:

extern "C" JNIEXPORT void JNICALL SampledObjectAlloc(jvmtiEnv *env,
JNIEnv* jni,
jthread thread,
jobject object,
jclass klass,
jlong size) {
jvmtiFrameInfo frames[32];
jint frame_count;
jvmtiError err;

err = global_jvmti->GetStackTrace(NULL, 0, 32, frames, &frame_count);
if (err == JVMTI_ERROR_NONE && frame_count >= 1) {
jweak ref = jni->NewWeakGlobalRef(object);
internal_storage.add(jni, ref, size, thread, frames, frame_count);
}
}

其中,internal_storage 是一个可以处理采样对象的数据结构,需要考虑是否有必要清理任何已被垃圾回收的样本等。该实现的内部细节是根据具体用例而定的,且不在本 JEP 的范围内。

采样间隔可用作减轻性能分析开销的一种手段。通过 512 KB 的采样间隔,开销应该足够低,用户可以合理地默认保持系统开启。

实现细节

当前的原型和实现证明了该方法的可行性。它包含五个部分:

  1. 由于 ThreadLocalAllocationBuffer(TLAB)结构中字段名称的更改而导致的架构相关更改。这些更改是最小的,因为它们只是名称的更改。
  2. TLAB 结构新增了一个新的 allocation_end 指针,以补充现有的 end 指针。如果采样被禁用,两个指针始终相等,代码的表现与之前相同。如果启用了采样,end 会被修改为下一个采样点请求的位置。然后,任何快速路径都会“认为”TLAB 在该点已满,并进入慢速路径,这在 (3) 中进行了解释。
  3. gc/shared/collectedHeap 代码由于其作为分配慢速路径入口点的使用而发生了变化。当 TLAB 被认为已满(因为分配已超过 end 指针),代码会进入 collectedHeap 并尝试分配一个新的 TLAB。此时,TLAB 会被恢复到其原始大小并尝试分配。如果分配成功,代码会对分配进行采样,然后返回。如果不成功,则意味着分配已到达 TLAB 的末尾,需要一个新的 TLAB。代码路径继续正常分配新的 TLAB,并确定该分配是否需要采样。如果分配被认为过大而不适合 TLAB,系统也会对分配进行采样,从而覆盖 TLAB 内和 TLAB 外的分配采样。
  4. 当请求采样时,会在堆栈的安全位置设置一个收集器对象,用于将信息发送给本地代理。收集器会跟踪已采样的分配,并在其自身帧销毁时向代理发送回调。这种机制确保对象被正确初始化。
  5. 如果 JVMTI 代理注册了 SampledObjectAlloc 事件的回调,该事件将被触发,并获取已采样的分配。示例实现可以在 libHeapMonitorTest.c 文件中找到,该文件用于 JTreg 测试。

替代方案

该 JEP 中提出的系统有多种替代方案。引言中已经提到了两个:Flight Recorder 提供了一个有趣的替代方案。这个实现提供了几个优势。首先,JFR 不允许设置采样大小或提供回调。其次,JFR 使用缓冲区系统可能会导致在缓冲区耗尽时丢失分配。最后,JFR 事件系统没有提供跟踪已被垃圾回收的对象的方法,这意味着无法使用它来提供有关活动对象和已垃圾回收对象的信息。

另一个替代方案是使用 ASM 进行字节码插桩。它的开销使其变得不可行,不是一个可行的解决方案。

这个 JEP 向 JVMTI 添加了一项新功能,JVMTI 是一个对各种开发和监控工具而言非常重要的 API/框架。通过这项功能,JVMTI 代理可以使用低开销的堆分析 API,并结合其余的 JVMTI 功能,为工具提供了极大的灵活性。例如,是否需要在每个事件点收集堆栈跟踪信息完全由代理自行决定。

测试

在 JTreg 框架中,此功能有 16 个测试,分别用于测试:多线程下的开启/关闭、多线程同时分配、数据是否以正确的间隔进行采样,以及收集的堆栈是否反映正确的程序信息。

风险与假设

禁用该功能没有任何性能损失或风险。未启用该系统的用户不会察觉到性能差异。

然而,启用该功能可能会带来潜在的性能/内存损耗。在最初的原型实现中,开销很小(<2%<2\%)。这一实现使用了一种更为重量级的机制,该机制修改了 JIT 代码。在本文展示的最终版本中,系统依托于 TLAB 代码,因此应该不会出现这种退化。

当前对 Dacapo 基准测试的评估将其开销定为:

  • 当功能被禁用时为 0%

  • 当功能在默认的 512 KB 间隔启用,但未执行回调操作时(即,SampledAllocEvent 方法为空但已注册到 JVM)为 1%

  • 使用采样回调进行简单实现以存储数据时(使用测试中的那个),开销为 3%