
Möchten Sie der JVM einige nützliche Funktionen hinzufügen? Theoretisch kann jeder Entwickler zu OpenJDK beitragen. In der Praxis sind jedoch nicht triviale Änderungen an HotSpot von der Seite nicht sehr willkommen. Selbst bei dem derzeit verkürzten Veröffentlichungszyklus kann es Jahre dauern, bis JDK-Benutzer Ihre Funktion sehen.
In einigen Fällen ist es jedoch möglich, die Funktionalität einer virtuellen Maschine zu erweitern, ohne ihren Code zu berühren. Die JVM-Tool-Schnittstelle, die Standard-API für die Interaktion mit der JVM, hilft.
In dem Artikel werde ich anhand konkreter Beispiele zeigen, was damit gemacht werden kann, was sich in Java 9 und 11 geändert hat, und ehrlich vor den Schwierigkeiten warnen (Spoiler: Ich muss mich mit C ++ befassen).
Ich habe auch über dieses Material auf JPoint gesprochen. Wenn Sie das Video bevorzugen, können Sie den Videobericht ansehen.
Eintrag
Das soziale Netzwerk Odnoklassniki, in dem ich als führender Ingenieur arbeite, ist fast ausschließlich in Java geschrieben. Aber heute erzähle ich Ihnen nur einen anderen Teil, der nicht vollständig in Java ist.
Wie Sie wissen, ist NullPointerException das beliebteste Problem bei Java-Entwicklern. Einmal bin ich im Dienst auf dem Portal auf NPE in der Produktion gestoßen. Der Fehler wurde von so etwas wie dieser Stapelverfolgung begleitet:

Natürlich können Sie in der Stapelverfolgung den Ort, an dem die Ausnahme aufgetreten ist, bis zu einer bestimmten Zeile im Code verfolgen. Nur in diesem Fall habe ich mich nicht besser gefühlt, denn hier kann NPE viel treffen, wo:

Es wäre großartig, wenn die JVM genau vorschlagen würde, wo dieser Fehler beispielsweise so ist:
java.lang.NullPointerException: Called 'getUsers()' method on null object
Leider enthält NPE jetzt nichts dergleichen. Obwohl sie schon lange danach gefragt haben, zumindest mit Java 1.4:
Dieser Fehler ist 16 Jahre alt. In regelmäßigen Abständen wurden immer mehr Fehler zu diesem Thema geöffnet, die jedoch ausnahmslos als "Won't Fix" geschlossen wurden:

Das passiert nicht überall. Volker Simonis von SAP
erzählte, wie sie diese Funktion schon lange in SAP JVM implementiert hatten, und half ihr mehr als einmal. Ein anderer SAP-Mitarbeiter hat erneut
einen Fehler in OpenJDK gemeldet und sich freiwillig bereit erklärt, einen Mechanismus zu implementieren, der dem in der SAP JVM ähnelt. Und siehe da, diesmal wurde der Fehler nicht geschlossen - es besteht die Möglichkeit, dass diese Funktion in JDK 14 eingeht.
Aber wann wird JDK 14 veröffentlicht und wann werden wir darauf umsteigen? Was tun, wenn Sie das Problem hier und jetzt untersuchen möchten?
Sie können natürlich Ihre OpenJDK-Gabel beibehalten. Die NPE-Berichtsfunktion selbst ist nicht so kompliziert, wir hätten sie sehr gut implementieren können. Gleichzeitig gibt es jedoch alle Probleme, Ihre eigene Baugruppe zu unterstützen. Es wäre großartig, die Funktion einmal zu implementieren und sie dann einfach als Plugin mit einer beliebigen Version der JVM zu verbinden. Und das ist wirklich möglich! Die JVM verfügt über eine spezielle API (ursprünglich für alle Arten von Debuggern und Profilern entwickelt): JVM Tool Interface.
Am wichtigsten ist, dass diese API Standard ist. Er hat eine strenge
Spezifikation , und wenn Sie eine entsprechende Funktion implementieren, können Sie sicher sein, dass sie in neuen Versionen der JVM funktioniert.
Um diese Schnittstelle verwenden zu können, müssen Sie ein kleines (oder großes, je nach Ihren Aufgaben) Programm schreiben. Native: Normalerweise ist es in C oder C ++ geschrieben. Die Standard-JDK-
jdk/include/jvmti.h
verfügt über eine Header-Datei
jdk/include/jvmti.h
, die Sie einschließen möchten.
Das Programm wird in eine dynamische Bibliothek kompiliert und beim Start der JVM über den Parameter
-agentpath
verbunden. Es ist wichtig, es nicht mit einem anderen ähnlichen Parameter zu verwechseln:
-javaagent
. Tatsächlich sind Java-Agenten ein Sonderfall von JVM-TI-Agenten. Weiter im Text unter dem Wort "Agent" ist genau der native Agent gemeint.
Wo soll ich anfangen?
Lassen Sie uns in der Praxis sehen, wie man den einfachsten JVM-TI-Agenten schreibt, eine Art "Hallo Welt".
#include <jvmti.h> #include <stdio.h> JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM* vm, char* options, void* reserved) { jvmtiEnv* jvmti; vm->GetEnv((void**) &jvmti, JVMTI_VERSION_1_0); char* vm_name = NULL; jvmti->GetSystemProperty("java.vm.name", &vm_name); printf("Agent loaded. JVM name = %s\n", vm_name); fflush(stdout); return 0; }
In der ersten Zeile füge ich die gleiche Header-Datei hinzu. Als nächstes kommt die Hauptfunktion, die im Agenten implementiert werden muss:
Agent_OnLoad()
. Die virtuelle Maschine selbst ruft es beim
JavaVM*
des Agenten auf und übergibt einen Zeiger auf das
JavaVM*
-Objekt.
Mit ihm können Sie einen Zeiger auf die JVM TI-Umgebung erhalten:
jvmtiEnv*
. Und rufen Sie dadurch wiederum bereits JVM TI-Funktionen auf. Lesen Sie beispielsweise mit
GetSystemProperty den Wert einer Systemeigenschaft.
Wenn ich jetzt diese "Hallo Welt"
-agentpath
und die kompilierte DLL-Datei an
-agentpath
, wird die von unserem Agenten gedruckte Zeile in der Konsole
-agentpath
, bevor das Java-Programm ausgeführt wird:

