Skip to content

JEP 331: Low-Overhead Heap Profiling | 低开销堆分析

摘要

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

目标

提供一种从 JVM 获取 Java 对象堆分配信息的方式,该方式应该:

  • 足够低开销,可以默认持续启用,
  • 通过一个定义良好的、程序化的接口进行访问,
  • 能够采样所有分配(即不限于某个特定堆区域或特定方式分配的对象),
  • 可以通过与实现无关的方式进行定义(即不依赖于任何特定的垃圾收集算法或虚拟机实现),以及
  • 可以提供有关活动和非活动 Java 对象的信息。

动机

用户急需了解自己的堆内容。糟糕的堆管理可能导致诸如堆耗尽和 GC 抖动等问题。因此,已经开发了许多工具来允许用户检查其堆,例如 Java Flight Recorder、jmap、YourKit 和 VisualVM 工具。

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

目前从 HotSpot 获取此信息有两种方式:

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

  • 其次,您可以使用 Java Flight Recorder,它在 TLAB(Thread-Local Allocation Buffers,线程本地分配缓冲区)重新填充时以及直接在老年代分配时获取堆栈跟踪。但这种方式的缺点是:a) 它与特定的分配实现(TLABs)绑定,会错过不符合该模式的分配;b) 它不允许用户自定义采样间隔;c) 它只记录分配,因此您无法区分活动对象和非活动对象。

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

描述

新的 JVMTI 事件和方法

这里提出的堆采样功能的用户 API 包括 JVMTI 的一个扩展,该扩展允许进行堆分析。以下系统依赖于一个事件通知系统,该系统会提供一个回调,例如:

java
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 方法:

java
jvmtiError SetHeapSamplingInterval(jvmtiEnv* env, jint sampling_interval)

其中,sampling_interval 是两次采样之间平均分配的字节数。该方法的说明如下:

  • 如果 sampling_interval 非零,采样间隔将被更新,并且每当新的平均采样间隔达到 sampling_interval 字节时,都会向用户发送一个回调。
    • 例如,如果用户希望每兆字节采样一次,那么 sampling_interval 就应该是 1024 * 1024。
  • 如果将零传递给该方法,那么一旦新的间隔被考虑进去,采样器将会对每次分配都进行采样,这可能需要一定数量的分配。

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

用例示例

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

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

当分配被初始化和正确设置时,将发送该事件,因此在实际代码执行分配之后稍晚一些。默认情况下,平均采样间隔是 512KB。

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

要禁用该系统,

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

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

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

新功能

为了保护新功能并使其成为 VM 实现的可选项,在 jvmtiCapabilities 中引入了一个名为 can_generate_sampled_object_alloc_events 的新功能。

全局 / 线程级采样

使用通知系统提供了一种直接的方法,仅向特定线程发送事件。这是通过 SetEventNotificationMode 和提供要修改的线程的第三个参数来完成的。

完整示例

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

java
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;
}

// 将采样器设置为 1MB。
if (JVMTI_ERROR_NONE !=  (*jvmti)->SetHeapSamplingInterval(jvmti, 1024 * 1024)) {
  return JNI_ERR;
}

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

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

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

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

用户存储的采样分配

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

例如,可以这样操作:

java
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);
  }
}

在这个例子中,当 SampledObjectAlloc 事件被触发时,它获取一个堆栈跟踪,并创建一个对象的弱引用,然后将该对象及其大小、线程、堆栈帧和帧计数添加到 internal_storage(这里假设它是一个用于存储此类数据的自定义数据结构或容器)。通过这种方式,用户可以在应用程序的生命周期中跟踪哪些对象被采样,以及哪些对象仍然存活。

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%