Fehlerbehebung bei langsamem Debugger
Andere Sprachen: English Español Français 日本語 한국어 Português 中文
Obwohl ihr Overhead im Allgemeinen minimal ist, koennen Java-Debugger unter bestimmten Umstaenden dennoch erhebliche Laufzeitkosten verursachen. In einem unguenstigen Szenario kann der Debugger sogar die VM vollstaendig einfrieren.
Untersuchen wir die Gruende fuer diese Probleme und ihre moeglichen Loesungen.
Die in diesem Artikel beschriebenen Details beziehen sich auf JetBrains IDEs, und einige der erwahnten Funktionen sind moeglicherweise in anderen Tools nicht verfuegbar. Dennoch sollte die allgemeine Fehlerbehebungsstrategie weiterhin anwendbar sein.
Ursache diagnostizieren
Bevor wir moegliche Loesungen erkunden, waere es klug, das Problem zu identifizieren. Die haeufigsten Gruende fuer die Verlangsamung einer Anwendung durch den Debugger sind:
- Verwendung von Methoden-Breakpoints
- Zu haeufiges Auswerten von Ausdruecken
- Auswerten von Ausdruecken, die rechnerisch zu aufwendig sind
- Remote-Debugging mit hoher Latenz
IntelliJ IDEA eliminiert jegliches Raten bei der Identifizierung der Ursache von Debugger-Leistungsproblemen, indem es detaillierte Statistiken im Overhead-Tab des Debuggers bereitstellt:
Um darauf zuzugreifen, waehle Overhead im Layout Settings-Tab. Der Overhead-Tab zeigt die Liste der Breakpoints und Debugger-Funktionen. Neben jedem Breakpoint oder jeder Funktion kannst du sehen, wie oft jede Debugger-Funktion verwendet wurde und wie viel Zeit die Ausfuehrung in Anspruch nahm.
Im Overhead-Tab kannst du auch eine ressourcenintensive Funktion voruebergehend deaktivieren, indem du das entsprechende Kontrollkaestchen der Funktion abwaehlst.
Nachdem wir gesehen haben, wie man die Quelle des Leistungsproblems identifiziert, schauen wir uns die haeufigsten Ursachen an und wie man sie behebt.
Methoden-Breakpoints
Bei der Verwendung von Methoden-Breakpoints in Java kann es zu Leistungseinbussen kommen, abhaengig vom verwendeten Debugger. Dies liegt daran, dass die entsprechende Funktion, die vom Java Debug Interface bereitgestellt wird, merklich langsam ist.
Um dieses Problem zu beheben, bietet IntelliJ IDEA emulierte Methoden-Breakpoints an. Diese funktionieren genau wie regulaere Methoden-Breakpoints, arbeiten aber effizienter. Emulierte Methoden-Breakpoints verwenden einen Trick unter der Haube: Anstatt tatsaechliche Methoden-Breakpoints zu setzen, ersetzt die IDE sie durch regulaere Zeilen-Breakpoints innerhalb aller Methodenimplementierungen im Projekt.
Standardmaessig sind alle Methoden-Breakpoints in IntelliJ IDEA emuliert:
Wenn du einen Debugger verwendest, der diese Funktion nicht hat, und du Leistungsprobleme mit Methoden-Breakpoints hast, kannst du denselben Trick manuell durchfuehren. Das Aufsuchen aller Methodenimplementierungen kann muehsam sein, aber es kann sich auszahlen, indem es dir Zeit beim Debugging spart.
‘Verarbeitung von Klassen fuer emulierte Methoden-Breakpoints’ dauert zu lange
Wenn eine Methode eine grosse Anzahl von Implementierungen hat, kann das Setzen eines Methoden-Breakpoints darauf einige Zeit dauern. In diesem Fall zeigen IntelliJ IDEA und Android Studio einen Dialog mit der Meldung Processing classes for emulated method breakpoints.
Wenn der Prozess der Emulierung von Methoden-Breakpoints zu lange fuer dich dauert, ziehe stattdessen einen Zeilen-Breakpoint in Betracht. Alternativ kannst du etwas Laufzeitleistung opfern, indem du das Emulated-Kontrollkaestchen in den Breakpoint-Einstellungen abwaehlst.
Bedingte Breakpoints in heissem Code
Das Setzen eines bedingten Breakpoints in heissem Code kann eine Debugging-Sitzung drastisch verlangsamen, abhaengig davon, wie oft dieser Code ausgefuehrt wird.
Betrachte den folgenden Code-Ausschnitt:
public class Loop {
public static final int ITERATIONS = 100_000;
public static void main(String[] args) {
var start = System.currentTimeMillis();
var sum = 0;
for (int i = 0; i < ITERATIONS; i++) {
sum += i;
}
var end = System.currentTimeMillis();
System.out.println(sum);
System.out.printf("The loop took: %d ms\n", end - start);
}
} const val ITERATIONS = 100_000
fun main() = measureTimeMillis {
var sum = 0
for (i in 0 until ITERATIONS) {
sum += i
}
println(sum)
}.let { println("The loop took: $it ms") } Setzen wir einen Breakpoint bei sum += i und
geben false als Bedingung an.
Das bedeutet effektiv, dass der Debugger an diesem Breakpoint niemals anhalten sollte.
Jedoch muss der Debugger jedes Mal, wenn diese Zeile ausgefuehrt wird, false auswerten.
In diesem Fall waren die Ergebnisse beim Ausfuehren dieses Codes mit und ohne Breakpoint 39 ms bzw. 29855 ms.
Bemerkenswerterweise ist der Unterschied schon bei nur 100.000 Iterationen enorm!
Es mag ueberraschend sein, dass das Auswerten einer scheinbar trivialen Bedingung wie false
so viel Zeit in Anspruch nimmt.
Das liegt daran, dass die verstrichene Zeit nicht nur auf die Berechnung des Ausdrucksergebnisses zurueckzufuehren ist.
Sie umfasst auch die Behandlung von Debugger-Ereignissen und die Kommunikation mit dem Debugger-Frontend.
Die Loesung ist unkompliziert. Du kannst die Bedingung direkt in den Anwendungscode integrieren:
public class Loop {
public static final int ITERATIONS = 100_000;
public static void main(String[] args) {
var start = System.currentTimeMillis();
var sum = 0;
for (int i = 0; i < ITERATIONS; i++) {
if (false) { // condition goes here
System.out.println("break") // breakpoint goes here
}
sum += i;
}
var end = System.currentTimeMillis();
System.out.println(sum);
System.out.printf("The loop took: %d ms\n", end - start);
}
} fun main() = measureTimeMillis {
var sum = 0
for (i in 0 until ITERATIONS) {
if (false) { // condition goes here
println("break") // breakpoint goes here
}
sum += i
}
println(sum)
}.let { println("The loop took: $it ms") } Mit diesem Setup fuehrt die VM den Code der Bedingung direkt aus und kann diesen Code moeglicherweise sogar optimieren. Umgekehrt kommt der Debugger nur beim Erreichen des Breakpoints ins Spiel. Obwohl in den meisten Faellen nicht erforderlich, kann diese Aenderung dir Zeit sparen, wenn du das Programm bedingt mitten in einem heissen Pfad anhalten musst.
Die beschriebene Technik funktioniert perfekt mit Klassen, deren Quellcode verfuegbar ist. Bei kompiliertem Code, wie Bibliotheken, kann der Trick jedoch schwieriger umzusetzen sein. Dies ist ein spezieller Anwendungsfall, den ich in einer separaten Diskussion behandeln werde.
Implizite Auswertung
Zusaetzlich zu Funktionen wie Breakpoint-Bedingungen und Watches, bei denen du Ausdruecke selbst angibst, gibt es auch Funktionen, die implizit Ausdruecke fuer dich auswerten.
Hier ist ein Beispiel:
Wenn du ein Programm anhältst, zeigt der Debugger die Werte der Variablen an, die im aktuellen Kontext verfuegbar sind. Einige Typen haben moeglicherweise komplexe Strukturen, die schwer zu betrachten und zu navigieren sind. Der Debugger transformiert sie fuer deine Bequemlichkeit mit speziellen Ausdruecken, die Renderer genannt werden.
Renderer koennen trivial sein, wie toString() , oder komplexer,
wie solche, die den Inhalt von Collections transformieren. Sie koennen entweder eingebaut oder benutzerdefiniert sein.
Der Debugger von IntelliJ IDEA ist sehr flexibel in der Art, wie er deine Daten anzeigt. Die IDE ermoeglicht es dir sogar, Renderer mithilfe von Annotationen zu konfigurieren, was konsistente Klassendarstellungen in einem Projekt mit mehreren Mitwirkenden gewaehrleistet.
Um mehr ueber die Konfiguration des Formats fuer die Datenanzeige zu erfahren, lies die Dokumentation von IntelliJ IDEA.
Typischerweise ist der durch Debug-Renderer verursachte Overhead vernachlaessigbar,
aber die Auswirkung haengt letztendlich vom jeweiligen Anwendungsfall ab.
Tatsaechlich, wenn einige deiner toString() -Implementierungen Code zum Krypto-Mining enthalten,
wird der Debugger Schwierigkeiten haben, den toString() -Wert fuer diese Klasse anzuzeigen!
Wenn sich das Rendern einer bestimmten Klasse als langsam erweist, kannst du den entsprechenden Renderer deaktivieren. Als flexiblere Alternative kannst du den Renderer so einrichten, dass er nur bei Bedarf verwendet wird. On-Demand-Renderer werden nur ausgefuehrt, wenn du explizit darum bittest, ihr Ergebnis anzuzeigen.
Hohe Latenz in Remote-Debug-Sitzungen
Aus technischer Sicht unterscheidet sich das Debugging einer Remote-Anwendung nicht vom lokalen Debugging. So oder so wird die Verbindung ueber einen Socket hergestellt - wir schliessen den Shared-Memory-Modus aus dieser Diskussion aus - und der Debugger ist sich nicht einmal bewusst, wo die Host-JVM laeuft.
Ein Faktor, der jedoch einzigartig fuer Remote-Debugging sein koennte, ist die Netzwerklatenz. Bestimmte Debugger-Funktionen fuehren bei jeder Verwendung mehrere Netzwerk-Roundtrips durch. In Kombination mit hoher Latenz kann dies zu einem erheblichen Leistungsabfall beim Debugging fuehren.
Wenn das der Fall ist, denke darueber nach, das Projekt lokal auszufuehren, da es dir viel Zeit sparen koennte. Andernfalls koenntest du davon profitieren, einige der erweiterten Funktionen voruebergehend zu deaktivieren.
Fazit
In diesem Artikel haben wir gelernt, wie man die haeufigsten Probleme behebt, die zu schlechter Debugger-Leistung fuehren. Waehrend die IDE dies manchmal fuer dich uebernimmt, ist es wichtig, die zugrunde liegenden Mechanismen zu verstehen. Dies befaehigt dich, flexibler, effizienter und kreativer in deinem taeglichen Debugging zu sein.
Ich hoffe, du fandest diese Tipps und Tricks nuetzlich. Wie immer ist dein Feedback sehr willkommen! Kontaktiere mich gerne auf X, LinkedIn, oder Telegram.
Viel Erfolg beim Debugging!