Anreicherung NPE
Da die Hallo Welt nicht das interessanteste Beispiel ist, kehren wir zu unseren Ausnahmen zurück. Der vollständige Agentencode, der NPE-Berichte ergänzt, befindet sich
auf GitHub .
So sieht
Agent_OnLoad()
aus, wenn ich die virtuelle Maschine bitten möchte, uns über alle Ausnahmen zu informieren:
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM* vm, char* options, void* reserved) { jvmtiEnv* jvmti; vm->GetEnv((void**) &jvmti, JVMTI_VERSION_1_0); jvmtiCapabilities capabilities = {0}; capabilities.can_generate_exception_events = 1; jvmti->AddCapabilities(&capabilities); jvmtiEventCallbacks callbacks = {0}; callbacks.Exception = ExceptionCallback; jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_EXCEPTION, NULL); return 0; }
Zuerst frage ich die JVM TI nach der entsprechenden Funktion (can_generate_exception_events). Wir werden die Fähigkeit separat besprechen.
Der nächste Schritt ist das Abonnieren der Ausnahmeereignisse. Immer wenn die JVM Ausnahmen auslöst (egal ob sie abgefangen werden oder nicht), wird unsere
ExceptionCallback()
-Funktion aufgerufen.
Der letzte Schritt besteht darin,
SetEventNotificationMode()
, um die Zustellung von Benachrichtigungen zu ermöglichen.
In ExceptionCallback übergibt die JVM alles, was wir zur Behandlung von Ausnahmen benötigen. void JNICALL ExceptionCallback(jvmtiEnv* jvmti, JNIEnv* env, jthread thread, jmethodID method, jlocation location, jobject exception, jmethodID catch_method, jlocation catch_location) { jclass NullPointerException = env->FindClass("java/lang/NullPointerException"); if (!env->IsInstanceOf(exception, NullPointerException)) { return; } jclass Throwable = env->FindClass("java/lang/Throwable"); jfieldID detailMessage = env->GetFieldID(Throwable, "detailMessage", "Ljava/lang/String;"); if (env->GetObjectField(exception, detailMessage) != NULL) { return; } char buf[32]; sprintf(buf, "at location %id", (int) location); env->SetObjectField(exception, detailMessage, env->NewStringUTF(buf)); }
Hier gibt es sowohl das Objekt des Threads, der die Ausnahme ausgelöst hat (Thread), als auch den Ort, an dem dies passiert ist (Methode, Speicherort), und das Objekt der Ausnahme (Ausnahme) und sogar den Ort im Code, der diese Ausnahme abfängt (catch_method, catch_location).
Was wichtig ist: In diesem Rückruf wird neben dem Zeiger auf die JVM-TI-Umgebung auch die JNI-Umgebung (env) übergeben. Dies bedeutet, dass wir alle darin enthaltenen JNI-Funktionen verwenden können. Das heißt, JVM TI und JNI koexistieren perfekt und ergänzen sich.
In meinem Agenten verwende ich beide. Insbesondere überprüfe ich über JNI, ob meine Ausnahme vom Typ
NullPointerException
, und ersetze dann das Feld
detailMessage
eine Fehlermeldung.
Da die JVM selbst uns den Speicherort übergibt - den Bytecode-Index, für den die Ausnahme aufgetreten ist, habe ich diesen Speicherort hier in die Nachricht eingefügt:

