如何通过编程方式无堆转储获取运行中JVM堆内最大对象的实时统计信息
无全量堆转储场景下,Java8 HotSpot(Linux)类维度堆内存实时统计实现方案
先把两个核心疑问的结论摆前面:
- 纯标准Java API没有办法在不做全堆遍历/转储的前提下,拿到全局堆内类维度的实例数、内存占用统计
- 主流Profiler的低开销实现完全基于HotSpot VM内置的对象采样能力,走JVM TI原生接口实现,开销极低,精度满足要求
标准Java API的能力边界
梳理JDK8对外暴露的所有相关API,可以明确覆盖范围的硬边界:
java.lang.management.MemoryMXBean:仅能输出堆/非堆的整体内存大小、使用率,没有类维度的拆分粒度java.lang.instrument.Instrumentation:- 提供的
getObjectSize(Object obj)方法需要传入具体的对象引用,才能拿到单个对象的Shallow大小,没有接口能直接枚举堆中所有存活对象的引用 - 如果要靠这个接口做全局统计,必须从GC Root出发递归遍历整个对象图,这个过程会让JVM进入长时间安全点暂停,和生成一次完整heap dump的开销基本一致,完全达不到实时低开销的要求
- 仅适合"提前知道要追踪哪些自定义类、主动持有这些类的对象引用"的场景,覆盖不了全局统计的需求
- 提供的
- 字节码增强的变通路径:通过
retransformClasses给所有类的构造方法埋点,累计对象创建的计数。这种方式的硬伤很明显:- 只能统计埋点完成后新创建的对象,完全覆盖不了堆中的存量老对象
- 每个对象构造都要触发Java层的逻辑分支,运行时开销通常超过10%
- 拿不到对象头、对齐填充带来的实际内存占用,统计值的偏差非常大。
主流Profiler的底层实现逻辑
所有主流性能分析工具在这类场景下,从来不会走全堆遍历的路径,核心都是复用HotSpot内置的TLAB分配采样钩子,实现低开销的类维度内存聚合:
- HotSpot在对象分配路径上,专门预留了采样触发点:当线程在自己的TLAB缓冲区里完成对象分配时,不会每次都触发回调,而是根据设置的采样间隔,按概率抽样采集当前分配的对象信息
- 被采样到的对象会直接回调给分析模块,带上所属类的元数据、对象本身的大小,分析模块会根据采样间隔的权重,把采样值放大成全量场景下的估算值
- 整个过程不需要遍历堆、不需要冻结所有应用线程,运行时开销通常能控制在1%以内,秒级刷新的统计精度和全量heap dump的分析结果偏差通常在5%以内,和日常用Profiler看到的输出效果完全一致
- 采样间隔可以灵活调整:间隔越小精度越高、开销越大;间隔越大精度越低、开销越小,完全可以根据业务场景动态调整。
Linux+Java8 HotSpot下的原生实现方案(通用化优先)
方案1:基于JVM TI的原生采样代理
这是通用性最强、精度最高的实现方式,完全不依赖特定Profiler的私有能力,适配所有HotSpot VM的版本:
核心实现步骤:
- 开发一个C/C++编写的Java代理,目标JVM启动时或者运行时通过attach机制挂载
- 在JVM TI初始化阶段申请对应权限,开启对象采样能力,设置自定义的采样间隔
- 注册对象采样事件的回调函数,在回调里实时按类维度聚合实例数、累计占用大小
简化的核心代码示例:
// 原生代理初始化阶段核心逻辑(Linux/Java8 HotSpot适配) jvmtiEnv* jvmti_env; jvmtiCapabilities jvmti_caps = {0}; // 申请必要的能力 jvmti_env->GetPotentialCapabilities(&jvmti_caps); jvmti_caps.can_generate_all_class_hook_events = 1; jvmti_caps.can_tag_objects = 1; jvmti_env->AddCapabilities(&jvmti_caps); // 设置采样间隔:每分配1MB大小触发一次采样 jvmti_env->SetObjectAllocationSamplingRate(1 * 1024 * 1024); // 定义采样回调函数 void JNICALL sample_object_callback(jvmtiEnv* jvmti, JNIEnv* env, jclass sampled_klass, jlong object_size) { // 获取类的全限定名 char* klass_signature; jvmti->GetClassSignature(sampled_klass, &klass_signature, nullptr); // 本地维护的类维度统计表:key为类签名,value为<累计实例数, 累计估算内存占用> class_stat_table[klass_signature].sample_count += 1; class_stat_table[klass_signature].estimated_memory += object_size * sampling_weight; // 释放JVM分配的类名字符串 jvmti->Deallocate((unsigned char*)klass_signature); } // 注册回调、开启事件通知 jvmtiEventCallbacks callbacks = {0}; callbacks.ObjectAllocationSample = sample_object_callback; jvmti_env->SetEventCallbacks(&callbacks, sizeof(callbacks)); jvmti_env->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_OBJECT_ALLOCATION_SAMPLE, nullptr);
注意:Java8 HotSpot下的采样权重可以根据TLAB的大小动态计算,不需要手动硬编码,最终输出的统计值已经做了权重补偿,和全量堆统计的结果没有明显感知差异。
方案2:读取HotSpot的perf共享内存(零代理侵入)
Linux环境下HotSpot会在/tmp/hsperfdata_<当前用户>/<目标JVM进程PID>路径下创建一块共享内存段,实时写入JVM运行的各类性能指标。Java8版本下,这个共享内存段里已经内置了每个已加载类的TLAB分配累计大小、TLAB外分配大小的统计值,不需要给目标JVM挂载任何代理,只需要按照固定的结构解析这块共享内存,就能拿到类维度的内存占用估算值。
这种方式的侵入性为零,完全不会影响目标JVM的运行,缺点是解析的结构依赖HotSpot的内部实现,版本变更时结构可能调整,通用性比JVM TI方案略差。
常见踩坑提醒
- 不要尝试靠"枚举所有已加载类、逐类实例计数"的纯Java方案做全局统计:HotSpot没有对外提供"拿到某个类所有存活实例"的标准API,这类操作必须进入安全点遍历堆,和生成heap dump的开销没有区别
- 不要把采样统计的"估算值"当成绝对精确值:在1MB的采样间隔下,单类的内存占用估算误差通常在5%以内,完全满足日常排查大对象的需求,如果需要100%精确的存量对象统计,再触发全量heap dump就可以
- 这类实现方式的可移植性很强:只要是Linux下运行的HotSpot VM,不管是Java8还是后续的高版本JDK,核心的采样逻辑不需要做大的修改就能复用。
内容的提问来源于stack exchange,提问作者Mark Bramnik
相关产品推荐
相关产品推荐

