Kolumne: Java-Trickkiste

Bytecode-Analyse im Eigenbau

Arno Haase

Im Java Magazin 12.2014 haben wir uns angesehen, wie man den Classpth scannen kann, ohne Klassen in die JVM zu laden und dann Reflection zu verwenden. Jetzt gehen wir wie angekündigt einen Schritt weiter und analysieren Aufrufketten auf Basis des Bytecodes. Als Beispiel dafür, was damit möglich ist, erstellen wir ein Ranking der am häufigsten aufgerufenen Methoden und suchen die Methoden mit der größten inneren Komplexität heraus. Wie schon letzten Monat verwenden wir dazu die Bytecode-Bibliothek asm.

Rückblick: Zusammensuchen von Klassen

Die Analyse des Bytecodes beruht auf dem Code zum Scannen des Classpath, den wir letzten Monat betrachtet haben. Deshalb hier eine kurze Wiederholung – wer den Code noch vor seinem inneren Auge hat, kann diesen Abschnitt getrost überspringen.

Der erste Schritt ist, alle JAR-Dateien bzw. Verzeichnisse zu finden, die Klassen des Systems enthalten. Dabei gehen wir davon aus, dass die gesamte Anwendung über einen einzigen URLClassLoader geladen wird, und holen uns von ihm und seinen Eltern-ClassLoadern die URLs (Listing 1).

Listing 1

public List getRootUrls () {
  List result = new ArrayList<> ();

  ClassLoader cl = Thread.currentThread().getContextClassLoader();
  while (cl != null) {
    if (cl instanceof URLClassLoader) {
      URL[] urls = ((URLClassLoader) cl).getURLs();
      result.addAll (Arrays.asList (urls));
    }
    cl = cl.getParent();
  }
  return result;
}

Anschließend iterieren wir für jeden dieser URLs über alle enthaltenen Class-Dateien. Für Verzeichnisse steigen wir dazu rekursiv in die Unterverzeichnisse ab und betrachten in jedem Verzeichnis alle Dateien mit der Endung .class, während wir JAR-Dateien mit einem JARInputStream über die enthaltenen Dateien iterieren.

Dieser Code ist durchaus interessant, hat aber mit dem Analysieren des Bytecodes direkt nichts zu tun. Weil er gleichzeitig nicht ganz kurz ist, wiederhole ich ihn hier nicht und verweise auf das Java Magazin 12.2014 und die Dokumentation zu JARInputStream.

ClassVisitor: Infos zur Klasse

Wir öffnen dann für jede Class-Datei einen InputStream auf ihren rohen Inhalt und übergeben den an die Bibliothek asm – genauer gesagt eine Instanz von ClassReader – zum Einlesen. asm ist extrem auf Effizienz optimiert und arbeitet deshalb mit „Streaming“: Es liest eine Class-Datei einmal von vorne nach hinten ein, und man kann Callbacks registrieren für alles, was einen interessiert. Diese Callbacks heißen bei asm „Visitor“, und es gibt für verschiedene Bestandteile einer Klasse jeweils Basisklassen, von denen man erben muss.

Der Einstiegspunkt in eine Klasse ist immer ein ClassVisitor, den man direkt an den ClassReader zur Verarbeitung übergibt (Listing 2). In unserem Code erfüllt die Klasse MyClassVisitor diesen Zweck. Die Basisklasse ClassVisitor hat übrigens ziemlich viele Methoden. Sie stellt aber für jede von ihnen eine Default-Implementierung bereit, sodass Subklassen nur die wenigen Methoden überschreiben müssen, für die sie sich interessieren.

Im Konstruktor müssen wir eine Konstante an die Basisklasse übergeben, die intern das Verhalten von asm steuert und Rückwärtskompatibilität zu älteren Versionen ermöglicht.

Der erste Callback, der uns interessiert, ist die Methode visit(). Sie liefert die grundlegenden Informationen über die Klasse: Bytecode-Version, Modifiers, Namen, Typsignatur, Basisklasse und implementierte Interfaces. Wir merken uns den Klassennamen in einem Attribut.

Der zweite Callback, den wir implementieren, ist visitMethod(). Er wird für jede Methode der aktuellen Klasse aufgerufen und ist der Einstieg in die Analyse des Bytecodes innerhalb dieser Methoden. Unser Code gibt eine Instanz der Klasse InvocationCountMethodVisitor zurück, die wir im nächsten Abschnitt definieren.

Listing 2

new ClassReader (in).accept (new MyClassVisitor (), 0);

class MyClassVisitor extends ClassVisitor {
  private String className;

  MyClassVisitor () { super (Opcodes.ASM5); }

  @Override public void visit (
    int version, int access, String name, 
    String signature, String superName, 
    String[] interfaces) {
    className = name;
  }

  @Override public MethodVisitor visitMethod (
    int access, String name, String desc, 
    String signature, String[] exceptions) {
    return new InvocationCountMethodVisitor();
  }
}