Die Nummer 66 gibt den Index im Bytecode an, bei dem diese Ausnahme aufgetreten ist. Die manuelle Analyse des Bytecodes ist jedoch trostlos: Sie müssen die Klassendatei dekompilieren, nach der 66. Anweisung suchen und versuchen zu verstehen, was sie tat ... Es wäre großartig, wenn unser Agent selbst etwas besser lesbares zeigen könnte.
In diesem Fall bietet die JVM TI jedoch alles, was Sie benötigen. Richtig, Sie müssen zusätzliche Funktionen der JVM TI anfordern: Bytecode abrufen und Konstantenpoolmethode.
jvmtiCapabilities capabilities = {0}; capabilities.can_generate_exception_events = 1; capabilities.can_get_bytecodes = 1; capabilities.can_get_constant_pool = 1; jvmti->AddCapabilities(&capabilities);
Jetzt werde ich ExceptionCallback erweitern: Über die JVM-TI-Funktion
GetBytecodes()
ich den Hauptteil der Methode, um anhand des Standortindex zu überprüfen, was darin enthalten ist. Als nächstes folgt eine große Anweisung für den Switch-Bytecode: Wenn dies ein Zugriff auf das Array ist, wird eine Fehlermeldung angezeigt, wenn der Zugriff auf das Feld eine andere Meldung ist, wenn der Methodenaufruf der dritte ist und so weiter.
ExceptionCallback-Code jint bytecode_count; u1* bytecodes; if (jvmti->GetBytecodes(method, &bytecode_count, &bytecodes) != 0) { return; } if (location >= 0 && location < bytecode_count) { const char* message = get_exception_message(bytecodes[location]); if (message != NULL) { ... env->SetObjectField(exception, detailMessage, env->NewStringUTF(buf)); } } jvmti->Deallocate(bytecodes);
Es bleibt nur der Name des Feldes oder der Methode zu ersetzen. Sie können es aus dem
konstanten Pool beziehen , der dank der JVM TI wieder verfügbar ist.
if (jvmti->GetConstantPool(holder, &cpool_count, &cpool_bytes, &cpool) != 0) { return strdup("<unknown>"); }
Als nächstes kommt ein bisschen Magie, aber in Wirklichkeit nichts Schwieriges. Nur gemäß
der Spezifikation des Klassendateiformats analysieren wir den konstanten Pool und isolieren von dort aus die Zeile - den Namen der Methode.
Konstante Poolanalyse u1* ref = get_cpool_at(cpool, get_u2(bytecodes + 1));
Ein weiterer wichtiger Punkt: Einige JVM-TI-Funktionen, z. B.
GetConstantPool()
oder
GetBytecodes()
, weisen eine bestimmte Struktur im nativen Speicher zu, die freigegeben werden muss, wenn Sie mit der Arbeit fertig sind.
jvmti->Deallocate(cpool);
Führen Sie das Quellprogramm mit unserem erweiterten Agenten aus, und hier ist eine völlig andere Beschreibung der Ausnahme: Es wird berichtet, dass wir die longValue () -Methode für das Null-Objekt aufgerufen haben.

Andere Anwendungen
Im Allgemeinen möchten Entwickler Ausnahmen häufig auf ihre eigene Weise behandeln.
StackOverflowError
Sie beispielsweise die JVM automatisch neu, wenn ein
StackOverflowError
.
Dieser Wunsch kann verstanden werden, da
StackOverflowError
derselbe schwerwiegende Fehler wie
OutOfMemoryError
und es nach seinem Auftreten nicht mehr möglich ist, den korrekten Betrieb des Programms zu gewährleisten. Um das Problem zu analysieren, möchte ich beispielsweise manchmal einen Thread-Dump oder Heap-Dump erhalten, wenn eine Ausnahme auftritt.

Fairerweise hat das IBM JDK eine solche Chance sofort. Jetzt wissen wir jedoch bereits, dass Sie mit dem JVM TI-Agenten dasselbe in HotSpot implementieren können. Es reicht aus, einen Ausnahmerückruf zu abonnieren und die Ausnahme zu analysieren. Aber wie entferne ich Thread-Dump oder Heap-Dump von unserem Agenten? Die JVM TI bietet alles, was Sie für diesen Fall benötigen:

