
Der Java Flight Recorder (JFR) zeichnet auf, was in einer laufenden JVM passiert: welche Methoden die CPU beschäftigen, wo Objekte entstehen, worauf Threads warten und wann der Garbage Collector läuft. Er ist Teil der JVM. Du brauchst nichts zu installieren und keinen Agenten anzuhängen.
Bis Java 10 gab es den Flight Recorder nur im kommerziellen Oracle JDK. Seit Java 11 ist er mit JEP 328 Teil des OpenJDK. Die JEPs nennen ihn inzwischen JDK Flight Recorder – gemeint ist dasselbe Werkzeug.
Was kostet die Aufzeichnung? Laut den Konfigurationsdateien, die das JDK mitbringt, typischerweise weniger als 1 % mit den Standard-Settings (default) und etwa 2 % mit den ausführlicheren Profiling-Settings (profile). Die Standard-Settings sind ausdrücklich für den Dauerbetrieb in Produktion gedacht. Läuft der Flight Recorder dort mit, hast du nach einem Vorfall die Daten der letzten Minuten oder Stunden – und musst den Fehler nicht erst reproduzieren.
In diesem Artikel erfährst du,
- wie du eine JFR-Aufzeichnung beim Start der JVM und an einer laufenden JVM startest,
- welche Events die Settings
defaultundprofileaufzeichnen, - wie der Flight Recorder seine Daten puffert und wie du den Ringpuffer im RAM und auf der Platte konfigurierst,
- wie du eine JFR-Aufzeichnung mit dem Kommando
jfröffnest, - wie du mit fünf Fragen an eine Aufzeichnung CPU-Last, Allokationen, Wartezeiten, ein Speicherleck und Exceptions findest,
- wie du einzelne Methoden exakt misst,
- wie du eigene Events schreibst und im laufenden Programm mitliest,
- was in einer JFR-Aufzeichnung steht, bevor du sie weitergibst.
Alle Ausgaben stammen aus Java 27. Ab welcher Version ein Werkzeug oder eine Option verfügbar ist, steht am Ende im Abschnitt JFR in den Java-Versionen.
Das Beispiel in diesem Artikel
Damit die Aufzeichnung etwas zu zeigen hat, verwende ich ein kleines Programm, das Bestellungen verarbeitet. Acht Worker-Threads erzeugen eine Minute lang zufällige Bestellungen und übergeben jede an die folgende Methode process():
void process(Order order) throws InterruptedException {
// eigenes JFR-Event, siehe Abschnitt „Eigene Events“
OrderEvent event = new OrderEvent();
event.orderId = order.id();
event.begin();
int quantity = parseQuantity(order.quantity());
if (!isBlocked(order.productId())) {
BigDecimal shippingCost = shippingClient.shippingCost(order);
long invoiceNumber = invoiceNumbers.next();
String receipt = formatReceipt(order, quantity, shippingCost, invoiceNumber);
receiptCache.put(order.id(), receipt);
}
event.commit();
processedOrders.incrementAndGet();
}
Für jede Bestellung
- liest
parseQuantity()die Menge aus einem Textfeld, - prüft
isBlocked(), ob das Produkt auf einer Sperrliste steht, - fragt
shippingCost()die Versandkosten per HTTP bei einem Versanddienst ab, - holt
next()die nächste Rechnungsnummer, - setzt
formatReceipt()den Text der Rechnung zusammen, - und schließlich legt
process()die Rechnung in einem Cache ab, damit spätere Nachfragen zur Bestellung schnell beantwortet werden können.
Das OrderEvent um diese Schritte herum erkläre ich im Abschnitt Eigene Events.
Die Hilfsmethoden sehen so aus:
private static int parseQuantity(String quantity) {
try {
return Integer.parseInt(quantity);
} catch (NumberFormatException _) {
return 1;
}
}
private boolean isBlocked(String productId) {
return blockedProducts.contains(productId);
}
private static String formatReceipt(
Order order, int quantity, BigDecimal shippingCost, long invoiceNumber) {
String receipt = "Invoice " + invoiceNumber + ", quantity " + quantity + "\n";
for (String line : order.lines()) {
receipt += line + "\n";
}
receipt += "Shipping: " + shippingCost + " EUR\n";
return receipt;
}
Im Durchschnitt kommt die Menge (quantity) jeder fünften Bestellung mit Einheit an, z. B. als "3 pcs" – was dann zu einer NumberFormatException führt, woraufhin die parseQuantity()-Methode den Fallback-Wert 1 zurückgibt.
blockedProducts ist eine ArrayList mit 200.000 Produktnummern, von denen keine in einer Bestellung des Beispiels vorkommt – contains() durchsucht also bei jeder Bestellung die ganze Liste. Eine Bestellung hat zwischen 50 und 149 Positionen, und receiptCache ist eine ConcurrentHashMap, aus der das Programm nie etwas entfernt.
Der Versanddienst läuft im selben Prozess. Es ist ein HTTP-Server aus dem Package com.sun.net.httpserver, der vor jeder Antwort 20 ms wartet und so einen entfernten Dienst simuliert. Der Client fragt ihn mit URL.openStream() ab:
BigDecimal shippingCost(Order order) {
URI uri = serviceUri.resolve("/cost?order=" + order.id());
try (var in = uri.toURL().openStream()) {
return new BigDecimal(new String(in.readAllBytes(), StandardCharsets.US_ASCII));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
Die Rechnungsnummer kommt aus einem Generator, der die letzte Nummer in einer Datenbank liest, erhöht und zurückschreibt. Den Datenbankzugriff simuliert eine Wartezeit von 5 ms:
class InvoiceNumberGenerator {
private long lastNumber;
// liest, erhöht und speichert die letzte Nummer in der Datenbank
synchronized long next() throws InterruptedException {
Thread.sleep(5); // simulierter Datenbankzugriff
return ++lastNumber;
}
}
Einige dieser Stellen sind absichtlich ungeschickt geschrieben – so, wie man sie in gewachsenem Code findet. Welche das sind und was sie kosten, soll die Aufzeichnung zeigen, nicht der Blick in den Code.
Den vollständigen Code findest du im GitHub-Repository java-flight-recorder, im Package eu.happycoders.jfr. Die Hauptklasse heißt OrderProcessing, ihr einziges Argument ist die Laufzeit in Sekunden. Nach mvn compile liegen alle Klassen im Verzeichnis target/classes; die Ausgaben der Aufzeichnung, die ich im Kapitel Fünf Fragen an die Aufzeichnung auswerte, stehen im Verzeichnis results/.
Eine Aufzeichnung starten
Es gibt zwei Wege, um eine Aufzeichnung zu starten: Entweder startest du sie zusammen mit der JVM, oder du startest sie später in einer JVM, die schon läuft.
Beim Start der JVM: -XX:StartFlightRecording
Mit der VM-Option -XX:StartFlightRecording beginnt die Aufzeichnung, sobald die JVM läuft. Die Parameter folgen nach einem Doppelpunkt (oder einem Gleichheitszeichen), durch Kommas getrennt:
$ java -XX:StartFlightRecording:filename=orders.jfr \
-cp target/classes eu.happycoders.jfr.OrderProcessing 60
[0.121s][info][jfr,startup] Started recording 1. No limit specified, using maxsize=250MB as default.
[0.121s][info][jfr,startup]
[0.121s][info][jfr,startup] Use jcmd 99911 JFR.dump name=1 to copy recording data to file.
Processed 8.557 orders
Die 60 am Ende gehört nicht zum Flight Recorder: Sie ist das Argument des Beispielprogramms, das 60 Sekunden lang Bestellungen verarbeitet und sich dann beendet.
Wenn sich die JVM beendet, schreibt sie die Datei orders.jfr. Gibst du filename an, macht sie das auch ohne den Parameter dumponexit=true; erst mit dumponexit=false schreibt sie die Datei nicht. Ohne filename geht die Aufzeichnung beim Beenden dagegen verloren – es sei denn, du setzt dumponexit=true. Dann legt die JVM im Arbeitsverzeichnis eine Datei mit einem generierten Namen an, z. B. hotspot-pid-99344-id-1-2026_10_27_23_23_51.jfr.
Die folgende Tabelle zeigt alle Parameter. Die Spalte „Default“ nennt den Wert, der gilt, wenn du den Parameter weglässt:
| Parameter | Bedeutung | Default |
|---|---|---|
filename | Datei, in die die JVM die Aufzeichnung schreibt | keiner |
name | Name, unter dem du die Aufzeichnung mit jcmd ansprichst | eine Nummer |
duration | Dauer der Aufzeichnung | 0s (unbegrenzt) |
delay | Wartezeit, bevor die Aufzeichnung beginnt | 0s |
settings | default, profile, none oder der Pfad zu einer .jfc-Datei | default |
disk | Daten fortlaufend auf die Platte schreiben | true |
maxage | maximales Alter der Daten auf der Platte | unbegrenzt |
maxsize | maximale Größe der Daten auf der Platte | 250 MB, wenn auch maxage fehlt |
dumponexit | beim Beenden der JVM in eine Datei schreiben | false |
path-to-gc-roots | für mögliche Speicherlecks den Pfad zur GC-Root ermitteln | false |
report-on-exit | beim Beenden eine Auswertung auf der Konsole ausgeben | keine |
Die Parameter settings, disk, maxage und maxsize erkläre ich in den nächsten Abschnitten, path-to-gc-roots im Abschnitt über Speicherlecks und report-on-exit beim Öffnen ohne Datei.
delay ist das Gegenstück zu duration: Mit delay beginnt die Aufzeichnung erst nach der angegebenen Wartezeit. Das ist sinnvoll, wenn du den laufenden Betrieb untersuchen willst und nicht die Startphase der Anwendung. In den ersten Sekunden lädt die JVM noch Klassen, und der Code läuft zunächst im Interpreter, bis der JIT-Compiler die häufig aufgerufenen Methoden nach und nach in Maschinencode übersetzt hat. Die Anwendung ist in dieser Phase langsamer als später. Zeichnest du die Startphase mit auf, verzerrt sie die Messwerte für den laufenden Betrieb.
Im Beispielprogramm sieht man das deutlich: Die ersten acht Bestellungen – je eine pro Worker-Thread – dauern zwischen 77 und 129 ms. Schon ab der neunten Bestellung sind es knapp 60 ms, und über die ganze Minute liegt der Median bei 56 ms. Die Dauer jeder Bestellung stammt aus dem eigenen Event eu.happycoders.Order, das ich im Abschnitt Eigene Events vorstelle.
So zeichnest du 20 Sekunden auf, beginnend zehn Sekunden nach dem Start:
$ java -XX:StartFlightRecording=delay=10s,duration=20s,filename=delayed.jfr \
-cp target/classes eu.happycoders.jfr.OrderProcessing 40
[0.098s][info][jfr,startup] Recording 1 scheduled to start in 10 s. The result will be written to:
[0.098s][info][jfr,startup]
[0.098s][info][jfr,startup] <Pfad>/delayed.jfr
An einer laufenden JVM: jcmd
Läuft die Anwendung schon, startest du die Aufzeichnung mit jcmd. Dafür brauchst du die Prozess-ID, und die bekommst du, wenn du jcmd ohne Argumente aufrufst – zusammen mit der Hauptklasse jeder laufenden JVM:
$ jcmd
96592 jdk.jcmd/sun.tools.jcmd.JCmd
96574 eu.happycoders.jfr.OrderProcessing 90
JFR.start beginnt dann die Aufzeichnung. Es versteht dieselben Parameter wie -XX:StartFlightRecording – bis auf report-on-exit:
$ jcmd 96574 JFR.start name=orders
96574:
Started recording 1. No limit specified, using maxsize=250MB as default.
Use jcmd 96574 JFR.dump name=orders filename=FILEPATH to copy recording data to file.
JFR.check zeigt, welche Aufzeichnungen laufen:
$ jcmd 96574 JFR.check
96574:
Recording 1: name=orders maxsize=250,0MB (running)
JFR.dump schreibt den bisherigen Inhalt in eine Datei, ohne die Aufzeichnung anzuhalten. Im folgenden Beispiel habe ich zweimal gedumpt, mit 15 Sekunden Abstand:
$ jcmd 96574 JFR.dump name=orders filename=dump1.jfr
96574:
Dumped recording "orders", 995,3 kB written to:
<Pfad>/dump1.jfr
$ jcmd 96574 JFR.dump name=orders filename=dump2.jfr
96574:
Dumped recording "orders", 1,8 MB written to:
<Pfad>/dump2.jfr
Beide Dateien beginnen beim Start der Aufzeichnung: dump1.jfr umfasst 21 Sekunden, dump2.jfr 36 Sekunden. Ein Dump kopiert also den aktuellen Inhalt – er leert den Puffer nicht.
JFR.stop beendet die Aufzeichnung und schreibt sie, wenn du einen Dateinamen angibst, ein letztes Mal:
$ jcmd 96574 JFR.stop name=orders filename=stop.jfr
96574:
Stopped recording "orders", 2,2 MB written to:
<Pfad>/stop.jfr
Für keinen dieser Schritte – JFR.start, JFR.check, JFR.dump und JFR.stop – musste die Anwendung neu starten. Das ist der Weg, um ein Problem zu analysieren, das gerade in Produktion auftritt.
Was aufgezeichnet wird: default und profile
Welche Events der Flight Recorder aufzeichnet, legt eine .jfc-Datei fest. Welche Datei er verwendet, wählst du mit dem Parameter settings, z. B. mit -XX:StartFlightRecording:settings=profile beim Start der JVM oder mit jcmd <PID> JFR.start settings=profile an einer laufenden JVM. Das JDK bringt im Verzeichnis lib/jfr zwei solche Dateien mit:
default.jfc: Laut ihrer eigenen Beschreibung ist sie für den Dauerbetrieb in Produktion gedacht, mit typischerweise unter 1 % Overhead. Sie gilt, wenn dusettingsweglässt.profile.jfc: Sie ist für das gezielte Profiling gedacht, mit typischerweise etwa 2 % Overhead.
Die beiden unterscheiden sich vor allem in Intervallen, Schwellenwerten und Obergrenzen. Die folgende Tabelle zeigt die Werte aus Java 27 für die Events, die in diesem Artikel vorkommen:
| Event | default | profile |
|---|---|---|
jdk.ExecutionSample (Stichproben für das CPU-Profil) | alle 20 ms | alle 10 ms |
jdk.JavaMonitorEnter (Warten auf synchronized) | ab 20 ms | ab 10 ms |
jdk.SocketRead, jdk.FileRead und die Schreib-Pendants | ab 1 ms, höchstens 100/s | ab 1 ms, höchstens 300/s |
jdk.ObjectAllocationSample (Stichproben der Allokationen) | höchstens 150/s | höchstens 300/s |
jdk.JavaExceptionThrow | höchstens 100/s | höchstens 300/s |
jdk.OldObjectSample (mögliche Speicherlecks) | ohne Stacktrace | mit Stacktrace |
„Ab 20 ms“ oder „ab 1 ms“ bezeichnet einen Schwellenwert, in der .jfc-Datei heißt er threshold: Der Flight Recorder zeichnet ein solches Event nur auf, wenn der Vorgang mindestens so lange gedauert hat. Kürzere Wartezeiten und Lesevorgänge lässt er weg – das hält die Zahl der Events klein.
Die Obergrenzen pro Sekunde gibt es seit Java 25 – mehr dazu im Abschnitt Rate-limited Sampling des Artikels über Java 25. Sie bedeuten: Eine Anzahl in der Aufzeichnung ist nicht unbedingt die Anzahl dessen, was passiert ist. Im Beispiel ruft das Programm für jede Bestellung den Versanddienst auf. Der antwortet erst nach 20 ms, der Lesevorgang liegt also über dem Schwellenwert von 1 ms, und der Flight Recorder zeichnet ihn auf. Trotzdem stehen nach einer Minute mit default für 8.557 Bestellungen nur 5.773 Socket-Lesevorgänge in der Aufzeichnung – denn mehr als 100 pro Sekunde zeichnet default nicht auf. Mit profile dagegen hat der Flight Recorder für 8.588 Bestellungen alle 8.588 Lesevorgänge aufgezeichnet.
Einzelne Events und Optionen anpassen
Seit Java 17 kannst du die Einstellungen für einzelne Events direkt auf der Kommandozeile umstellen, in der Form <Event>#<Setting>=<Wert>. So zeichnest du beispielsweise Wartezeiten an synchronized-Blöcken schon ab 5 ms auf (statt ab 20 ms in default und ab 10 ms in profile):
$ java -XX:StartFlightRecording:jdk.JavaMonitorEnter#threshold=5ms,filename=orders.jfr ...
Die .jfc-Dateien enthalten außerdem Optionen, die mehrere Events auf einmal einstellen, z. B. locking-threshold für alle Wartezeiten an Locks oder memory-leaks für die Aufzeichnung möglicher Speicherlecks. Welche Optionen es gibt, zeigt jfr help configure. Auch diese Optionen kannst du direkt auf der Kommandozeile angeben:
$ java -XX:StartFlightRecording:locking-threshold=5ms,filename=orders.jfr ...
Brauchst du eine Kombination öfter, schreibst du sie mit jfr configure in eine eigene .jfc-Datei. Das folgende Kommando nimmt profile als Grundlage, ändert zwei Optionen (locking-threshold und memory-leaks) und schreibt die neue Konfiguration dann in die Datei orders.jfc:
$ jfr configure --input profile locking-threshold=5ms memory-leaks=gc-roots \
--output orders.jfc
Configuration written successfully to:
<Pfad>/orders.jfc
Danach startest du die Aufzeichnung mit settings=orders.jfc.
Wohin die Events gehen: Puffer, Chunks und Datei
Ein Event landet nicht direkt in der .jfr-Datei. Es durchläuft vier Stationen, und die Größe der ersten drei Stationen legt je ein Parameter fest:
- Jeder Thread schreibt seine Events zuerst in einen eigenen Thread-Puffer. Dessen Größe legt
threadbuffersizefest. Der Default ist 8 KB, aufgerundet auf eine ganze Speicherseite – auf einem Mac mit Apple-Prozessor, dessen Speicherseiten 16 KB groß sind, sind es daher 16 KB. - Ist der Thread-Puffer voll, überträgt die JVM seinen Inhalt in einen der globalen Puffer. Davon gibt es standardmäßig 20 zu je 512 KB. Zusammen sind sie so groß wie
memorysize, standardmäßig 10 MB. - Ist ein globaler Puffer voll, schreibt ein Thread des Flight Recorders ihn in eine Chunk-Datei im sogenannten Repository auf der Platte. Ein Chunk wächst bis
maxchunksize, standardmäßig 12 MB, dann beginnt der nächste. - Schließlich fügt die JVM bei einem Dump, bei
JFR.stopoder beim Beenden die Chunks zur.jfr-Datei zusammen.
Das Diagramm zeigt diese Stationen von links nach rechts, mit den Parametern, die ihre Größe festlegen. Der gestrichelte Rahmen fasst die Chunk-Dateien im Repository zusammen:
Das Repository liegt im Temp-Verzeichnis des Betriebssystems, in einem Unterverzeichnis aus Datum, Uhrzeit und Prozess-ID. Wo genau, verrät die System-Property jdk.jfr.repository:
$ jcmd 96574 VM.system_properties | grep jdk.jfr.repository
jdk.jfr.repository=/private/var/folders/wt/np9sw9515z117kn393z4t4k00000gn/T/2026_10_27_23_00_50_96574
Wie viele Events passen in eine Station? Der Flight Recorder zählt keine Events, er zählt Bytes. In der Aufzeichnung des Beispiels belegen 8.588 OrderEvents 156.953 Bytes – rund 18 Bytes pro Event. Ein jdk.SocketRead-Event belegt mit Host, Port und dem Verweis auf seinen Stacktrace rund 43 Bytes. Die ganze Minute mit profile ergibt eine Datei von 2,7 MB.
Die Größen der Puffer und Chunks stellst du mit -XX:FlightRecorderOptions ein, z. B. -XX:FlightRecorderOptions:memorysize=20m.
Der Ringpuffer: auf der Platte oder im RAM
Erreicht eine Aufzeichnung ihre Grenze, löscht der Flight Recorder die ältesten Daten – er arbeitet als Ringpuffer. Wo dieser Ring liegt, entscheidet der Parameter disk. Es ist immer genau eine Stelle.
Mit disk=true, dem Default, ist der RAM nur Durchgangsstation, und der Ring ist das Repository auf der Platte. maxage und maxsize begrenzen ihn. Setzt du keine der beiden Grenzen, nimmt die JVM 250 MB – das meldet die Log-Zeile „No limit specified, using maxsize=250MB as default“.
Dabei löscht der Flight Recorder immer ganze Chunks. Das hat eine Folge, die bei wenig Daten auffällt: Mit maxage=20s enthielt ein Dump nach 64 Sekunden trotzdem alle 64 Sekunden. Das Beispiel schreibt mit default gut 30 KB pro Sekunde, der erste Chunk war also nach einer Minute noch lange nicht bei seinen 12 MB – und ein Chunk, der noch wächst, wird nicht gelöscht.
Mit maxchunksize=1m sieht man den Ring arbeiten. Die Tabelle zeigt die Chunk-Dateien im Repository mit ihrer Größe:
| nach | Chunk-Dateien im Repository |
|---|---|
| 34 s | 2026_10_27_23_07_38.jfr (1.296.447 Bytes)2026_10_27_23_08_07.jfr (394.770 Bytes) |
| 64 s | 2026_10_27_23_08_07.jfr (1.295.986 Bytes)2026_10_27_23_08_38.jfr (352.777 Bytes) |
| 94 s | 2026_10_27_23_08_38.jfr (1.309.288 Bytes)2026_10_27_23_09_11.jfr (254.771 Bytes) |
Jede Datei trägt die Startzeit ihres Chunks im Namen. Bei gut 1 MB beginnt ein neuer Chunk, und ein Chunk, dessen Ende länger als maxage zurückliegt, fällt aus dem Ring. Ein Dump nach 124 Sekunden mit maxage=30s enthielt nur noch den jüngsten Chunk, 2026_10_27_23_09_11.jfr, und damit die Daten der letzten 32 Sekunden.
Bei maxage enthält ein Dump also mindestens die Daten der letzten maxage. Weil der Flight Recorder nur ganze Chunks löscht, können es auch mehr sein – im Beispiel 32 statt 30 Sekunden. Bei maxsize bleibt er dagegen unter der Grenze: Mit maxsize=2m lagen nie mehr als zwei Chunk-Dateien im Repository, und ein Dump nach drei Minuten enthielt zwei Chunks mit zusammen 1,8 MB und 34 Sekunden.
Mit disk=false schreibt der Flight Recorder nichts auf die Platte. Der Ring sind dann die globalen Puffer im RAM, und ihre Größe ist memorysize, standardmäßig 10 MB. maxage und maxsize wirken nicht, die JVM warnt beim Start:
[0.118s][warning][jfr,start] Option maxage has no effect with disk=false.
Im Beispiel reichen die 10 MB für die ganze Minute. Mit memorysize=1m und einem Dump nach 62 Sekunden fehlten dagegen die ersten 19 Sekunden: Das erste OrderEvent im Dump stammt von 23:11:29, die Aufzeichnung hatte um 23:11:10 begonnen. jfr summary zeigt als Start trotzdem 23:11:10 – die Startzeit der Aufzeichnung, nicht die des ältesten Events, das noch übrig ist.
Ich empfehle für den Dauerbetrieb in Produktion disk=true mit einem maxage, das die Zeit abdeckt, die du nach einem Vorfall zurückblicken willst, und einem maxsize, das dein Plattenplatz verträgt:
$ java -XX:StartFlightRecording:name=continuous,maxage=1h,maxsize=500m ...
disk=false lohnt sich, wo die Anwendung nicht auf die Platte schreiben soll oder kann. Dann solltest du memorysize so groß wählen, dass die Zeit hineinpasst, die du brauchst – wie viel das ist, zeigt die Größe einer Probeaufzeichnung.
Drei Ebenen der Konfiguration
Die Parameter dieses Kapitels gehören zu drei Ebenen, und jede Ebene stellst du an einer anderen Stelle ein:
| Ebene | Wo | Parameter |
|---|---|---|
| eine Aufzeichnung | -XX:StartFlightRecording,jcmd … JFR.start | filename, name, duration, delay, settings, disk, maxage, maxsize, dumponexit, path-to-gc-roots |
| die ganze JVM | -XX:FlightRecorderOptions,jcmd … JFR.configure | memorysize, maxchunksize, threadbuffersize (nur über -XX:FlightRecorderOptions), repository (bei JFR.configure: repositorypath), stackdepth, preserve-repository |
| einzelne Events | .jfc-Datei, <Event>#<Setting>=<Wert>, .jfc-Optionen | Intervalle, Schwellenwerte, Obergrenzen, Stacktraces |
Mehrere Aufzeichnungen können gleichzeitig laufen, mit eigenen Grenzen und eigenen Settings. Sie teilen sich aber die Thread-Puffer und die globalen Puffer der JVM. Deren Größen lassen sich nicht mehr ändern, sobald der Flight Recorder gestartet ist – JFR.configure wirkt für sie also nur, bevor die erste Aufzeichnung startet.
stackdepth legt fest, wie viele Frames ein Stacktrace höchstens hat, standardmäßig 64.
Eine Aufzeichnung öffnen
Eine .jfr-Datei ist binär. Du öffnest sie mit dem Kommando jfr aus dem JDK oder mit JDK Mission Control.
Das Kommando jfr: summary, view, print
jfr summary zeigt, welche Events wie oft in der Datei stehen. Die folgende Ausgabe gehört zur Aufzeichnung mit profile, die ich im nächsten Kapitel auswerte; ich habe sie nach den ersten sechs Event-Typen abgeschnitten:
$ jfr summary orders.jfr
Version: 2.1
Chunks: 1
Start: 2026-10-27 23:15:37 (UTC)
Duration: 60 s
Event Type Count Size (bytes)
=============================================================
jdk.ObjectAllocationSample 17098 265113
jdk.GCPhaseParallel 15307 394394
jdk.ThreadSleep 8602 175674
eu.happycoders.Order 8588 156953
jdk.SocketRead 8588 372966
jdk.JavaMonitorEnter 8557 200485
...
Oben stehen die Version des Dateiformats, die Zahl der Chunks sowie Beginn (in UTC) und Dauer der Aufzeichnung, darunter für jeden Event-Typ, wie oft er vorkommt und wie viele Bytes er in der Datei belegt – absteigend sortiert nach der Anzahl.
jfr view fasst die Events zu Tabellen zusammen. In Java 27 kennt es 82 vordefinierte Views, gruppiert in Sichten auf die JVM (z. B. gc-pauses, safepoints), auf die Umgebung (z. B. cpu-load, container-memory-usage) und auf die Anwendung (z. B. hot-methods, contention-by-site). jfr help view listet sie alle auf. Der Aufruf für hot-methods beispielsweise lautet:
$ jfr view hot-methods orders.jfr
Die Ausgabe dieses Aufrufs zeige und erkläre ich im Abschnitt Wo verbraucht die Anwendung CPU-Zeit?.
Mit --width legst du die Breite der Tabelle in Zeichen fest. Alle Tabellen in diesem Artikel habe ich mit --width 86 erzeugt, damit sie ohne horizontales Scrollen in die Spalte passen; an manchen Stellen kürzt jfr dadurch einen Methodennamen mit (...).
jfr print gibt die einzelnen Events aus. --events wählt sie aus, --stack-depth legt fest, wie viele Frames eines Stacktraces erscheinen (standardmäßig fünf), und --json bzw. --xml liefert die Events maschinenlesbar:
$ jfr print --events jdk.SocketRead --stack-depth 12 orders.jfr
Ein jdk.SocketRead-Event aus dieser Ausgabe findest du im Abschnitt Worauf warten die Threads?, die Ausgaben für die anderen Event-Typen in den übrigen Abschnitten des nächsten Kapitels.
Ohne Datei: JFR.view und report-on-exit
Für eine View brauchst du nicht unbedingt eine Datei. Mit jcmd … JFR.view kannst du sie auch direkt aus einer laufenden Aufzeichnung heraus anzeigen (die Breite heißt hier width=):
$ jcmd 99816 JFR.view width=86 hot-methods
99816:
Java Methods that Execute the Most
Method Samples Percent
--------------------------------------------------------------------- ------- -------
java.util.ArrayList.indexOfRange(Object, int, int) 109 43,60%
eu.happycoders.jfr.OrderService.formatReceipt(...) 47 18,80%
java.util.ArrayList.add(Object, Object[], int) 8 3,20%
java.lang.StringConcatHelper.newArrayWithSuffix(String, int, byte) 5 2,00%
java.util.Objects.requireNonNull(Object) 3 1,20%
Und seit Java 25 gibt report-on-exit eine View aus, wenn die JVM sich beendet – praktisch für einen Testlauf, nach dem du nur eine Frage beantwortet haben willst. Die View contention-by-site zum Beispiel zeigt, an welchen Stellen im Code Threads darauf gewartet haben, einen synchronized-Block betreten zu dürfen – wie oft, wie lange im Mittel und wie lange höchstens (mehr dazu im Abschnitt Worauf warten die Threads?). So gibst du sie am Ende eines Laufs aus:
$ java -XX:StartFlightRecording:report-on-exit=contention-by-site \
-cp target/classes eu.happycoders.jfr.OrderProcessing 20
...
Processed 2.857 orders
Contention by Site
StackTrace Count Avg. Max.
--------------------------------------------------------- ----- ------- -------
eu.happycoders.jfr.InvoiceNumberGenerator.next() 2.824 26,5 ms 46,9 ms
report-on-exit darf mehrmals vorkommen, einmal pro View.
JDK Mission Control
JDK Mission Control (JMC) ist die grafische Oberfläche für den Flight Recorder. Es ist nicht Teil des JDK, du musst es separat herunterladen. JMC öffnet .jfr-Dateien, verbindet sich mit laufenden JVMs und startet dort Aufzeichnungen, und seine Seite „Automated Analysis“ bewertet eine Aufzeichnung automatisch nach bekannten Problemmustern.
Für alles, was in diesem Artikel vorkommt, reicht jfr. Wie du dieselbe Aufzeichnung in JMC auswertest – mit Flame Graph, Allokationsbaum und GC-Zeitachse –, werde ich in einem eigenen Artikel über JDK Mission Control zeigen.
Fünf Fragen an die Aufzeichnung
Für die Auswertung habe ich das Beispiel mit settings=profile aufgezeichnet – für einen gezielten Profiling-Lauf ist das die richtige Wahl:
$ java -XX:StartFlightRecording:filename=orders.jfr,settings=profile \
-cp target/classes eu.happycoders.jfr.OrderProcessing 60
...
Processed 8.588 orders
Jeder der folgenden Abschnitte beantwortet eine Frage an diese Datei.
Wo verbraucht die Anwendung CPU-Zeit?
Die View hot-methods zeigt, in welchen Methoden die Threads am häufigsten angetroffen wurden:
$ jfr view hot-methods orders.jfr
Java Methods that Execute the Most
Method Samples Percent
--------------------------------------------------------------------- ------- -------
java.util.ArrayList.indexOfRange(Object, int, int) 406 41,30%
eu.happycoders.jfr.OrderService.formatReceipt(...) 151 15,36%
eu.happycoders.jfr.OrderService.process(Order) 128 13,02%
java.util.ArrayList.grow(int) 17 1,73%
java.lang.String$$StringConcat.0x00000700010c8000.prepend(...) 10 1,02%
Die Tabelle beruht auf Sampling: Alle 10 ms (bei default alle 20 ms) liest der Flight Recorder die Stacktraces der Threads, die gerade Java-Code ausführen, und speichert sie als jdk.ExecutionSample. Die Spalte „Samples“ gibt an, wie oft eine Methode dabei ganz oben auf dem Stack stand. Threads, die warten – auf einen Lock, auf eine Antwort über das Netzwerk, auf Thread.sleep() –, tauchen in dieser Statistik nicht auf.
Ganz oben steht mit 41 % keine Methode des Beispiels, sondern ArrayList.indexOfRange() aus dem JDK. Das ist häufig so: Die Zeit vergeht in einer Methode des JDK, die Ursache liegt im Aufrufer. Den zeigt jfr print mit dem Stacktrace eines Samples – ein solches Sample folgt weiter unten.
Vorher fällt aber eine zweite Zeile auf: process() mit 13 %, obwohl process() selbst nur andere Methoden aufruft. 125 der 128 Samples, die jfr print für process() ganz oben im Stack zeigt, tragen die Zeilennummer 44 – das ist die Zeile mit dem Aufruf von isBlocked().
Der Grund liegt im JIT-Compiler. Er hat isBlocked() samt contains() und indexOfRange() in process() eingebaut (Inlining). Wo im Maschinencode welche Methode und welche Zeile beginnt, speichert der JIT-Compiler standardmäßig nur für bestimmte Stellen, im Wesentlichen für die Safepoints. Ein Sample, das zwischen zwei solchen Stellen landet, ordnet der Flight Recorder der nächsten solchen Stelle zu – hier dem Aufruf von isBlocked() in process().
Die Diagnose-Option -XX:+DebugNonSafepoints lässt den JIT-Compiler diese Zuordnung für den ganzen Maschinencode speichern. Sie ist nur zusammen mit -XX:+UnlockDiagnosticVMOptions erlaubt:
$ java -XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints \
-XX:StartFlightRecording:filename=orders-dns.jfr,settings=profile \
-cp target/classes eu.happycoders.jfr.OrderProcessing 60
...
$ jfr view hot-methods orders-dns.jfr
Java Methods that Execute the Most
Method Samples Percent
--------------------------------------------------------------------- ------- -------
java.lang.String.equals(Object) 300 32,93%
eu.happycoders.jfr.OrderService.formatReceipt(...) 132 14,49%
java.util.ArrayList.indexOfRange(Object, int, int) 126 13,83%
eu.happycoders.jfr.OrderService.process(Order) 53 5,82%
java.util.Random.next(int) 10 1,10%
Jetzt steht String.equals() oben, und process() ist auf 6 % gefallen. Im Folgenden gibt jfr print alle Events vom Typ jdk.ExecutionSample aus. Darin habe ich ein Sample gesucht, in dem String.equals() ganz oben steht – es zeigt die ganze Kette bis in den Code des Beispiels:
$ jfr print --events jdk.ExecutionSample --stack-depth 8 orders-dns.jfr
...
jdk.ExecutionSample {
startTime = 23:16:38.545 (2026-10-27)
sampledThread = "pool-1-thread-8" (javaThreadId = 52)
state = "STATE_RUNNABLE"
stackTrace = [
java.lang.String.equals(Object) line: 2170
java.util.ArrayList.indexOfRange(Object, int, int) line: 300
java.util.ArrayList.indexOf(Object) line: 287
java.util.ArrayList.contains(Object) line: 276
eu.happycoders.jfr.OrderService.isBlocked(String) line: 64
eu.happycoders.jfr.OrderService.process(Order) line: 44
eu.happycoders.jfr.OrderService.processUntil(Instant, OrderGenerator) line: 30
eu.happycoders.jfr.OrderProcessing.lambda$main$0(OrderService, Instant, OrderGenerator) line: 22
...
]
}
...
String.equals() und indexOfRange() zusammen ergeben (in der Ausgabe von jfr view hot-methods zuvor) 47 % aller Samples: Fast die Hälfte der Zeit, in der das Programm Java-Code ausführt, geht in die lineare Suche durch die Sperrliste. Ein HashSet statt der ArrayList macht daraus eine vermutlich deutlich schnellere Hash-Abfrage.
Ich empfehle -XX:+DebugNonSafepoints für jeden Profiling-Lauf, in dem du nach der Methode oder Zeile suchst, die Zeit kostet. Ohne die Option landen die Samples inlinter Methoden beim Aufrufer – im Beispiel bei process() statt bei String.equals().
Das Sampling nimmt seine Stichproben übrigens in festen Abständen der Uhrzeit und zählt dabei nur Threads, die gerade Java-Code ausführen. Seit Java 25 kann der Flight Recorder zusätzlich Stichproben nach verbrauchter CPU-Zeit nehmen, mit dem Event jdk.CPUTimeSample – vorerst experimentell und nur unter Linux. Auf macOS meldet die JVM beim Einschalten „CPU time method sampling not supported in JFR on your platform“. Mehr dazu im Abschnitt JFR CPU-Time Profiling des Artikels über Java 25.
Welcher Code erzeugt die meisten Objekte?
Objekt-Allokationen zeichnet der Flight Recorder ebenfalls als Stichproben auf, als jdk.ObjectAllocationSample. Jede Stichprobe trägt ein Gewicht (weight): die Bytes, die der Thread seit seiner vorigen Stichprobe allokiert hat – meist für viele Objekte, nicht nur für das eine, bei dem die Stichprobe genommen wurde. Die Summe der Gewichte über viele Stichproben ergibt laut der Beschreibung des Events ein statistisch genaues Bild. Die View allocation-by-class summiert die Gewichte nach Klasse:
$ jfr view allocation-by-class orders.jfr
Allocation by Class
Object Type Allocation Pressure
----------------------------------------------------------------- -------------------
byte[] 87,20%
java.lang.String 3,43%
char[] 1,26%
java.lang.String[] 0,89%
sun.net.www.protocol.http.HttpURLConnection 0,69%
87 % der allokierten Bytes sind byte[]. Woher die Allokationen kommen – über alle Klassen hinweg –, zeigt die View allocation-by-site:
$ jfr view allocation-by-site orders.jfr
Allocation by Site
Method Allocation Pressure
----------------------------------------------------------------- -------------------
jdk.internal.misc.Unsafe.allocateUninitializedArray(Class, int) 77,53%
java.io.InputStream.readNBytes(int) 2,98%
java.util.Arrays.copyOfRange(byte[], int, int) 1,49%
java.io.BufferedInputStream.getBufIfOpen(boolean) 1,26%
java.lang.String$$StringConcat.0x00000700010c8000.concat(...) 1,26%
Die oberste Zeile betrifft trotzdem nur byte[]: Alle Stichproben mit allocateUninitializedArray() ganz oben tragen die Klasse byte[]. Auch hier steht eine JDK-Methode oben, denn auch allocation-by-site gruppiert nach dem obersten Frame. Den Aufrufer zeigt wieder jfr print:
$ jfr print --events jdk.ObjectAllocationSample --stack-depth 6 orders.jfr
...
jdk.ObjectAllocationSample {
Context: Order.orderId = 200
startTime = 23:15:37.921 (2026-10-27)
objectClass = byte[] (classLoader = bootstrap)
weight = 351,6 kB
eventThread = "pool-1-thread-1" (javaThreadId = 45)
stackTrace = [
jdk.internal.misc.Unsafe.allocateUninitializedArray(Class, int) line: 1422
java.lang.StringConcatHelper.newArray(int) line: 283
java.lang.StringConcatHelper.newArrayWithSuffix(String, int, byte) line: 309
eu.happycoders.jfr.OrderService.formatReceipt(Order, int, BigDecimal, long) line: 71
eu.happycoders.jfr.OrderService.process(Order) line: 47
eu.happycoders.jfr.OrderService.processUntil(Instant, OrderGenerator) line: 30
]
}
...
Laut Stacktrace kommt die Allokation aus der Methode formatReceipt(), Zeile 71. Diese Zeile ist receipt += line + "\n". Jede dieser Verkettungen erzeugt einen neuen String mit einem neuen byte[], das den ganzen bisherigen Text kopiert – bei 149 Positionen 149-mal. Ein StringBuilder oder String.join() erzeugt den Text hingegen in einem Durchgang.
Die Zeile Context: Order.orderId = 200 kommt vom eigenen Event des Beispiels; was es damit auf sich hat, zeigt der Abschnitt Eigene Events.
Worauf warten die Threads?
Die beiden vorigen Fragen betrafen nur die Rechenzeit der Threads. Eine Bestellung dauert im Beispiel aber im Median 56 ms, und nur den kleinsten Teil davon rechnet der Thread. Was in der restlichen Zeit passiert, zeigt die View latencies-by-type:
$ jfr view latencies-by-type orders.jfr
Latencies by Type
Event Type Count Average P 99 Longest Total
---------------------------------------------- ----- ------- ------- ------- --------
Java Thread Sleep 8.602 27,4 ms 22,6 ms 5,01 s 3 m 56 s
Java Monitor Blocked 8.557 26,6 ms 34,4 ms 52,6 ms 3 m 47 s
Java Thread Park 1.006 226 ms 328 ms 1 m 0 s 3 m 47 s
Socket Read 8.588 21,3 ms 22,9 ms 50,2 ms 3 m 3 s
Java Monitor Wait 6 10,0 s 10,0 s 10,0 s 1 m 0 s
Zwei Zeilen gehören zu den Worker-Threads, also den acht Threads, die die Bestellungen verarbeiten:
- Java Monitor Blocked: 8.557-mal hat ein Thread darauf gewartet, einen
synchronized-Block betreten zu dürfen, im Mittel 26,6 ms lang. - Socket Read: 8.588-mal hat ein Thread auf Daten aus dem Netzwerk gewartet, im Mittel 21,3 ms lang.
Zusammen sind das im Mittel 48 ms – bei einer Bestellung, die im Mittel 56 ms dauert. Die übrigen 8 ms tauchen in der Tabelle nicht auf: 5 ms davon hält der Thread selbst den Lock, während der Generator die Rechnungsnummer aus der simulierten Datenbank holt. Diese Wartezeit liegt unter dem Schwellenwert von 10 ms, ab dem profile „Java Thread Sleep“ aufzeichnet. Die restlichen rund 3 ms rechnet der Thread: Sperrliste, HTTP-Anfrage und Rechnungstext.
Die übrigen Zeilen der View latencies-by-type stammen aus dem Testaufbau und dem JDK: Fast alle Einträge unter „Java Thread Sleep“ kommen aus dem simulierten Versanddienst, der vor jeder Antwort 20 ms schläft; der längste „Java Thread Park“ mit einer Minute ist der main-Thread, der auf das Ende der Worker wartet.
Welcher synchronized-Block die Threads aufhält, zeigt die View contention-by-site:
$ jfr view contention-by-site orders.jfr
Contention by Site
StackTrace Count Avg. Max.
--------------------------------------------------------------- ----- ------- -------
eu.happycoders.jfr.InvoiceNumberGenerator.next() 8.557 26,6 ms 52,6 ms
Es gibt nur eine solche Stelle, die Methode InvoiceNumberGenerator.next(). An ihr haben die Threads 8.557-mal gewartet, im Mittel 26,6 ms und höchstens 52,6 ms lang.
Ein einzelnes jdk.JavaMonitorEnter-Event nennt zusätzlich im Feld previousOwner den Thread, der den Lock gehalten hat:
$ jfr print --events jdk.JavaMonitorEnter --stack-depth 3 orders.jfr
jdk.JavaMonitorEnter {
Context: Order.orderId = 104
startTime = 23:15:37.816 (2026-10-27)
duration = 15,0 ms
monitorClass = eu.happycoders.jfr.InvoiceNumberGenerator (classLoader = app)
previousOwner = "pool-1-thread-6" (javaThreadId = 50)
address = 0x7BDB2CEE60
eventThread = "pool-1-thread-5" (javaThreadId = 49)
stackTrace = [
eu.happycoders.jfr.InvoiceNumberGenerator.next() line: 9
eu.happycoders.jfr.OrderService.process(Order) line: 46
eu.happycoders.jfr.OrderService.processUntil(Instant, OrderGenerator) line: 30
...
]
}
...
Der InvoiceNumberGenerator hält den Lock bei jedem Aufruf 5 ms lang: In seiner synchronized-Methode next() simuliert er den Datenbankzugriff mit Thread.sleep(5). Mehr als 200 Rechnungsnummern pro Sekunde kann er also nicht vergeben, und die acht Threads stehen Schlange. Abhilfe schafft eine Sequenz in der Datenbank oder ein Generator, der Nummern blockweise reserviert – bei Blöcken von 100 Nummern wartet dann nur noch jeder hundertste Aufruf auf die Datenbank.
Die Wartezeit auf das Netzwerk schlüsselt die View socket-reads-by-host nach Gegenstelle auf:
$ jfr view socket-reads-by-host orders.jfr
Socket Reads by Host
Host Reads Total Read
-------------------------------------------------------------------- ----- ----------
localhost 8.588 662,6 kB
Alle 8.588 Lesevorgänge gingen an localhost, wo der simulierte Versanddienst läuft, und haben zusammen 662,6 kB gelesen.
Und ein einzelnes jdk.SocketRead-Event führt über seinen Stacktrace zum Aufrufer:
$ jfr print --events jdk.SocketRead --stack-depth 12 orders.jfr
...
jdk.SocketRead {
Context: Order.orderId = 37606
startTime = 23:15:58.708 (2026-10-27)
duration = 20,8 ms
host = "localhost"
address = "127.0.0.1"
port = 60419
timeout = 0 s
bytesRead = 79 bytes
endOfStream = false
eventThread = "pool-1-thread-7" (javaThreadId = 51)
stackTrace = [
java.io.BufferedInputStream.fill() line: 289
java.io.BufferedInputStream.read1(byte[], int, int) line: 330
java.io.BufferedInputStream.read(byte[], int, int) line: 388
sun.net.www.http.HttpClient.parseHTTPHeader(MessageHeader, HttpURLConnection) line: 812
sun.net.www.http.HttpClient.parseHTTP(MessageHeader, HttpURLConnection) line: 744
sun.net.www.protocol.http.HttpURLConnection.getInputStream0() line: 1408
sun.net.www.protocol.http.HttpURLConnection.getInputStream() line: 1311
java.net.URL.openStream() line: 1260
eu.happycoders.jfr.ShippingClient.shippingCost(Order) line: 19
eu.happycoders.jfr.OrderService.process(Order) line: 45
eu.happycoders.jfr.OrderService.processUntil(Instant, OrderGenerator) line: 30
eu.happycoders.jfr.OrderProcessing.lambda$main$0(OrderService, Instant, OrderGenerator) line: 22
...
]
}
...
Die 20,8 ms sind die Antwortzeit des Versanddienstes, und die lässt sich auf der Seite des Aufrufers nicht beschleunigen. Wegfallen würde die Wartezeit mit einem Cache für die Versandkosten. Und mit virtuellen Threads würde ein wartender Thread keinen Plattform-Thread blockieren.
Für virtuelle Threads gibt es eine eigene View, pinned-threads: Sie zeigt, wo ein virtueller Thread blockiert hat, während er an seinen Träger-Thread gebunden war – wie du solches Pinning findest, steht im Abschnitt Erkennen von Pinning des Artikels über virtuelle Threads.
Was hält den Speicher fest?
Die View gc-pauses beantwortet die Frage, ob der Garbage Collector die Anwendung bremst. Im Beispiel tut er es nicht:
$ jfr view gc-pauses orders.jfr
GC Pauses
---------
Total Pause Time: 79,1 ms
Number of Pauses: 48
Minimum Pause Time: 0,698 ms
Median Pause Time: 1,41 ms
...
Maximum Pause Time: 5,83 ms
48 Pausen mit zusammen 79 ms in einer Minute sind 0,13 % der Laufzeit – so wenig, dass sich Arbeit am Garbage Collector hier nicht lohnt. Einen Hinweis auf ein anderes Problem liefert die View gc, die jede Garbage Collection mit dem Heap davor und danach auflistet. Die folgende Ausgabe zeigt die ersten drei und die letzten drei Zeilen:
$ jfr view gc orders.jfr
Garbage Collections
Start GC ID GC Name Heap Before GC Heap After GC Longest Pause
-------- ----- --------------------------- -------------- ------------- -------------
23:15:37 1 G1New 9,8 MB 3,1 MB 1,73 ms
23:15:37 2 G1New 11,1 MB 10,9 MB 2,12 ms
23:15:38 3 G1New 42,9 MB 15,4 MB 5,83 ms
...
23:16:33 46 G1New 79,2 MB 40,1 MB 1,72 ms
23:16:35 47 G1New 80,1 MB 40,3 MB 1,25 ms
23:16:36 48 G1New 72,3 MB 40,9 MB 1,51 ms
Die Spalte „Heap After GC“ wächst in einer Minute von 3,1 auf 40,9 MB. Was nach einer Garbage Collection übrig bleibt, wird noch referenziert – und wenn dieser Rest stetig wächst, hält irgendetwas immer mehr Objekte fest. Das deutet auf ein Speicherleck hin.
Auch die Frage, was die Objekte festhält, kann der Flight Recorder beantworten. Er nimmt Stichproben allokierter Objekte und meldet diejenigen Objekte, die noch leben, als jdk.OldObjectSample – mit profile samt Stacktrace der Allokation. Als alt gilt ein Objekt, wenn es vor der letzten Garbage Collection allokiert wurde, sie also überlebt hat.
Jede Stichprobe gehört zu genau einem Objekt. Höchstens 256 solcher Stichproben behält der Flight Recorder gleichzeitig; räumt der Garbage Collector ein Objekt ab, fällt seine Stichprobe weg. Welche Stichproben er behält, wenn alle 256 Plätze belegt sind, entscheidet ein internes Gewicht, das nicht in der Aufzeichnung steht: Eine neue Stichprobe wiegt so viel, wie seit der vorigen Stichprobe allokiert wurde. Fällt eine Stichprobe weg, schlägt der Flight Recorder ihr Gewicht der nächstjüngeren zu. Und eine neue Stichprobe verdrängt diejenige Stichprobe mit dem kleinsten Gewicht, sofern ihr eigenes nicht noch kleiner ist. Ein Objekt, das lange lebt, erbt so das Gewicht vieler abgeräumter Objekte und wird kaum noch verdrängt – und gerade solche Objekte kommen für ein Speicherleck infrage.
Wo ein Objekt entstanden ist, verrät der Stacktrace der Stichprobe. Den Pfad von der GC-Root zum Objekt, also die Kette von Referenzen, die das Objekt letztendlich am Leben hält, ermittelt der Flight Recorder aber nur, wenn du path-to-gc-roots=true angibst. Gib die Option am besten bei einem JFR.dump an der laufenden JVM an. Beim Beenden der JVM hat der Flight Recorder in meinem Versuch nur für 8 von 198 Stichproben einen Pfad gefunden; bei einem Dump aus der laufenden JVM hingegen für fast alle: für 214 von 215:
$ jcmd 99180 JFR.dump name=orders filename=leak.jfr path-to-gc-roots=true
99180:
Dumped recording "orders", 3,0 MB written to:
<Pfad>/leak.jfr
Eine Stichprobe aus diesem Dump sieht so aus:
$ jfr print --events jdk.OldObjectSample --stack-depth 6 leak.jfr
...
jdk.OldObjectSample {
Context: Order.orderId = 106502
startTime = 23:21:03.522 (2026-10-27)
duration = 11,1 ms
allocationTime = 23:20:46.395 (2026-10-27)
objectSize = 4,0 kB
objectAge = 17,1 s
lastKnownHeapUsage = 34,4 MB
object = [
byte[4094]
value : java.lang.String
val : java.util.concurrent.ConcurrentHashMap$Node
[10567] : java.util.concurrent.ConcurrentHashMap$Node[16384]
table : java.util.concurrent.ConcurrentHashMap
receiptCache : eu.happycoders.jfr.OrderService
]
arrayElements = 4094
root = {
description = "Thread Name: main"
system = "Threads"
type = "Stack Variable"
}
eventThread = "pool-1-thread-3" (javaThreadId = 47)
stackTrace = [
jdk.internal.misc.Unsafe.allocateUninitializedArray(Class, int) line: 1422
java.lang.StringConcatHelper.newArray(int) line: 283
java.lang.StringConcatHelper.newArrayWithSuffix(String, int, byte) line: 309
eu.happycoders.jfr.OrderService.formatReceipt(Order, int, BigDecimal, long) line: 73
eu.happycoders.jfr.OrderService.process(Order) line: 47
eu.happycoders.jfr.OrderService.processUntil(Instant, OrderGenerator) line: 30
]
}
...
Den Pfad von der GC-Root zum Objekt liest du im Feld object von unten nach oben: Das Feld receiptCache eines OrderService verweist auf eine ConcurrentHashMap, deren Tabelle auf einen Eintrag, dessen Wert ein String ist – und dessen byte[] mit 4.094 Bytes ist das Objekt, das schon 17 Sekunden lebt. Den OrderService selbst hält eine lokale Variable des main-Threads (root). Der Stacktrace darunter zeigt, wo das Objekt entstanden ist: in formatReceipt().
205 der 215 Stichproben im Dump hängen auf diesem Weg am Feld receiptCache. Der Cache wächst, weil das Beispiel nie etwas aus ihm entfernt. Um nicht zu einem Memory Leak zu werden, braucht ein Cache eine Obergrenze oder ein Ablaufdatum für seine Einträge.
Die Views memory-leaks-by-class und memory-leaks-by-site zeige ich hier nicht. Sie fassen die Stichproben nach Klasse bzw. nach der Methode zusammen, in der sie entstanden sind – mit einer Zeile pro Klasse oder Methode, aber ohne Anzahl. In der Ausgabe für den Dump steht formatReceipt() deshalb gleichrangig neben Methoden wie loadBlockedProducts(), deren Objekte planmäßig die ganze Laufzeit leben. Den Pfad, der das Leck erklärt, zeigt nur jfr print.
Welche Exceptions fliegen?
Eine gefangene Exception, die der catch-Block nicht protokolliert, hinterlässt keine Spur im Log. In der Flight-Recorder-Aufzeichnung steht sie trotzdem, und die View exception-by-type gibt sie mit ihrer Häufigkeit aus:
$ jfr view exception-by-type orders.jfr
Exceptions by Type
Class Count
----------------------------------------------------------- --------------------------
java.lang.NumberFormatException 1.661
java.lang.NoSuchMethodError 90
java.lang.IncompatibleClassChangeError 4
java.lang.NoSuchMethodException 1
Das sind 1.661 NumberFormatExceptions in einer Minute, knapp 28 pro Sekunde. Die drei anderen Exception-Typen stammen aus dem JDK: Laut der View exception-by-site werden sie fast ausschließlich von MethodHandleNatives.resolve() geworfen, wenn das JDK intern Methoden-Handles auflöst:
$ jfr view exception-by-site orders.jfr
Exceptions by Site
Method Count
------------------------------------------------------------------------------- -----
java.lang.NumberFormatException.forInputString(String, int) 1.661
java.lang.invoke.MethodHandleNatives.resolve(MemberName, Class, int, boolean) 94
java.lang.Class.getMethod(String, Class[]) 1
Die View nennt auch für die NumberFormatException nur eine JDK-Methode, NumberFormatException.forInputString(). Den Aufrufer zeigt ein Event mit tieferem Stacktrace:
$ jfr print --events jdk.JavaExceptionThrow --stack-depth 10 orders.jfr
...
jdk.JavaExceptionThrow {
Context: Order.orderId = 12807
startTime = 23:15:45.021 (2026-10-27)
message = "For input string: "5 pcs""
thrownClass = java.lang.NumberFormatException (classLoader = bootstrap)
eventThread = "pool-1-thread-8" (javaThreadId = 52)
stackTrace = [
java.lang.Throwable.<init>(String) line: 286
java.lang.Exception.<init>(String) line: 67
java.lang.RuntimeException.<init>(String) line: 63
java.lang.IllegalArgumentException.<init>(String) line: 50
java.lang.NumberFormatException.<init>(String) line: 54
java.lang.NumberFormatException.forInputString(String, int) line: 67
java.lang.Integer.parseInt(String, int) line: 529
java.lang.Integer.parseInt(String) line: 626
eu.happycoders.jfr.OrderService.parseQuantity(String) line: 57
eu.happycoders.jfr.OrderService.process(Order) line: 43
...
]
}
...
parseQuantity() nutzt die Exception als Kontrollfluss: Für jede Menge mit Einheit wirft Integer.parseInt() eine NumberFormatException, und der catch-Block setzt die Menge auf 1. Kommen ungültige Eingaben regelmäßig vor, solltest du sie vor dem Parsen erkennen, statt die Exception zu fangen.
Beachte bei Exceptions die Obergrenze aus dem Abschnitt Was aufgezeichnet wird: Mit default zeichnet der Flight Recorder höchstens 100 Exceptions pro Sekunde auf. Fliegen mehr, ist die Zählung niedriger als die tatsächliche Zahl.
Exakt messen statt Stichproben zählen: Method Timing
Sampling sagt, wo die Zeit vergeht, aber nicht, wie oft eine Methode aufgerufen wird und wie lange ein Aufruf dauert. Dafür gibt es seit Java 25 Method Timing (JEP 520): Der Flight Recorder baut zusätzlichen Code in durch einen Filter ausgewählte Methoden ein und misst jeden einzelnen Aufruf.
Den Filter gibst du über die .jfc-Option method-timing an – hier für isBlocked(), mit einem Lauf von 20 Sekunden:
$ java -XX:StartFlightRecording:filename=timing.jfr,method-timing=eu.happycoders.jfr.OrderService::isBlocked \
-cp target/classes eu.happycoders.jfr.OrderProcessing 20
Processed 2.827 orders
$ jfr view --width 98 method-timing timing.jfr
Method Timing
Timed Method Invocations Minimum Time Average Time Maximum Time
---------------------------------------------- ----------- ------------ ------------ ------------
eu.happycoders.jfr.OrderService.isBlocked(...) 2.827 0,185000 ms 0,727000 ms 3,420000 ms
Für diese Tabelle habe ich --width 98 verwendet, denn bei 86 Zeichen kürzt jfr den Methodennamen bis zur Klasse.
isBlocked() wurde 2.827-mal aufgerufen, einmal pro Bestellung, und braucht im Mittel 0,727 ms. Der Filter kann wie im Beispiel eine Methode sein (Klasse::methode), aber auch eine ganze Klasse oder eine Annotation wie @jakarta.ws.rs.GET; mehrere Filter trennst du mit Semikolon. Die Schwester-Option method-trace zeichnet statt der Zeiten jeden Aufruf mit Stacktrace auf.
Dafür verändert der Flight Recorder den Code jeder Methode, die der Filter erfasst, und jeder ihrer Aufrufe kostet zusätzliche Zeit. Wähle den Filter deshalb so eng, wie deine Frage es erlaubt. Mehr dazu im Abschnitt JFR Method Timing & Tracing des Artikels über Java 25.
Eigene Events
Die JDK-Events kennen deine Fachlogik nicht. Eine Bestellung, eine Anfrage, ein Batch-Job – für so etwas kannst du ein eigenes Event schreiben. Das Beispiel definiert eines für Bestellungen:
@Name("eu.happycoders.Order")
@Label("Order")
class OrderEvent extends Event {
@Label("Order ID")
@Contextual
long orderId;
}
Eine Event-Klasse erweitert jdk.jfr.Event. @Name legt den Namen fest, unter dem das Event in der Aufzeichnung steht, @Label einen lesbaren Titel für Event und Feld. In process() erzeugt das Beispiel für jede Bestellung ein OrderEvent, setzt die Bestellnummer, ruft begin() vor und commit() nach der Verarbeitung auf. Die Zeit dazwischen ist die Dauer des Events:
void process(Order order) throws InterruptedException {
OrderEvent event = new OrderEvent();
event.orderId = order.id();
event.begin();
...
event.commit();
processedOrders.incrementAndGet();
}
Ein eigenes Event funktioniert mit allen Werkzeugen aus diesem Artikel. jfr view zeigt es unter seinem Namen eu.happycoders.Order:
$ jfr view eu.happycoders.Order orders.jfr
Order
Start Time Duration Event Thread Stack Trace Order ID
---------- -------- --------------- ---------------------------------------- --------
23:15:37 114 ms pool-1-thread-1 eu.happycoders.jfr.OrderService.proce... 100
23:15:37 98,5 ms pool-1-thread-3 eu.happycoders.jfr.OrderService.proce... 102
23:15:37 121 ms pool-1-thread-8 eu.happycoders.jfr.OrderService.proce... 107
...
Aus den Dauern dieser Events habe ich die Zahlen zur Dauer einer Bestellung in diesem Artikel berechnet: 8.588 Bestellungen, Median 56,1 ms, 99. Perzentil 64,4 ms. Eine View, die Median und Perzentile für ein eigenes Event ausgibt, hat jfr nicht. Ich habe die Events deshalb mit jfr print --json --events eu.happycoders.Order orders.jfr exportiert und die Dauern selbst ausgewertet.
Die Annotation @Contextual auf orderId gibt es seit Java 25. Solange ein OrderEvent läuft – zwischen begin() und commit() –, hängt der Flight Recorder die Bestellnummer an jedes andere Event desselben Threads. Daher stammen die Zeilen Context: Order.orderId = … in den Ausgaben der vorigen Abschnitte: Zu jedem Warten auf den Lock, jeder Netzwerkabfrage und jeder Exception weißt du, bei welcher Bestellung es passiert ist. Genauso verbindest du Trace- und Span-IDs deiner Anwendung mit den Events der JVM. Mehr dazu im Abschnitt Kontext für eigene JFR-Events mit @Contextual des Artikels über Java 25.
Erzeugt ein Event sehr viele Einträge, begrenzt die Annotation @Throttle – ebenfalls seit Java 25 – seine Anzahl pro Sekunde, z. B. mit @Throttle("300/s"). Der Wert der Annotation ist nur die Voreinstellung. In einer eigenen .jfc-Datei kannst du ihn mit der Einstellung throttle ändern – allerdings nur für Event-Klassen, die @Throttle tragen. Hätte das OrderEvent des Beispiels die Annotation, sähe das so aus:
<event name="eu.happycoders.Order">
<setting name="throttle">100/s</setting>
</event>
Auf der Kommandozeile geht das ebenso. Weil ein eigenes Event in keiner mitgelieferten .jfc-Datei steht, stellst du der Einstellung dort ein + voran; ohne das + gibt die JVM nur eine Warnung aus und ignoriert die Einstellung:
$ java -XX:StartFlightRecording:+eu.happycoders.Order#throttle=100/s ...
Im laufenden Programm mitlesen: RecordingStream
Seit Java 14 (JEP 349) kann eine Anwendung die Events ihrer eigenen JVM lesen, während sie entstehen – ohne sie in eine Datei zu schreiben. Die Klasse RecordingStream startet eine Aufzeichnung und übergibt jedes Event, das du in ihr einschaltest, an einen Handler, den du dafür registrierst.
Das folgende Programm meldet jede Bestellung, die länger als 80 ms dauert:
public class MonitoredOrderProcessing {
static void main(String[] args) throws Exception {
try (RecordingStream stream = new RecordingStream()) {
stream.enable("eu.happycoders.Order").withThreshold(Duration.ofMillis(80));
stream.onEvent(
"eu.happycoders.Order",
event ->
System.out.printf(
"Slow order %d: %d ms%n",
event.getLong("orderId"), event.getDuration().toMillis()));
stream.startAsync();
OrderProcessing.main(args);
}
}
}
enable() schaltet das Event ein, withThreshold() filtert alle Bestellungen unter 80 ms heraus, onEvent() registriert den Handler, und startAsync() startet die Aufzeichnung; die Handler laufen dann in einem eigenen Thread. Die Ausgabe nach einer Minute:
Slow order 101: 80 ms
Slow order 107: 85 ms
Slow order 102: 93 ms
Slow order 103: 98 ms
Slow order 100: 104 ms
Slow order 104: 110 ms
Processed 8.616 orders
Langsam waren nur die ersten Bestellungen, gleich nach dem Start der JVM. Auf diese Weise kannst du Metriken aus JFR-Events ableiten und z. B. an ein Monitoring-System weitergeben.
Die Events einer anderen JVM – etwa eines Servers, den du von außen überwachen willst – liest du seit Java 16 mit der Klasse RemoteRecordingStream aus dem Modul jdk.management.jfr. Sie verbindet sich über JMX (MBeanServerConnection) mit dieser JVM und bietet dieselben Methoden wie RecordingStream.
Eine Aufzeichnung weitergeben
Eine Aufzeichnung enthält mehr als Performance-Daten. Der Flight Recorder schreibt u. a. die Umgebungsvariablen, die System-Properties und die Kommandozeile der JVM hinein – in der Aufzeichnung des Beispiels 79 Umgebungsvariablen und 15 System-Properties. Steht darin ein Passwort oder ein Token, steht es im Klartext in jeder Kopie der Datei.
Seit Java 27 ersetzt der Flight Recorder Werte, die wie Passwörter oder Tokens aussehen, automatisch durch [REDACTED] (JEP 536). Welche Namen und Argumente er dafür erkennt und wie du die Muster anpasst, beschreibt der Abschnitt JFR In-Process Data Redaction des Artikels über Java 27.
Für alles, was diese Muster nicht erfassen, entfernt jfr scrub (seit Java 19) Events aus einer Kopie der Datei. Das folgende Kommando entfernt die Umgebungsvariablen und System-Properties:
$ jfr scrub --exclude-events InitialEnvironmentVariable,InitialSystemProperty \
orders.jfr orders-scrubbed.jfr
Scrubbed recording file written to:
<Pfad>/orders-scrubbed.jfr
Removed events:
jdk.InitialEnvironmentVariable 79/79
jdk.InitialSystemProperty 15/15
Ebenso kannst du Events nach Kategorie (--exclude-categories) oder nach Thread (--exclude-threads) entfernen – oder umgekehrt mit --include-… nur bestimmte behalten.
JFR in den Java-Versionen
Die folgende Tabelle zeigt, ab welcher Java-Version die Werkzeuge und Optionen aus diesem Artikel zur Verfügung stehen:
| Version | Neu für den Flight Recorder |
|---|---|
| Java 11 | Flight Recorder im OpenJDK (JEP 328) |
| Java 12 | Kommando jfr mit print und summary (ab Java 11.0.6 auch in Java 11) |
| Java 14 | RecordingStream (JEP 349) |
| Java 16 | RemoteRecordingStream |
| Java 17 | jfr configure, Event-Einstellungen und .jfc-Optionen direkt auf der Kommandozeile |
| Java 19 | jfr scrub |
| Java 21 | jfr view und jcmd … JFR.view |
| Java 23 | Event jdk.SerializationMisdeclaration, siehe Fehldeklarationen finden im Artikel über Serialisierung |
| Java 25 | Cooperative Sampling (JEP 518), Method Timing & Tracing (JEP 520), CPU-Time Profiling unter Linux, experimentell (JEP 509), Obergrenzen pro Sekunde und @Throttle, @Contextual, report-on-exit |
| Java 27 | automatisches Schwärzen von Passwörtern und Tokens (JEP 536) |
Zusammenfassung
Der Java Flight Recorder ist seit Java 11 Teil des OpenJDK und zeichnet mit den Standard-Settings bei typischerweise unter 1 % Overhead auf, was in der JVM passiert. Du startest ihn mit -XX:StartFlightRecording beim Start der JVM oder mit jcmd <pid> JFR.start an einer laufenden JVM, und du holst die Daten mit JFR.dump, JFR.stop oder beim Beenden der JVM als .jfr-Datei ab.
Die Events laufen über Thread-Puffer und globale Puffer in Chunk-Dateien auf der Platte. Der Ringpuffer liegt mit disk=true auf der Platte und wird mit maxage und maxsize begrenzt, immer in ganzen Chunks; mit disk=false liegt er im RAM und ist so groß wie memorysize.
Die fünf Fragen an die Aufzeichnung des Beispiels hat jfr view so beantwortet:
- Wo verbraucht die Anwendung CPU-Zeit?
hot-methodsfand die lineare Suche in einerArrayList; einHashSetmacht daraus eine Hash-Abfrage. - Welcher Code erzeugt die meisten Objekte?
allocation-by-sitefand zusammen mitjfr printdie String-Verkettung in einer Schleife; einStringBuildererzeugt den Text in einem Durchgang. - Worauf warten die Threads?
latencies-by-typeundcontention-by-sitefanden einen Lock um einen langsamen Datenbankzugriff; Abhilfe schafft eine Sequenz in der Datenbank oder ein Generator, der Nummern blockweise reserviert. - Was hält den Speicher fest?
gczeigte den wachsenden Heap, undpath-to-gc-rootsfand den Cache ohne Obergrenze; er braucht eine Obergrenze oder ein Ablaufdatum für seine Einträge. - Welche Exceptions fliegen?
exception-by-typefand eine Exception als Kontrollfluss; ungültige Eingaben erkennst du besser vor dem Parsen.
Die Views zeigen von jedem Stacktrace nur einen Frame. Die Frames darunter – etwa den Aufrufer in deinem Code, wenn oben eine JDK-Methode steht – zeigt jfr print --stack-depth.
Meine Empfehlungen:
- Lass den Flight Recorder in Produktion dauerhaft mitlaufen, mit
default,disk=true, einemmaxagefür den Zeitraum, auf den du nach einem Vorfall zurückblicken willst, und einemmaxsize. - Nimm für einen gezielten Profiling-Lauf
settings=profileund-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints, damit die Samples bei der richtigen Zeile landen. - Setze
delay, wenn der Start der Anwendung die Daten verzerrt. - Hol den Pfad von den GC-Roots zu langlebigen Objekten mit
jcmd <pid> JFR.dump path-to-gc-roots=trueaus der laufenden JVM, nicht beim Beenden. - Schreib eigene Events für die Einheiten deiner Fachlogik und markiere ihre ID mit
@Contextual, damit du zu jedem Event der JVM weißt, in welchem Kontext (z. B. bei welcher Bestellung) es passiert ist. - Prüfe eine Aufzeichnung, bevor du sie weitergibst, und entferne mit
jfr scrub, was nicht nach außen gehört.
Wenn dir der Artikel weitergeholfen hat, würde ich mich sehr über eine positive Bewertung auf meinem ProvenExpert-Profil freuen. Dein Feedback hilft mir, meine Inhalte weiter zu verbessern und motiviert mich, neue informative Artikel zu schreiben.




