Einstieg in das Allocation Profiling
Andere Sprachen: English Español Français 日本語 한국어 Português 中文
Wir befinden uns oft in Situationen, in denen Code nicht richtig funktioniert und wir keine Ahnung haben, wo wir überhaupt mit der Untersuchung beginnen sollen.
Können wir nicht einfach auf den Code starren, bis uns die Lösung irgendwann einfällt? Sicher, aber diese Methode wird wahrscheinlich nicht funktionieren ohne tiefes Wissen über das Projekt und viel geistige Anstrengung. Ein klügerer Ansatz wäre, die verfügbaren Tools zu nutzen. Sie können dich in die richtige Richtung weisen.
In diesem Beitrag schauen wir uns an, wie wir Speicherallokationen profilieren können, um ein Laufzeitproblem zu lösen.
Das Problem
Beginnen wir mit dem Klonen des folgenden Repositories: https://github.com/flounder4130/party-parrot.
Starte die Anwendung mit der im Projekt enthaltenen Parrot-Run-Konfiguration. Die App scheint gut zu funktionieren: Du kannst die Animationsfarbe und -geschwindigkeit anpassen. Es dauert jedoch nicht lange, bis die Dinge schief gehen.
Nach einiger Zeit friert die Animation ein, ohne dass ein Hinweis auf die Ursache gegeben wird.
Das Programm kann manchmal einen OutOfMemoryError werfen, dessen Stack-Trace uns nichts über den Ursprung des Problems verrät.
Es gibt keine zuverlässige Möglichkeit zu sagen, wie genau sich das Problem manifestieren wird. Das Interessante an diesem Einfrieren der Animation ist, dass wir den Rest der UI danach immer noch verwenden können.
Ich habe Amazon Corretto 11 zum Ausführen dieser App verwendet. Das Ergebnis kann auf anderen JVMs oder sogar auf derselben JVM mit einer anderen Konfiguration abweichen.
Der Debugger
Es scheint, wir haben einen Bug. Versuchen wir es mit dem Debugger! Starte die Anwendung im Debug-Modus, warte bis die Animation einfriert, und drücke dann Pause Program.
Leider hat uns das nicht viel gesagt, weil alle Threads, die an der Papagei-Party beteiligt sind, im Wartezustand sind. Die Inspektion ihrer Stacks gibt keinen Hinweis darauf, warum das Einfrieren passiert ist. Offensichtlich müssen wir einen anderen Ansatz versuchen.
Ressourcenverbrauch überwachen
Da wir einen OutOfMemoryError bekommen,
ist ein guter Ausgangspunkt für die Analyse CPU and Memory Live Charts.
Sie ermöglicht es uns, den Echtzeit-Ressourcenverbrauch der laufenden Prozesse zu visualisieren.
Öffnen wir die Charts
für unsere Papagei-App und schauen, ob wir etwas erkennen können, wenn die Animation einfriert.
Tatsächlich sehen wir, dass der Speicherverbrauch kontinuierlich steigt, bevor er ein Plateau erreicht. Das ist genau der Moment, in dem die Animation hängt, und danach scheint sie für immer zu hängen.
Das gibt uns einen Hinweis. Normalerweise ist die Speicherverbrauchskurve sägezahnförmig: Das Diagramm steigt, wenn neue Objekte alloziert werden, und fällt periodisch, wenn der Speicher nach der Garbage Collection ungenutzter Objekte zurückgewonnen wird. Du kannst ein Beispiel eines normal funktionierenden Programms im Bild unten sehen:
Wenn die Sägezähne zu häufig werden, bedeutet das, dass der Garbage Collector intensiv arbeitet, um Speicher freizugeben. Ein Plateau bedeutet, dass er keinen freigeben kann.
Wir können testen, ob die JVM eine Garbage Collection durchführen kann, indem wir explizit eine anfordern:
Der Speicherverbrauch sinkt nicht, nachdem unsere App das Plateau erreicht hat, selbst wenn wir sie manuell auffordern, etwas Speicher freizugeben. Dies unterstützt unsere Hypothese, dass es keine Objekte gibt, die für die Garbage Collection in Frage kommen.
Eine naive Lösung wäre, einfach mehr Speicher hinzuzufügen. Füge dazu die VM-Option -Xmx500m
zur Run-Konfiguration hinzu.
Um schnell auf die Einstellungen der aktuell ausgewählten Run-Konfiguration zuzugreifen, halte ‘Shift’ gedrückt und klicke auf den Namen der Run-Konfiguration in der Hauptsymbolleiste.
Unabhängig vom verfügbaren Speicher geht dem Papagei trotzdem der Speicher aus. Wieder sehen wir dasselbe Bild. Der einzige sichtbare Effekt des zusätzlichen Speichers war, dass wir das Ende der “Party” hinausgezögert haben.
Allocation Profiling
Da wir wissen, dass unsere Anwendung nie genug Speicher bekommt, ist es vernünftig, ein Speicherleck zu vermuten
und ihren Speicherverbrauch zu analysieren.
Dafür können wir einen Memory Dump mit der VM-Option -XX:+HeapDumpOnOutOfMemoryError
sammeln. Dies ist ein vollkommen akzeptabler Ansatz zur Inspektion des Heaps;
jedoch zeigt er nicht auf den Code, der für die Erstellung dieser Objekte verantwortlich ist.
Wir können diese Informationen aus einem Profiler-Snapshot erhalten: Er liefert nicht nur Statistiken über die Objekttypen, sondern enthüllt auch die Stack-Traces, die dem Zeitpunkt ihrer Erstellung entsprechen. Obwohl dies ein etwas unkonventioneller Anwendungsfall für Allocation Profiling ist, hindert uns nichts daran, es zur Identifizierung des Problems zu verwenden.
Führen wir die Anwendung mit angehängtem IntelliJ Profiler aus. Während der Ausführung zeichnet der Profiler periodisch den Zustand der Threads auf und sammelt Daten über Speicherallokations-Ereignisse. Diese Daten werden dann in einer menschenlesbaren Form aggregiert, um uns eine Vorstellung davon zu geben, was die Anwendung bei der Allokation dieser Objekte tat.
Nachdem wir den Profiler eine Weile laufen gelassen haben, öffnen wir den Bericht und wählen Memory Allocations:
Es gibt mehrere Ansichten für die gesammelten Daten. In diesem Tutorial verwenden wir den Flame Graph. Er aggregiert die gesammelten Stacks in einer einzigen stack-ähnlichen Struktur und passt die Elementbreite entsprechend der Anzahl der gesammelten Samples an. Die breitesten Elemente repräsentieren die am massivsten allozierten Typen während des Profilierungszeitraums.
Eine wichtige Sache ist hier zu beachten: Viele Allokationen bedeuten nicht unbedingt ein Problem. Ein Speicherleck tritt nur auf, wenn die allozierten Objekte nicht garbage-collected werden. Während uns das Allocation Profiling nichts über die Garbage Collection verrät, kann es uns dennoch Hinweise für weitere Untersuchungen geben.
Schauen wir, woher die zwei massivsten Elemente,
byte[] und int[] , kommen.
Die Spitze des Stacks sagt uns, dass diese Arrays waehrend der Bildverarbeitung durch Code aus dem
java.awt.image -Paket erstellt werden.
Der Boden des Stacks sagt uns, dass all dies in einem separaten Thread geschieht, der von einem Executor Service verwaltet wird.
Wir suchen nicht nach Bugs in Bibliothekscode, also schauen wir auf den Projektcode dazwischen.
Von oben nach unten ist die erste Anwendungsmethode, die wir sehen,
recolor() , die wiederum von
updateParrot() aufgerufen wird.
Dem Namen nach zu urteilen, ist diese Methode genau das, was unseren Papagei bewegen lässt.
Schauen wir, wie das implementiert ist und warum es so viele Arrays benötigt.
Ein Klick auf den Frame bringt uns zum Quellcode der entsprechenden Methode:
public void updateParrot() {
currentParrotIndex = (currentParrotIndex + 1) % parrots.size();
BufferedImage baseImage = parrots.get(currentParrotIndex);
State state = new State(baseImage, getHue());
BufferedImage coloredImage = cache.computeIfAbsent(state, (s) -> Recolor.recolor(baseImage, hue));
parrot.setIcon(new ImageIcon(coloredImage));
}
Es scheint, dass updateParrot() ein Basisbild nimmt und es dann umfärbt.
Um zusätzliche Arbeit zu vermeiden, versucht die Implementierung zuerst, das Bild aus einem Cache abzurufen.
Der Schlüssel zum Abrufen ist ein State -Objekt, dessen Konstruktor
ein Basisbild und einen Farbton nimmt:
public State(BufferedImage baseImage, int hue) {
this.baseImage = baseImage;
this.hue = hue;
}
Datenfluss analysieren
Mit dem eingebauten statischen Analysator können wir den Wertebereich der Eingaben für den
State -Konstruktoraufruf verfolgen.
Klicke mit der rechten Maustaste auf das Konstruktorargument baseImage ,
wähle dann aus dem Menü Analyze | Data Flow to Here.
Erweitere die Knoten und achte auf ImageIO.read(path.toFile()) .
Es zeigt uns, dass die Basisbilder aus einer Reihe von Dateien stammen.
Wenn wir auf diese Zeile doppelklicken und die PARROTS_PATH -Konstante in der Nähe betrachten,
entdecken wir den Speicherort der Dateien:
public static final String PARROTS_PATH = "src/main/resources";
Wenn wir zu diesem Verzeichnis navigieren, sehen wir Folgendes:
Das sind zehn Basisbilder, die den möglichen Positionen des Papageis entsprechen.
Nun, was ist mit dem hue -Konstruktorargument?
Wenn wir den Code inspizieren, der die hue -Variable modifiziert, sehen wir, dass sie
einen Startwert von 50 hat.
Dann wird sie entweder mit einem Slider gesetzt oder automatisch von der
updateHue() -Methode aktualisiert.
So oder so ist sie immer im Bereich von 1 bis 100 .
Wir haben also 100 Varianten des Farbtons und 10 Basisbilder, was garantieren sollte, dass der Cache niemals größer als 1000 Elemente wird. Prüfen wir, ob das stimmt.
Bedingte Breakpoints
Jetzt kann der Debugger nützlich sein. Wir können die Größe des Caches mit einem bedingten Breakpoint überprüfen.
Das Setzen eines bedingten Breakpoints in heißem Code kann die Zielanwendung erheblich verlangsamen.
Setzen wir einen Breakpoint bei der Update-Aktion und fügen eine Bedingung hinzu, sodass er die Anwendung nur anhält, wenn die Cache-Größe 1000 Elemente übersteigt.
Jetzt führe die App im Debug-Modus aus.
Tatsächlich halten wir nach einiger Zeit an diesem Breakpoint an, was bedeutet, dass das Problem tatsächlich im Cache liegt.
Code inspizieren
Cmd + B
auf cache bringt uns zu seiner Deklarationsstelle:
private static final Map<State, BufferedImage> cache = new HashMap<>();
Wenn wir die Dokumentation für HashMap prüfen,
finden wir, dass ihre Implementierung auf den Methoden equals()
und hashcode() basiert,
und der Typ, der als Schlüssel verwendet wird, diese korrekt überschreiben muss.
Prüfen wir das. Cmd + B
auf State bringt uns zur Klassendefinition.
class State {
private final BufferedImage baseImage;
private final int hue;
public State(BufferedImage baseImage, int hue) {
this.baseImage = baseImage;
this.hue = hue;
}
public BufferedImage getBaseImage() { return baseImage; }
public int getHue() { return hue; }
}
Es scheint, wir haben den Schuldigen gefunden: Die Implementierung von equals()
und hashcode() ist nicht nur falsch. Sie fehlt komplett!
Methoden überschreiben
Das Schreiben von Implementierungen für equals()
und hashcode() ist eine langweilige Aufgabe.
Glücklicherweise können moderne Tools sie für uns generieren.
Drücke in der State -Klasse
Cmd + N
und wähle equals() and hashcode().
Akzeptiere die Vorschläge und klicke auf Next, bis die Methoden an der Cursorposition erscheinen.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
State state = (State) o;
return hue == state.hue && Objects.equals(baseImage, state.baseImage);
}
@Override
public int hashCode() {
return Objects.hash(baseImage, hue);
}
Die Korrektur überprüfen
Starten wir die Anwendung neu und schauen, ob sich die Dinge verbessert haben. Wieder können wir CPU and Memory Live Charts dafür verwenden:
Das ist viel besser!
Zusammenfassung
In diesem Beitrag haben wir uns angeschaut, wie wir mit den allgemeinen Symptomen eines Problems beginnen und dann, mit unserem logischen Denken und der Vielfalt der verfügbaren Tools, den Suchbereich schrittweise eingrenzen können, bis wir die genaue Codezeile finden, die das Problem verursacht. Noch wichtiger: Wir haben sichergestellt, dass die Papagei-Party weitergehen wird, egal was passiert!
Wie immer freue ich mich über dein Feedback! Viel Erfolg beim Profiling!