編寫分析器不是造火箭,只需 240 行程式碼即可輸出火焰圖

【CSDN 編者按】240 行純 Java 編寫 Java 分析器是完全可行的,生成的分析器甚至可用於分析性能問題。它並不是為了取代 async-profiler 之類的分析器而設計的,而是揭開分析器內部工作原理的神秘面紗。

原文連結:https://mostlynerdless.de/blog/2023/03/27/writing-a-profiler-in-240-lines-of-pure-java/

未經授權,禁止轉載!

作者 | Johannes Bechberger 譯者 | 彎月

責編 | 王子彧

幾個月前,我開始著手編寫分析器。如今,這些程式碼已經變成了我的分析器驗證工具的基礎。 這個項目的唯一問題是:我想從零開始編寫一款非安全點偏差分析器。這其中涉及大量 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類

Main類

首先,從代理入口點的實現著手:

public class Main {public static void agentmain(String agentArgs) {premain(agentArgs);}public static void premain(String agentArgs) {Main main = new Main();main.run(new Options(agentArgs));}private void run(Options options) {Thread t = new Thread(new Profiler(options));t.setDaemon(true);t.setName("Profiler");t.start();}}

當代理附加到 JVM 時調用 premain。因為使用者將 -javagent 傳遞給了 JVM。對於我們的示例來說,這意味著使用者運行 Java 時使用瞭如下命令:

java -javaagent:./target/tiny_profiler.jar=agentArgs …

但也有可能是使用者在運行時附加了代理。在這種情況下,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 類建模並解析這些選項:

public class Options {/** interval option */private Duration interval = Duration.ofMillis(10);/** flamegraph option */private Optional flamePath;/** table option */private boolean printMethodTable = true;...}

Main 類的核心是 run 方法:Profiler 類實現了 Runnable 接口,因此我們可以直接創建執行緒:

Thread t = new Thread(new Profiler(options));

接著,將這個分析器執行緒標記為守護執行緒,這意味著即使在分析器執行緒運行期間,JVM 也會在被分析的應用程序結束時終止:

t.setDaemon(true);

下面,啟動執行緒。但這需要先給執行緒命名,這一步非必需,但可方便調試。

t.setName("Profiler");t.start();
Profiler類

Profiler類

實際的取樣在 Profiler 類中處理:

