【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類
首先,從代理入口點的實現著手:
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 類中處理:
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,由它來執行之後的後期處理。

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 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
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 :P
private 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">
var chart = flamegraph().width(window.innerWidth);
d3.select("#chart").datum(""");
writeAsJson(s, maxDepth);
s.print("""
).call(chart);
window.onresize =
() => chart.width(window.innerWidth);
""");
}

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