Es ist nicht sehr praktisch, den gesamten Mechanismus zum Umgehen des Heaps und zum Erstellen eines Speicherauszugs zu implementieren. Aber ich werde das Geheimnis teilen, wie man es einfacher und schneller macht. Dies ist zwar nicht mehr in der Standard-JVM-TI enthalten, sondern eine private Erweiterung von Hotspot.
Sie müssen die Header-Datei
jmm.h von den HotSpot-Quellen verbinden und die Funktion
JVM_GetManagement()
:
#include "jmm.h" JNIEXPORT void* JNICALL JVM_GetManagement(jint version); void JNICALL ExceptionCallback(jvmtiEnv* jvmti, JNIEnv* env, ...) { JmmInterface* jmm = (JmmInterface*) JVM_GetManagement(JMM_VERSION_1_0); jmm->DumpHeap0(env, env->NewStringUTF("dump.hprof"), JNI_FALSE); }
Es wird ein Zeiger auf die HotSpot-Verwaltungsschnittstelle zurückgegeben, die in einem einzelnen Aufruf einen Heap-Dump oder Thread-Dump generiert. Den vollständigen Code für das Beispiel finden Sie in
meiner Antwort auf Stack Overflow.
Natürlich können Sie nicht nur Ausnahmen behandeln, sondern auch eine Reihe anderer verschiedener Ereignisse im Zusammenhang mit der JVM-Operation: Starten / Stoppen von Threads, Laden von Klassen, Garbage Collection, Kompilieren von Methoden, Eingeben / Beenden von Methoden, sogar Zugreifen auf oder Ändern bestimmter Felder von Java-Objekten.
Ich habe ein Beispiel für einen anderen
vmtrace- Agenten, der viele Standard-JVM-TI-Ereignisse abonniert und protokolliert. Wenn ich mit diesem Agenten ein einfaches Programm ausführe, erhalte ich ein detailliertes Protokoll, das anschließend mit Zeitstempeln versehen ist:

Wie Sie sehen können, werden zum einfachen Drucken der Hallo-Welt Hunderte von Klassen geladen, Dutzende und Hunderte von Methoden generiert und kompiliert. Es wird klar, warum die Ausführung von Java so lange dauert. Alles an allem dauerte mehr als zweihundert Millisekunden.
Was JVM TI kann
Neben der Ereignisbehandlung verfügt die JVM TI über eine Reihe weiterer Funktionen. Sie können in zwei Gruppen unterteilt werden.
Eine ist obligatorisch, die jede JVM, die die JVM-TI unterstützt, implementieren muss. Dazu gehören das Analysieren von Methoden, Feldern, Flows, das Hinzufügen neuer Klassen zum Klassenpfad usw.
Es gibt optionale Funktionen, für die eine vorläufige Funktionsanforderung erforderlich ist. JVM muss nicht alle unterstützen, HotSpot implementiert jedoch die gesamte Spezifikation vollständig. Optionale Funktionen sind in zwei Untergruppen unterteilt: diejenigen, die nur zu Beginn der JVM verbunden werden können (z. B. die Möglichkeit, einen Haltepunkt festzulegen oder lokale Variablen zu analysieren), und diejenigen, die jederzeit verbunden werden können (insbesondere Bytecode oder konstanter Pool, den ich oben verwendet).