public class Profiler implements Runnable {private final Options options;private final Store store;public Profiler(Options options) {this.options = options;this.store = new Store(options.getFlamePath());Runtime.getRuntime().addShutdownHook(new Thread(this::onEnd));}private static void sleep(Duration duration) {// ...}@Overridepublic void run() {while (true) {Duration start = Duration.ofNanos(System.nanoTime());sample();Duration duration = Duration.ofNanos(System.nanoTime()).minus(start);Duration sleep = options.getInterval().minus(duration);sleep(sleep);}}private void sample() {Thread.getAllStackTraces().forEach((thread, stackTraceElements) -> {if (!thread.isDaemon()) {// exclude daemon threadsstore.addSample(stackTraceElements);}});}private void onEnd() {if (options.printMethodTable()) {store.printMethodTable();}store.storeFlameGraphIfNeeded();}

我們來看看這個構造器,最有意思的是下面這行程式碼:

Runtime.getRuntime().addShutdownHook(new Thread(this::onEnd));

這行程式碼的意思是,讓 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 方法中的分析循環:

while (true) {Duration start = Duration.ofNanos(System.nanoTime());sample();Duration duration = Duration.ofNanos(System.nanoTime()).minus(start);Duration sleep = options.getInterval().minus(duration);sleep(sleep);}

此處調用了 sample 方法,並在這之後休眠了一段時間,為的是確保按照 interval(通常為 10 毫秒)的節奏調用 sample 方法。

這個 sample 方法中包含核心的取樣處理:

Thread.getAllStackTraces().forEach((thread, stackTraceElements) -> {if (!thread.isDaemon()) {// exclude daemon threadsstore.addSample(stackTraceElements);}});

此處,我們使用 Thread::getAllStackTraces 方法來獲取所有執行緒的堆疊跟蹤。這會觸發一個安全點,這也是這款分析器存在安全點偏差的原因。獲取執行緒子集的堆疊跟蹤是沒有意義的,因為 JDK 中沒有使用這些資訊的方法。線上程的子集上調用 Thread::getStackTrace 會觸發許多安全點,不僅僅是一個,因此導致的性能損失甚至會超過獲取所有執行緒的跟蹤。

Thread::getAllStackTraces 的結果經過了過濾,因此不包含守護執行緒(比如Profiler 執行緒或未使用的 Fork-Join-Pool 執行緒)。我們將正確的跟蹤傳遞給 Store,由它來執行之後的後期處理。

Store類

Store類

這是這款分析器的最後一個類,也是迄今為止最重要的後期處理、儲存和輸出所收集資訊的類:

package me.bechberger;import java.io.BufferedOutputStream;import java.io.OutputStream;import java.io.PrintStream;import java.nio.file.Path;import java.util.HashMap;import java.util.List;import java.util.Map;import java.util.Optional;import java.util.stream.Stream;/*** store of the traces*/public class Store {/** too large and browsers can't display it anymore */private final int MAX_FLAMEGRAPH_DEPTH = 100;private static class Node {// ...}private final Optional flamePath;private final Map methodOnTopSampleCount =new HashMap<>();private final Map methodSampleCount =new HashMap<>();private long totalSampleCount = 0;/*** trace tree node, only populated if flamePath is present*/private final Node rootNode = new Node("root");public Store(Optional flamePath) {this.flamePath = flamePath;}private String flattenStackTraceElement(StackTraceElement stackTraceElement) {// call intern to safe some memoryreturn (stackTraceElement.getClassName() + "." +stackTraceElement.getMethodName()).intern();}private void updateMethodTables(String method, boolean onTop) {methodSampleCount.put(method,methodSampleCount.getOrDefault(method, 0L) + 1);if (onTop) {methodOnTopSampleCount.put(method,methodOnTopSampleCount.getOrDefault(method, 0L) + 1);}}private void updateMethodTables(List trace) {for (int i = 0; i < trace.size(); i++) {String method = trace.get(i);updateMethodTables(method, i == 0);}}public void addSample(StackTraceElement[] stackTraceElements) {List trace =Stream.of(stackTraceElements).map(this::flattenStackTraceElement).toList();updateMethodTables(trace);if (flamePath.isPresent()) {rootNode.addTrace(trace);}totalSampleCount++;}// the only reason this requires Java 17 :Pprivate record MethodTableEntry(String method,long sampleCount,long onTopSampleCount) {}private void printMethodTable(PrintStream s,List sortedEntries) {// ...}public void printMethodTable() {// sort methods by sample count// the print a table// ...}public void storeFlameGraphIfNeeded() {// ...}}

Profiler 調用 addSample 方法,該方法會展開堆疊跟蹤元素,並將它們儲存在跟蹤樹中(用於火焰圖),並統計跟蹤的所有方法的數量。

有意思的部分是 Node 類建模的跟蹤樹。基本思想是,當 JVM 返回時,每個跟蹤 A -> B -> C(A 調用 B,B 調用 C,[C,B,A])都可以表示為根節點,其包含子節點 A、B和C,因此每個捕獲的蹤跡都是從根節點到葉節點的路徑。我們可以數一數節點出現在跟蹤中的次數。然後,使用它來輸出 d3-flame-graph 的樹資料結構,然後再用這個資料結構創建漂亮的火焰圖,如下所示:

圖:分析器根據renaissance dotty基準生成的火焰圖

請記住,實際的 Node 類如下:

private static class Node {private final String method;private final Map children = new HashMap<>();private long samples = 0;public Node(String method) {this.method = method;}private Node getChild(String method) {return children.computeIfAbsent(method, Node::new);}private void addTrace(List trace, int end) {samples++;if (end > 0) {getChild(trace.get(end)).addTrace(trace, end - 1);}}public void addTrace(List trace) {addTrace(trace, trace.size() - 1);}/*** Write in d3-flamegraph format*/private void writeAsJson(PrintStream s, int maxDepth) {s.printf("{ \"name\": \"%s\", \"value\": %d, \"children\": [",method, samples);if (maxDepth > 1) {for (Node child : children.values()) {child.writeAsJson(s, maxDepth - 1);s.print(",");}}s.print("]}");}public void writeAsHTML(PrintStream s, int maxDepth) {s.print("""type="text/css"href="https://cdn.jsdelivr.net/npm/d3-flame-graph@4.1.3/dist/d3-flamegraph.css">
""");}
Tiny-Profiler

Tiny-Profiler

我將最終的分析器命名為 tiny-profiler,源程式碼在 GitHub 上( MIT 許可)。這個分析器應該可以在任何帶有 JDK 17 或更新版本的平臺上工作。用法相當簡單:

# build itmvn package# run your program and print the table of methods sorted by their sample count# and the flame graph, taking a sample every 10msjava -javaagent:target/tiny-profiler.jar=flamegraph=flame.html ...你可以在renaissance dotty基準測試上運行,並創建如前所示的火焰圖:# download a benchmark> test -e renaissance.jar || wget https://github.com/renaissance-benchmarks/renaissance/releases/download/v0.14.2/renaissance-gpl-0.14.2.jar -O renaissance.jar> java -javaagent:./target/tiny_profiler.jar=flamegraph=flame.html -jar renaissance.jar dotty...===== method table ======Total samples: 11217Method                                      Samples Percentage  On top Percentagedotty.tools.dotc.typer.Typer.typed            59499     530.44       2       0.02dotty.tools.dotc.typer.Typer.typedUnadapted   31050     276.81       7       0.06scala.runtime.function.JProcedure1.apply      24283     216.48      13       0.12dotty.tools.dotc.Driver.process               19012     169.49       0       0.00dotty.tools.dotc.typer.Typer.typedUnnamed$1   18774     167.37       7       0.06dotty.tools.dotc.typer.Typer.typedExpr        18072     161.11       0       0.00scala.collection.immutable.List.foreach       16271     145.06       3       0.03...

此示例的開銷在我的 MacBook Pro 13″ 上大約為 2%,間隔為 10 毫秒,如果不考慮安全點偏差,結果是可接受的。

總結

綜上所述,用 240 行純 Java 編寫 Java 分析器完全可行,生成的分析器甚至可用於分析性能問題。這個分析器並不是為了取代 async-profiler 之類的分析器而設計的,我的目標是揭開分析器內部工作原理的神秘面紗。

相關文章

用一個小時編寫一個小程序

用一個小時編寫一個小程序

【CSDN 編者按】要想快速編寫程序,必須要認真考慮技能、工具選擇、攤銷和回報。 原文連結:https://buttondown.email...

有必要追求 100% 的單測覆蓋率嗎?

有必要追求 100% 的單測覆蓋率嗎?

【編者按】本文主要討論了一個項目的開發過程中對測試覆蓋率的要求以及其帶來的挑戰,強調了 100% 的測試覆蓋率的重要性和好處,尤其是在避免隱...

Kubernetes 真的很難嗎?

Kubernetes 真的很難嗎?

【CSDN 編者按】Kubernetes 提供了許多開箱即用的好東西,可以推進業務的發展。但這是否意味著,所有服務都要放到 Kubernet...

震驚!C 語言字串處理有很多坑?

震驚!C 語言字串處理有很多坑?

【CSDN 編者按】毋庸置疑,在使用 C 字串時必須小心,否則你就會因為各種的未定義行為而感到頭疼。 原文連結:https://www.de...

真正的Python多執行緒來了!

真正的Python多執行緒來了!

【CSDN 編者按】IBM工程師Martin Heinz發文表示,Python即將迎來了真正的多執行緒時刻! 原文:https://mart...

微軟計劃將 Windows 完整遷移到雲端

微軟計劃將 Windows 完整遷移到雲端

【編者按】微軟計劃通過 Windows 365 將 Windows 作業系統完整遷移到雲端,以提供基於人工智慧的高質量服務和實現無縫數字化生...

MySQL如何支撐每秒百萬QPS?

MySQL如何支撐每秒百萬QPS?

【編者按】本文主要介紹 PlanetScale 是如何通過 MySQL 的水平分片支撐每秒一百萬個查詢(QPS)的。 原文連結:https:...