Methodenaufrufe analysieren

Jetzt, wo wir bei den Methodenimplementierungen angekommen sind, können wir uns um unser erstes Ziel kümmern: Wir wollen für jede Methode mitprotokollieren, wie oft sie aufgerufen wird. Dazu legen wir zunächst eine Map an, die für jede Methode – als String repräsentiert – die Anzahl der Aufrufe speichert (Listing 3).

Listing 3

Map<String, Integer> invocationCounter = new HashMap<> ();

class InvocationCountMethodVisitor extends MethodVisitor {
  InvocationCountMethodVisitor () { super (Opcodes.ASM5); }

  @Override public void visitMethodInsn (
    int opcode, String owner, String name, String desc, boolean itf) {
    final String calledMethod = owner + "." + name + " " + desc;
    final int prev = invocationCounter.containsKey (calledMethod) ?
      invocationCounter.get (calledMethod) : 
      0;
    invocationCounter.put (calledMethod, prev+1);
  }
}

Zum Befüllen dieser Map schreiben wir die Klasse InvocationCountMethodVisitor, die von der asm-Klasse MethodVisitor erbt. asm ruft dort für jeden Bytecode jeder Methode einen Callback auf. Es gibt recht viele Bytecodes, und nicht alle davon sind auf Anhieb leicht zu verstehen. Zum Glück bietet asm Sammel-Callbacks für inhaltlich zusammenhängende Gruppen von Bytecodes, sodass man sie oft sinnvoll behandeln kann, ohne in die Tiefen der JVM einzutauchen.

Zum Zählen der Methodenaufrufe interessieren uns alle Bytecodes, die Methoden aufrufen – und dafür gibt es den Callback visitMethodInsn(…). Der liefert für jeden Methodenaufruf unter anderem den Typ, dem die aufgerufene Methode gehört („owner“), den Methodennamen („name“) sowie Parameterliste und Rückgabewert als String in einem internen Format der JVM („desc“).

Unsere Implementierung konkateniert diese Daten zunächst zu einem einzigen String, der die Methode eindeutig beschreibt. Dieser Hack ist der Kompaktheit dieses Artikels geschuldet – in „echtem“ Code würde ich stattdessen eine einfache Java-Klasse mit drei Attributen und passender equals()- und hashCode()-Implementierung schreiben.

Anschließend schaut unser Code in der Map nach, an wie vielen Codestellen diese Methode schon aufgerufen wurde, mit einer Sonderfallbehandlung, falls dies die erste solche Codestelle ist. Und schließlich erhöht sie diesen Wert um 1 und legt ihn wieder in der Map ab.

Sortieren der Ergebnisse

Damit ist das Datensammeln abgeschlossen, und nach einem kompletten Durchlauf durch alle Klassen auf dem Classpath enthält die Map invocationCounter für jede Methode jeder Klasse die Anzahl der Codestellen, von denen aus sie aufgerufen wird. Fehlt nur noch das Ausgeben als Ranking der meistbenutzten Methoden. Dafür müssen wir die Methoden nach Aufrufzahl sortieren, und zwar in absteigender Reihenfolge. Der Code in Listing 4 erledigt das. Als Basis dient eine TreeMap mit der Aufrufzahl als Key und einer Liste aller dazugehörigen Methoden als Wert. Als Comparator dient die natürliche Ordnung, aber in umgekehrter Reihenfolge. Unser Code verwendet die mit Java 8 eingeführten Factory-Methoden und Default-Implementierungen im Comparator-Interface und ist dadurch recht gut lesbar.

Anschließend iterieren wir einmal über alle aufgerufenen Methoden und sortieren ihre Daten in die neue Struktur ein. Wenn es schon eine andere Methode mit gleicher Aufrufzahl gibt, hängen wir die neue Methode an die entsprechende Liste an. Andernfalls legen wir in der Map vorher noch einen Eintrag mit leerer Liste an. Und schließlich geben wir für die zwanzig größten Aufrufzahlen aus, welche Methoden an so vielen Stellen im Code aufgerufen wurden.

Listing 4

final Map<Integer, Collection<String>> byCount = 
  new TreeMap<> (Comparator.<Integer> naturalOrder ().reversed ());

for (Map.Entry<String, Integer> entry: invocationCounter.entrySet ()) {
  if (! byCount.containsKey (entry.getValue ())) {
    byCount.put (entry.getValue (), new ArrayList<> ());
  }
  byCount.get (entry.getValue ()).add (entry.getKey ());
}

int num=0;
for (int count: byCount.keySet ()) {
  System.out.println (count + " invocations: ");
  for (String mtd: byCount.get (count)) {
    System.out.println ("  " + mtd);
  }
  num += 1;
  if (num >= 20) {
    break;
  }
}

