跳到主要内容

JEP 331:低开销堆分析

概括

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

目标

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

  • 开销是否足够低,可以默认连续启用,
  • 可通过定义明确的编程接口进行访问,
  • 可以对所有分配进行采样(即不限于某一特定堆区域中的分配或以一种特定方式分配的分配),
  • 可以以独立于实现的方式定义(即不依赖于任何特定的 GC 算法或 VM 实现),并且
  • 可以提供有关活 Java 对象和死 Java 对象的信息。

动机

用户非常需要了解其堆的内容。糟糕的堆管理可能会导致堆耗尽和 GC 抖动等问题。因此,开发了许多工具来允许用户内省他们的堆,例如 Java Flight Recorder、jmapYourKit 和 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_klass是 的类jobject,并且
  • size是分配的大小。

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

jvmtiError  SetHeapSamplingInterval(jvmtiEnv* env, jint sampling_interval)

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

  • 如果非零,则更新采样间隔,并将使用新的平均字节采样间隔向用户发送sampling_interval回调
    • 例如,如果用户想要每兆字节一个样本,sampling_interval则为 1024 * 1024。
  • 如果将零传递给该方法,则一旦考虑新间隔,采样器就会对每个分配进行采样,这可能需要一定数量的分配

请注意,采样间隔并不精确。每次发生样本时,选择下一个样本之前的字节数将是具有给定平均间隔的伪随机数。这是为了避免抽样偏差;例如,如果每 512KB 发生一次相同的分配,则 512KB 采样间隔将始终对相同的分配进行采样。因此,虽然采样间隔不会总是选定的间隔,但经过大量采样后,它会趋向于选定的间隔。

用例示例

要启用此功能,用户将使用通常的事件通知调用来:

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

当分配初始化并正确设置时,即在实际代码执行分配之后不久,将发送该事件。默认情况下,平均采样间隔为512KB。

启用采样事件系统的最低要求是使用SetEventNotificationModeJVMTI_ENABLE事件类型进行调用JVMTI_EVENT_SAMPLED_OBJECT_ALLOC。要修改采样间隔,用户调用该SetHeapSamplingInterval方法。

要禁用系统,

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

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

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

新能力

为了保护新功能并使其成为 VM 实现的可选功能,can_generate_sampled_object_alloc_eventsjvmtiCapabilities.

全局/线程级采样

使用通知系统提供了一种仅为特定线程发送事件的直接方法。这是通过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;
}

采样分配的用户存储

当事件生成时,回调可以使用 JVMTIGetStackTrace方法捕获堆栈跟踪。回调获得的 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的范围。

采样间隔可以用作减轻分析开销的一种方法。采样间隔为 512KB 时,开销应该足够低,用户可以合理地让系统默认处于开启状态。

实施细节

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

  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%)。这使用了更重量级的机制来修改 JIT 代码。在此提供的最终版本中,系统搭载了 TLAB 代码,并且不应经历这种回归。

目前对 Dacapo 基准的评估将开销定为:

  • 禁用该功能时为 0%

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

  • 采样回调的开销为 3%,该回调执行简单的实现来存储数据(使用测试中的回调)