Möglicherweise stellen Sie fest, dass die Liste der Funktionen den Funktionen des Debuggers sehr ähnlich ist. Tatsächlich ist ein Java-Debugger nichts anderes als ein Sonderfall des JVM-TI-Agenten, der alle diese Funktionen nutzt und alle Funktionen anfordert.
Die Aufteilung der Funktionen in Funktionen, die jederzeit aktiviert werden können, und Funktionen, die nur zum Startzeitpunkt erfolgen, erfolgt absichtlich. Nicht alle Funktionen sind kostenlos, einige tragen Overhead.
Wenn mit den direkten Gemeinkosten, die mit der Verwendung der Funktion einhergehen, alles klar ist, gibt es noch weniger offensichtliche indirekte Gemeinkosten, die auftreten, selbst wenn Sie die Funktion nicht verwenden, sondern einfach durch Funktionen erklären, dass sie irgendwann in der Zukunft benötigt werden. Dies liegt daran, dass die virtuelle Maschine den Code anders kompilieren oder der Laufzeit zusätzliche Überprüfungen hinzufügen kann.
Beispielsweise führt die bereits berücksichtigte Fähigkeit zum Abonnieren von Ausnahmen (can_generate_exception_events) dazu, dass alle auslösenden Ausnahmen nur langsam ausgeführt werden. Im Prinzip ist dies nicht so beängstigend, da Ausnahmen in einem guten Java-Programm selten sind.
Die Situation mit lokalen Variablen ist etwas schlimmer. Für can_access_local_variables, mit dem Sie jederzeit die Werte lokaler Variablen abrufen können, müssen Sie einige wichtige Optimierungen deaktivieren. Insbesondere die Escape-Analyse funktioniert nicht mehr, was zu einem spürbaren Overhead führen kann: Je nach Anwendung 5-10%.
Daher die Schlussfolgerung: Wenn Sie Java mit aktiviertem Debug-Agenten ausführen, ohne es überhaupt zu verwenden, werden Anwendungen langsamer ausgeführt. Auf jeden Fall ist es keine gute Idee, einen Debugging-Agenten in die Produktion aufzunehmen.
Eine Reihe von Funktionen, z. B. das Festlegen eines Haltepunkts oder das Verfolgen aller Ein- / Ausgänge einer Methode, sind mit einem viel größeren Aufwand verbunden. Insbesondere funktionieren einige JVM-TI-Ereignisse (FieldAccess, MethodEntry / Exit) nur im Interpreter.
Ein Agent ist gut und zwei sind besser
Sie können mehrere Agenten mit einem einzigen Prozess verbinden, indem Sie einfach mehrere
-agentpath
Parameter
-agentpath
. Jeder hat seine eigene JVM TI-Umgebung. Dies bedeutet, dass jeder seine Fähigkeiten abonnieren und seine Ereignisse unabhängig abfangen kann.
Und wenn zwei Agenten das Haltepunktereignis abonniert haben und in einem der Haltepunkt in einer Methode festgelegt ist, erhält der zweite Agent das Ereignis, wenn diese Methode ausgeführt wird?
In der Realität kann eine solche Situation nicht auftreten (zumindest in HotSpot JVM). Weil es einige Funktionen gibt, die jeweils nur einer der Agenten besitzen kann. Dazu gehören insbesondere breakpoint_events. Wenn der zweite Agent dieselbe Funktion anfordert, erhält er daher einen Fehler als Antwort.
Dies ist eine wichtige Schlussfolgerung: Der Agent sollte immer das Ergebnis der Funktionsanforderung überprüfen, auch wenn Sie auf HotSpot ausgeführt werden und wissen, dass alle verfügbar sind. Die JVM TI-Spezifikation sagt nichts über exklusive Funktionen aus, aber HotSpot verfügt über eine solche Implementierungsfunktion.
Zwar funktioniert die Agentenisolation nicht immer perfekt. Während der Entwicklung des
Async-Profilers bin ich auf dieses Problem
gestoßen : Wenn zwei Agenten vorhanden sind und einer die Generierung von Methoden zur Methodenkompilierung anfordert, erhalten alle Agenten diese Ereignisse. Natürlich habe ich einen
Fehler gemeldet , aber Sie sollten bedenken, dass Ereignisse, die Sie nicht erwarten, in Ihrem Agenten auftreten können.
Verwendung in einem regulären Programm
JVM TI scheint für Debugger und Profiler eine sehr spezifische Sache zu sein, kann aber auch in einem regulären Java-Programm verwendet werden. Betrachten Sie ein Beispiel.
Das reaktive Programmierparadigma ist mittlerweile weit verbreitet, wenn alles asynchron ist, aber es gibt ein Problem mit diesem Paradigma.
public class TaskRunner { private static void good() { CompletableFuture.runAsync(new AsyncTask(GOOD)); } private static void bad() { CompletableFuture.runAsync(new AsyncTask(BAD)); } public static void main(String[] args) throws Exception { good(); bad(); Thread.sleep(200); } }
Ich führe zwei asynchrone Aufgaben aus, die sich nur in den Parametern unterscheiden. Und wenn etwas schief geht, wird eine Ausnahme ausgelöst:

Aus der Stapelverfolgung ist völlig unklar, welche dieser Aufgaben das Problem verursacht haben. Weil die Ausnahme in einem völlig anderen Thread auftritt, in dem wir keinen Kontext haben. Wie in welcher Aufgabe zu verstehen?
Als eine der Lösungen können Sie dem Konstruktor unserer asynchronen Aufgabe Informationen darüber hinzufügen, wo wir sie erstellt haben:
public AsyncTask(String arg) { this.arg = arg; this.location = getLocation(); }
Denken Sie also an den Speicherort - einen bestimmten Ort im Code bis zu der Zeile, von der aus der Konstruktor aufgerufen wurde. Und im Falle einer Ausnahme, um es zu verpfänden:
try { int n = Integer.parseInt(arg); } catch (Throwable e) { System.err.println("ParseTask failed at " + location); e.printStackTrace(); }
Wenn nun eine Ausnahme auftritt, sehen wir, dass dies in Zeile 14 im TaskRunner passiert ist (wo die Aufgabe mit dem Parameter BAD erstellt wird):