Das genaue Ergebnis hängt natürlich vom konkreten Classpath und der Java-Version ab. In meinem Workspace liegt auf Platz 1 mit 64 637 Aufrufstellen die Methode StringBuilder.append(String). Das liegt einfach daran, dass der Java-Compiler diesen Aufruf überall erzeugt, wo im Quelltext Strings mit „+“ verknüpft werden.

Ebenfalls wenig überraschend ist, dass z. B. StringBuilder.toString(), Object.equals() oder Iterator.next() ganz vorne mit dabei sind. Dass aber StringBuffer.append(Object) mit 6 595 Aufrufen auf Platz 10 und damit noch vor Integer.valueOf(int) liegt, das der Compiler immerhin beim Autoboxing erzeugt, sagt wahrscheinlich etwas darüber aus, dass eine Reihe der bei mir eingebundenen Bibliotheken eine überalterte Codebasis haben.

Die Informationen, wer wen aufruft, kann man übrigens auf vielfältige andere Weise auswerten: zyklische Abhängigkeiten auf Package-, Klassen- oder Methodenebene, Fan-in und Fan-out („Ca“ und „Ce“), eine Liste aller rekursiven Methoden etc.

Komplexität von Methoden

Neben der Analyse von Methodenaufrufen wollen wir als zweiten Anwendungsfall von Bytecodeanalyse die Komplexität von Methoden untersuchen. Eine Metrik dazu zählt alle if-Abfragen und Schleifen sowie Vorkommen von && und || („zyklomatische Komplexität“). Weil diese Definition sich auf den Quelltext bezieht und wir mit Bytecode arbeiten, versuchen wir, sie mit wenig Aufwand anzunähern.

Dazu definieren wir eine Klasse ComplexityMethodVisitor, die von MethodVisitor erbt. Sie zählt Entscheidungspunkte im Bytecode und legt das Ergebnis in einer Map „complexity“ mit der Methodensignatur als Schlüssel ab (Listing 5). Die vollständige Methodensignatur der gerade untersuchten Methode muss der dazugehörige ClassVisitor in seiner Methode visitMethod an den Konstruktor übergeben, und der CompexityMethodVisitor merkt sie sich in einem Attribut.

Das Herzstück dieser Klasse ist die Methode visitJumpInsn(…). Sie wird als Callback für jeden Bytecode aufgerufen, der einen Sprung durchführt. Und sowohl if-Statements und Schleifen als auch &&– und ||-Operatoren sind im Bytecode durch bedingte Sprünge ausgedrückt. Die JVM hat dafür einfach keine anderen Ausdrucksmöglichkeiten (ähnlich wie Assembler-Code, an den sich manche vielleicht von ganz, ganz früher erinnern).

Die Details können variieren, weil Compiler Teilausdrücke zusammenfassen und den Code auf andere Weise umstrukturieren können, aber die Anzahl der Sprung-Bytecodes ist eine recht gute Annäherung an die Komplexität einer Methode.

Wenn asm mit dem Verarbeiten einer Methode fertig ist, ruft es als Letztes die Methode visitEnd() auf. Dort legt unser Code die kumulierte Komplexität in der zentralen Map complexity ab. Und weil viele interne Klassen des JDK komplexe Methoden haben, die uns gleichzeitig hier nicht besonders interessieren, verwerfen wir hier alle Daten aus Packages, die mit com/sun/ oder sun/ beginnen.

Das Ergebnis können wir dann analog zu Listing 4 als Ranking aufbereiten. In meinem Workspace liegen auf den ersten Plätzen ein paar generierte RMI-Stubs und einige Klassen von externen Bibliotheken. Als komplexeste „richtige“ JDK-Methode erscheint dann java/awt/GridBagLayout.GetLayoutInfo (Container, int) mit einer Komplexität von 155! Es lohnt sich, den dazugehörigen Quellcode anzuschauen – seine Länge und Komplexität sind sehenswert.

Fazit

Bytecode-Analyse ist kein Hexenwerk. Mit einer guten Bibliothek wie asm kann man in wenigen Stunden kleine Programme schreiben, die interessante Fragen über ein System beantworten. Man muss natürlich zuerst herausfinden, wie z. B. Methodenaufrufe oder logische Operationen im Bytecode abgebildet werden, aber mit der Dokumentation oder indem man mit dem Werkzeug javap mit dem Schalter -c kompilierten Code dekompiliert, findet man in der Praxis recht schnell einen Einstieg.

Man kann auf diese Weise weit über das hinausgehen, was IDEs oder generische Codeanalysewerkzeuge leisten können. So habe ich zum Beispiel in einem Kundenprojekt mit den hier beschriebenen Mitteln eine garantiert aktuelle und vollständige Visualisierung aller asynchronen Aufrufketten aus dem Bytecode extrahiert.

Und auch wenn man an dieser Stelle nicht selbst Hand anlegen will: Dies sind die Mechanismen, mit denen moderne Frameworks intern arbeiten – und es kann ja nie schaden, die Werkzeuge besser zu verstehen, mit denen man arbeitet.

Geschrieben von
Arno Haase
Arno Haase
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: