首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

编写分析器不是造火箭,只需 240 行代码即可输出火焰图

作者 | Johannes Bechberger   译者 | 弯月

责编 | 王子彧

出品 | CSDN(ID:CSDNnews)

几个月前,我开始着手编写分析器。如今,这些代码已经变成了我的分析器验证工具的基础。 这个项目的唯一问题是:我想从零开始编写一款非安全点偏差分析器。这其中涉及大量 C/C++/Unix 编程,但不是每个人都能阅读 C/C++ 代码。

什么是安全点偏差?

安全点是 JVM 具有已知的、确定的状态,并且所有线程都已停止的时间点。JVM 本身需要安全点来执行主要的垃圾收集、类定义、方法去优化等。线程会定期检查它们是否应该进入安全点,例如,在方法入口、出口或循环回跳处进行检查。仅在安全点进行分析的分析器具有固有的偏差,因为它包含的帧都来自线程进行安全点检查时调用的方法所在的位置。唯一的优点是,在安全点遍历堆栈不太容易出错,因为堆和栈的变动都很少。

相关的更多信息,请参见 Seetha Wenner 撰写的文章《 Java 安全点与异步分析》(参考链接:https://seethawenner.medium.com/java-safepoint-and-async-profiling-cdce0818cd29),以及 Nitsan Wakart 的经典文章《Safepoints: Meaning, Side Effects and Overheads》(参考链接:http://psy-lob-saw.blogspot.com/2015/12/safepoints.html)。

总而言之,安全点偏差分析器无法提供应用程序的整体视图,但仍然有助于从更高的角度分析主要的性能问题。

本文旨在用每个人都能理解的纯 Java 代码开发一个微型 Java 分析器。编写分析器不是造火箭,如果不考虑安全点偏差,我们可以编写一款实用的分析器,而且只需 240 行代码即可输出火焰图。该项目的源代码,请参见 GitHub(https://github.com/parttimenerd/tiny-profiler)。

我们在 Java 代理启动的守护线程中实现分析器。这样,可以方便我们同时运行分析器与需要分析的 Java 程序。分析器的主要构成如下:

Main:Java 代理的入口点,分析线程的启动器。

Options:解析并存储代理选项。

Profiler:容纳了分析循环。

Store:存储并输出采集到的结果。

Main类

首先,从代理入口点的实现着手:

当代理附加到 JVM 时调用 premain。因为用户将 -javagent 传递给了 JVM。对于我们的示例来说,这意味着用户运行 Java 时使用了如下命令:

但也有可能是用户在运行时附加了代理。在这种情况下,JVM 将调用方法 agentmain。如果想了解有关 Java 代理的更多信息,请参见 JDK 文档(https://docs.oracle.com/en/java/javase/17/docs/api/java.instrument/java/lang/instrument/package-summary.html)。

请注意,我们必须在生成的 JAR 文件的 MANIFEST 文件中设置 Premain-Class 和 Agent-Class 属性。

Java 代理解析代理参数,获取选项,再由 Options 类建模并解析这些选项:

Main 类的核心是 run 方法:Profiler 类实现了 Runnable 接口,因此我们可以直接创建线程:

接着,将这个分析器线程标记为守护线程,这意味着即使在分析器线程运行期间,JVM 也会在被分析的应用程序结束时终止:

下面,启动线程。但这需要先给线程命名,这一步非必需,但可方便调试。

Profiler类

实际的采样在 Profiler 类中处理:

我们来看看这个构造器,最有意思的是下面这行代码:

这行代码的意思是,让 JVM 在关闭时调用 Profiler::onEnd。这很关键,因为分析器线程已被默默中止,而我们仍想输出捕获的结果。

有关关闭挂钩的更多信息,请参见 Java 文档。(https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Runtime.html#addShutdownHook(java.lang.Thread))。

接下来,再看看 run 方法中的分析循环:

此处调用了 sample 方法,并在这之后休眠了一段时间,为的是确保按照 interval(通常为 10 毫秒)的节奏调用 sample 方法。

这个 sample 方法中包含核心的采样处理:

此处,我们使用 Thread::getAllStackTraces 方法来获取所有线程的堆栈跟踪。这会触发一个安全点,这也是这款分析器存在安全点偏差的原因。获取线程子集的堆栈跟踪是没有意义的,因为 JDK 中没有使用这些信息的方法。在线程的子集上调用 Thread::getStackTrace 会触发许多安全点,不仅仅是一个,因此导致的性能损失甚至会超过获取所有线程的跟踪。

Thread::getAllStackTraces 的结果经过了过滤,因此不包含守护线程(比如Profiler 线程或未使用的 Fork-Join-Pool 线程)。我们将正确的跟踪传递给 Store,由它来执行之后的后期处理。

Store类

这是这款分析器的最后一个类,也是迄今为止最重要的后期处理、存储和输出所收集信息的类:

Profiler 调用 addSample 方法,该方法会展开堆栈跟踪元素,并将它们存储在跟踪树中(用于火焰图),并统计跟踪的所有方法的数量。

有意思的部分是 Node 类建模的跟踪树。基本思想是,当 JVM 返回时,每个跟踪 A -> B -> C(A 调用 B,B 调用 C,[C,B,A])都可以表示为根节点,其包含子节点 A、B和C,因此每个捕获的踪迹都是从根节点到叶节点的路径。我们可以数一数节点出现在跟踪中的次数。然后,使用它来输出 d3-flame-graph 的树数据结构,然后再用这个数据结构创建漂亮的火焰图,如下所示:

图:分析器根据renaissance dotty基准生成的火焰图

请记住,实际的 Node 类如下:

Tiny-Profiler

我将最终的分析器命名为 tiny-profiler,源代码在 GitHub 上( MIT 许可)。这个分析器应该可以在任何带有 JDK 17 或更新版本的平台上工作。用法相当简单:

此示例的开销在我的 MacBook Pro 13″ 上大约为 2%,间隔为 10 毫秒,如果不考虑安全点偏差,结果是可接受的。

总结

综上所述,用 240 行纯 Java 编写 Java 分析器完全可行,生成的分析器甚至可用于分析性能问题。这个分析器并不是为了取代 async-profiler 之类的分析器而设计的,我的目标是揭开分析器内部工作原理的神秘面纱。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20230406A05POY00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券