Aber wie bekommt man die Stelle im Code, von der aus der Konstruktor aufgerufen wird? Vor Java 9 gab es die einzig legale Möglichkeit, dies zu tun: Holen Sie sich einen Stack-Trace, überspringen Sie einige irrelevante Frames, und etwas tiefer auf dem Stack befindet sich die Stelle, die unser Code aufgerufen hat.
String getLocation() { StackTraceElement caller = Thread.currentThread().getStackTrace()[3]; return caller.getFileName() + ':' + caller.getLineNumber(); }
Aber es gibt ein Problem. Das vollständige StackTrace zu erhalten ist ziemlich langsam. Ich habe einen ganzen
Bericht darüber.
Dies wäre kein so großes Problem, wenn es selten passieren würde. Zum Beispiel haben wir einen Webdienst - ein Frontend, das HTTP-Anfragen akzeptiert. Dies ist eine großartige Anwendung, Millionen von Codezeilen. Und um Renderfehler abzufangen, verwenden wir einen ähnlichen Mechanismus: In den Komponenten für das Rendern erinnern wir uns an den Ort, an dem sie erstellt wurden. Wir haben Millionen solcher Komponenten, sodass das Abrufen aller Stapelspuren eine konkrete Zeit in Anspruch nimmt, um die Anwendung zu starten, und nicht nur eine Minute. Daher war diese Funktion zuvor in der Produktion deaktiviert, obwohl sie zur Analyse von Problemen in der Produktion benötigt wird.
Java 9 hat eine neue Methode zur Umgehung von Stream-Stacks eingeführt: StackWalker, der über die Stream-API all dies bei Bedarf träge ausführen kann. Das heißt, wir können die richtige Anzahl von Frames überspringen und nur einen erhalten, der uns interessiert.
String getLocation() { return StackWalker.getInstance().walk(s -> { StackWalker.StackFrame frame = s.skip(3).findFirst().get(); return frame.getFileName() + ':' + frame.getLineNumber(); }); }
Es funktioniert ein wenig besser als das Abrufen des vollständigen Stack-Trace, jedoch nicht um eine Größenordnung oder sogar um ein Vielfaches. In unserem Fall stellte sich heraus, dass es ungefähr eineinhalb Mal schneller war:

