天天看點

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

作者:CSDN

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

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

未經授權,禁止轉載!

作者 | Johannes Bechberger 譯者 | 彎月責編 | 王子彧

出品 | CSDN(ID:CSDNnews)

幾個月前,我開始着手編寫分析器。如今,這些代碼已經變成了我的分析器驗證工具的基礎。 這個項目的唯一問題是:我想從零開始編寫一款非安全點偏差分析器。這其中涉及大量 C/C++/Unix 程式設計,但不是每個人都能閱讀 C/C++ 代碼。

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

什麼是安全點偏差?

安全點是 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:存儲并輸出采集到的結果。
編寫分析器不是造火箭,隻需 240 行代碼即可輸出火焰圖

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<Path> 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();           
編寫分析器不是造火箭,隻需 240 行代碼即可輸出火焰圖

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) {              // ...              }              @Override              public 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 threads              store.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 threads              store.addSample(stackTraceElements);              }              });           

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

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

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

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<Path> flamePath;              private final Map<String, Long> methodOnTopSampleCount =               new HashMap<>();              private final Map<String, Long> 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<Path> flamePath) {              this.flamePath = flamePath;              }              private String flattenStackTraceElement(              StackTraceElement stackTraceElement) {              // call intern to safe some memory              return (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<String> trace) {              for (int i = 0; i < trace.size(); i++) {              String method = trace.get(i);              updateMethodTables(method, i == 0);              }              }              public void addSample(StackTraceElement[] stackTraceElements) {              List<String> trace =               Stream.of(stackTraceElements)              .map(this::flattenStackTraceElement)              .toList();              updateMethodTables(trace);              if (flamePath.isPresent()) {              rootNode.addTrace(trace);              }              totalSampleCount++;              }              // the only reason this requires Java 17 :P              private record MethodTableEntry(              String method,               long sampleCount,               long onTopSampleCount) {              }              private void printMethodTable(PrintStream s,               List<MethodTableEntry> 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 的樹資料結構,然後再用這個資料結建構立漂亮的火焰圖,如下所示:

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

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

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

private static class Node {               private final String method;               private final Map<String, Node> 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<String> trace, int end) {               samples++;               if (end > 0) {               getChild(trace.get(end)).addTrace(trace, end - 1);               }               }                   public void addTrace(List<String> 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("""               <head>               <link rel="stylesheet"               type="text/css"               href="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-flamegraph.css">               </head>               <body>               <div id="chart"></div>               <script type="text/javascript"               src="https://d3js.org/d3.v7.js"></script>               <script type="text/javascript"               src="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-flamegraph.min.js"></script>               <script type="text/javascript">               var chart = flamegraph().width(window.innerWidth);               d3.select("#chart").datum(""");               writeAsJson(s, maxDepth);               s.print("""               ).call(chart);               window.onresize =               () => chart.width(window.innerWidth);               </script>               </body>               """);               }           
編寫分析器不是造火箭,隻需 240 行代碼即可輸出火焰圖

Tiny-Profiler

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

# build it              mvn package                  # run your program and print the table of methods sorted by their sample count              # and the flame graph, taking a sample every 10ms              java -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: 11217              Method Samples Percentage On top Percentage              dotty.tools.dotc.typer.Typer.typed 59499 530.44 2 0.02              dotty.tools.dotc.typer.Typer.typedUnadapted 31050 276.81 7 0.06              scala.runtime.function.JProcedure1.apply 24283 216.48 13 0.12              dotty.tools.dotc.Driver.process 19012 169.49 0 0.00              dotty.tools.dotc.typer.Typer.typedUnnamed$1 18774 167.37 7 0.06              dotty.tools.dotc.typer.Typer.typedExpr 18072 161.11 0 0.00              scala.collection.immutable.List.foreach 16271 145.06 3 0.03              ...           

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

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

總結

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