Es ist ein
Problem mit der suboptimalen Implementierung von StackWalker bekannt, das höchstwahrscheinlich sogar in JDK 13 behoben wird. Aber was sollten wir jetzt in Java 8 tun, wo StackWalker nicht einmal langsam ist?
Die JVM TI kommt wieder zur Rettung. Es gibt eine
GetStackTrace()
-Funktion, die alles kann, was Sie brauchen: ein Fragment eines Stack-Trace einer bestimmten Länge ab dem angegebenen Frame
GetStackTrace()
und nichts weiter tun.
GetStackTrace(jthread thread, jint start_depth, jint max_frame_count, jvmtiFrameInfo* frame_buffer, jint* count_ptr)
Es bleibt nur noch eine Frage: Wie rufe ich die JVM TI-Funktion von unserem Java-Programm aus auf? Wie bei jeder anderen nativen Methode: Laden Sie die native Bibliothek mit
System.loadLibrary()
, wo sich die JNI-Implementierung unserer Methode befindet.
public class StackFrame { public static native String getLocation(int depth); static { System.loadLibrary("stackframe"); } }
Ein Zeiger auf die JVM-TI-Umgebung
kann nicht nur von Agent_OnLoad ()
abgerufen werden , sondern auch, während das Programm ausgeführt wird, und um es weiterhin mit normalen nativen JNI-Methoden zu verwenden:
JNIEXPORT jstring JNICALL Java_StackFrame_getLocation(JNIEnv* env, jclass unused, jint depth) { jvmtiFrameInfo frame; jint count; jvmti->GetStackTrace(NULL, depth, 1, &frame, &count);
Dieser Ansatz ist bereits um ein Vielfaches schneller und ermöglichte es uns, einige Minuten beim Starten der Anwendung zu sparen:
Richtig, beim nächsten JDK-Update waren wir überrascht, dass die Anwendung plötzlich sehr, sehr langsam gestartet wurde. Die Untersuchung führte zu der sehr nativen Bibliothek für den Empfang von Stapelspuren. Als wir verstanden, kamen wir zu dem Schluss, dass der Fehler nicht an unserer Stelle, sondern im JDK auftrat. Ab dem JDK 8u112 sind alle JVM-TI-Funktionen, die mit Methoden arbeiten (GetMethodName, GetMethodDeclaringClass usw.), sehr langsam geworden.Ich habe einen Fehler gestartet , ein wenig recherchiert und eine lustige Geschichte entdeckt: Einige JVM TI-Funktionen haben Debugging-Prüfungen hinzugefügt, aber nicht bemerkt, dass sie auch aus dem Produktionscode aufgerufen wurden. Dieses Verwendungsszenario wurde nicht gefunden, da es nicht im Quellcode in C ++, sondern in der Datei enthalten warjvmtiEnter.xsl .Stellen Sie sich vor: Während der Kompilierung von HotSpot wird ein Teil des Quellcodes im laufenden Betrieb durch die XSLT-Transformation generiert. Auf diese Weise schlug Enterprise HotSpot zurück.Was könnte die Lösung sein? Rufen Sie diese Funktionen nur nicht zu oft auf, sondern versuchen Sie, die Ergebnisse zwischenzuspeichern. Das heißt, wenn für einige jmethodID-Informationen Informationen empfangen wurden, merken Sie sich diese lokal in Ihrem Agenten. Durch Anwenden eines solchen Cachings auf Agentenebene haben wir die Leistung auf die vorherige Ebene zurückgesetzt.Dynamische Verbindung
Als vorheriges Beispiel habe ich gezeigt, dass JVM TI direkt aus Java-Code mit gewöhnlichen nativen Methoden verwendet werden kann System.loadLibrary
.Darüber hinaus haben wir bereits gesehen, wie JVM-TI-Agenten -agentpath
beim Starten der JVM verbunden werden.Und es gibt noch einen weiteren dritten Weg: das dynamische Anhängen.Was ist die Idee? Wenn Sie die Anwendung gestartet haben und nicht dachten, dass Sie in Zukunft eine Funktion benötigen würden, oder wenn Sie plötzlich einen Fehler in der Produktion untersuchen müssen, können Sie den JVM TI-Agenten direkt zur Laufzeit herunterladen.Ab JDK 9 wird dies direkt über die Befehlszeile mit dem Dienstprogramm jcmd ermöglicht: jcmd <pid> JVMTI.agent_load /path/to/agent.so [arguments]
Und für ältere Versionen von JDK können Sie mein Jattach- Dienstprogramm verwenden . Zum Beispiel kann der Async-Profiler eine Verbindung zu Anwendungen herstellen, die ohne zusätzliche JVM-Argumente ausgeführt werden, auch dank jattach.Um die Möglichkeit einer dynamischen Verbindung in Ihrem JVM TI-Agenten nutzen zu können, müssen Sie zusätzlich Agent_OnLoad()
eine ähnliche Funktion implementieren Agent_OnAttach()
. Der einzige Unterschied: Agent_OnAttach()
Sie können die Funktionen nicht verwenden, die nur zum Startzeitpunkt des Agenten verfügbar sind.Es ist wichtig zu beachten, dass Sie dieselbe Bibliothek mehrmals dynamisch verbinden können, damit sie Agent_OnAttach()
wiederholt aufgerufen werden kann.Ich werde anhand eines Beispiels demonstrieren. IntelliJ IDEA wird die Rolle der Produktion übernehmen: Dies ist auch eine Java-Anwendung, dh wir können uns auch im laufenden Betrieb mit ihr verbinden und etwas unternehmen.Wir werden die Prozess-ID unserer IDEA finden und dann mit dem Dienstprogramm jattach die JI-Bibliothek der patcher.dll-TI-Bibliothek mit diesem Prozess verbinden:jattach 8648 load patcher.dll true
Und sofort hat sie die Menüfarbe in rot geändert:
Was macht dieser Agent? Findet alle Java-Objekte der angegebenen Klasse ( javax.swing.AbstractButton
) und ruft die JNI-Methode auf setBackground()
. Den vollständigen Code finden Sie hier .Was ist neu in Java 9?
JVM TI existiert seit langer Zeit und trotz der vorhandenen Fehler gibt es bereits eine etablierte Debug-API, die sich seit langem nicht geändert hat. Die ersten bedeutenden Neuerungen wurden in Java 9 veröffentlicht.Wie Sie wissen, brachte Java 9 Entwicklern die mit Modulen verbundenen Schmerzen und Leiden. Erstens ist es schwierig geworden, die "Geheimnisse" von JDK zu nutzen, ohne die manchmal im Prinzip nicht auskommt.Im JDK gibt es beispielsweise keine legale Möglichkeit, Direct ByteBuffer zu löschen. Nur über eine private API:
Angenommen, in Cassandra gibt es keinen Ort ohne diese Funktion, da die gesamte DBMS-Arbeit auf der Arbeit mit MappedByteBuffer basiert. Wenn Sie sie nicht manuell löschen, stürzt die JVM schnell ab.Wenn Sie versuchen, denselben Code unter JDK 9 auszuführen, erhalten Sie IllegalAccessError:
Die Situation bei Reflection ist ungefähr dieselbe: Es ist schwierig geworden, private Felder zu erreichen.Beispielsweise sind nicht alle Dateivorgänge unter Linux in Java verfügbar. Daher haben Programmierer für Linux-spezifische Funktionen den java.io.FileDescriptor
Systemdateideskriptor durch Reflektion und Verwendung von JNI aus dem Objekt abgerufen, wobei einige Systemfunktionen darauf aufgerufen wurden. Und jetzt, wenn Sie dies unter JDK 9 ausführen, werden Sie Flüche in den Protokollen sehen:
Natürlich gibt es JVM-Flags, die die erforderlichen privaten Module öffnen und es Ihnen ermöglichen, private Klassen und Reflektionen zu verwenden. Sie müssen jedoch alle Pakete, die Sie verwenden möchten, manuell registrieren. Um beispielsweise Cassandra nur auf Java 11 auszuführen, müssen Sie ein solches Banner registrieren : --add-exports java.base/jdk.internal.misc=ALL-UNNAMED --add-exports java.base/jdk.internal.ref=ALL-UNNAMED --add-exports java.base/sun.nio.ch=ALL-UNNAMED --add-exports java.management.rmi/com.sun.jmx.remote.internal.rmi=ALL-UNNAMED --add-exports java.rmi/sun.rmi.registry=ALL-UNNAMED --add-exports java.rmi/sun.rmi.server=ALL-UNNAMED --add-exports java.sql/java.sql=ALL-UNNAMED --add-opens java.base/java.lang.module=ALL-UNNAMED --add-opens java.base/jdk.internal.loader=ALL-UNNAMED --add-opens java.base/jdk.internal.ref=ALL-UNNAMED --add-opens java.base/jdk.internal.reflect=ALL-UNNAMED --add-opens java.base/jdk.internal.math=ALL-UNNAMED --add-opens java.base/jdk.internal.module=ALL-UNNAMED --add-opens java.base/jdk.internal.util.jar=ALL-UNNAMED --add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED
Zusammen mit den Modulen wurden jedoch JVM TI- Funktionen für die Arbeit mit ihnen angezeigt:- GetAllModules
- AddModuleExports
- AddModuleOpens
- usw.
Wenn Sie sich diese Liste ansehen, bietet sich die Lösung an: Sie können warten, bis die JVM geladen ist, eine Liste aller Module abrufen, alle Pakete durchgehen, alles für alle öffnen und genießen.Hier ist das gleiche Beispiel mit Direct ByteBuffer: public static void main(String[] args) { ByteBuffer buf = ByteBuffer.allocateDirect(1024); ((sun.nio.ch.DirectBuffer) buf).cleaner().clean(); System.out.println("Buffer cleaned"); }
Wenn wir es ohne Agenten ausführen, erwarten wir einen IllegalAccessError. Und wenn Sie dem Agentenpfad einen von mir geschriebenen Antimodul- Agenten hinzufügen , funktioniert das Beispiel fehlerfrei. Gleiches gilt für Reflexion.Was ist neu in Java 11?
Eine weitere Neuerung erschien in Java 11. Es ist nur eine, aber was für eine! Es besteht die Möglichkeit einer einfachen Profilerstellung für Zuordnungen: Es wurde ein neues Ereignis hinzugefügt SampledObjectAlloc
, das Sie abonnieren können, damit selektive Benachrichtigungen über Zuordnungen erfolgen.Alles, was für die weitere Analyse benötigt wird, wird an den Rückruf übertragen: der zuzuweisende Thread, das ausgewählte Objekt selbst, seine Klasse, Größe. Eine andere Methode SetHeapSampingInterval
besteht darin, die Häufigkeit zu ändern, mit der diese Benachrichtigungen eingehen.
Warum wird das benötigt?
Allocation Profiling war früher in allen gängigen Profilern, arbeitete jedoch mit Instrumenten, die mit hohem Overhead behaftet sind. Das einzige Profiling-Tool mit geringem Overhead war der Java Flight Recorder.Die Idee der neuen Methode besteht darin, nicht alle Zuordnungen zu instrumentieren, sondern nur einige davon, mit anderen Worten, zu testen.Im schnellsten und häufigsten Fall erfolgt die Zuweisung innerhalb des lokalen Thread-Zuordnungspuffers durch einfaches Erhöhen des Zeigers. Und mit der Aufnahme der Abtastung in TLAB wird eine virtuelle Grenze hinzugefügt, die der Abtastfrequenz entspricht. Sobald die nächste Zuordnung diese Grenze überschreitet, wird ein Ereignis über die Zuordnung des Objekts gesendet.
In einigen Fällen werden große Objekte, die nicht in TLAB passen, direkt im Heap zugewiesen. Solche Objekte gehen auch den langsamen Zuordnungspfad durch die JVM-Laufzeit und werden ebenfalls abgetastet.Aufgrund der Tatsache, dass die Probenahme jetzt nur für einige Objekte durchgeführt wird, ist der Overhead für die Produktion bereits akzeptabel - in den meisten Fällen weniger als 5%.Interessanterweise ist diese Funktion seit der Zeit von JDK 7 vor langer Zeit speziell für Flight Recorder entwickelt worden. Über die private Hotspot-API hat dies jedoch auch der Async-Profiler verwendet. Und jetzt, beginnend mit JDK 11, ist diese API öffentlich geworden, hat die JVM-TI eingegeben und andere Profiler können sie verwenden. Insbesondere weiß YourKit bereits wie. Informationen zur Verwendung dieser API finden Sie in dem Beispiel in unserem Repository.Mit diesem Profiler können Sie schöne Zuordnungsdiagramme erstellen. Beobachten Sie, welche Objekte hervorstechen, wie viele von ihnen hervorstechen und vor allem wo.
Fazit
JVM TI ist eine großartige Möglichkeit, mit einer virtuellen Maschine zu interagieren.In C oder C ++ geschriebene Plugins können beim Start der JVM gestartet oder direkt während der Ausführung der Anwendung dynamisch verbunden werden. Darüber hinaus kann die Anwendung selbst die JVM-TI-Funktionen über native Methoden verwenden.Alle gezeigten Beispiele werden in unserem Repository auf GitHub veröffentlicht . Verwenden, studieren und Fragen stellen.