Compiler, Interpreter, JIT
Vom Quelltext zum Maschinenbefehl: was ein Compiler in welchen Schritten tut, warum die JVM erst interpretiert und dann übersetzt, was der JIT-Compiler alles wegoptimiert – und wann ein Native Image die bessere Wahl ist.
Ein Prozessor versteht nur seine eigenen Maschinenbefehle. Ein Compiler übersetzt ein ganzes Programm vorab in solche Befehle – so arbeiten C, Rust und Go. Ein Interpreter liest das Programm zur Laufzeit und führt es Schritt für Schritt aus – flexibel, aber langsamer. Java geht einen dritten Weg: javac übersetzt in Bytecode, die JVM interpretiert ihn zunächst und übersetzt dann die häufig benutzten Teile zur Laufzeit in Maschinencode – das ist der JIT-Compiler.
Der Vorteil: Der JIT sieht, wie das Programm tatsächlich läuft, und optimiert genau dafür – oft besser, als ein Compiler vorab es könnte. Der Nachteil: Ein Java-Programm braucht eine Aufwärmphase. Wo schneller Start zählt, gibt es mit GraalVM Native Image und Projekt Leyden Vorab-Übersetzung auch für Java.
Was ein Compiler tut
Jeder Compiler – von javac bis zum C-Compiler – arbeitet in denselben Grundschritten:
Hier ist ein echter, winziger Compiler für Rechenausdrücke. Er erzeugt Java-Bytecode, wie javac es für eine Methode static int f(int a, int b, int c) { return …; } täte, und führt ihn auf einer simulierten Stackmaschine aus. Tipp einen Ausdruck ein:
Der Parser kennt die Vorrangregeln – Punkt vor Strich, Klammern zuerst – und baut daraus einen Baum. Die Codeerzeugung läuft den Baum von unten nach oben ab: erst beide Operanden auf den Stapel, dann der Operator. Genau so sieht echter Java-Bytecode aus. Und ein Ausdruck wie a + * b scheitert schon am Parser – das ist ein Syntaxfehler, lange bevor irgendetwas läuft.
Compiler, Interpreter und alles dazwischen
| Vorab-Compiler (AOT) | Interpreter | JIT-Compiler | |
|---|---|---|---|
| Wann übersetzt? | beim Bauen | gar nicht – führt direkt aus | zur Laufzeit, nur was häufig läuft |
| Start | sofort schnell | sofort | erst langsam, dann schnell |
| Höchstleistung | sehr gut | deutlich langsamer | sehr gut, teils besser als AOT |
| Plattform | ein Binärprogramm pro Prozessor und Betriebssystem | überall, wo der Interpreter läuft | überall, wo die VM läuft |
| Beispiele | C, C++, Rust, Go, GraalVM Native Image | Shell-Skripte, klassisch Python | Java (HotSpot), JavaScript (V8), C# (.NET) |
Die Grenzen verschwimmen: Python übersetzt intern ebenfalls in Bytecode, und seit Python 3.13 gibt es einen experimentellen JIT. .NET kann vorab und zur Laufzeit übersetzen. Und auch die JVM beginnt mit einem Interpreter.
Was in der JVM passiert
Die Standard-JVM heißt HotSpot – nach ihrer Kernidee: die heißen Stellen finden. Sie startet jede Methode im Interpreter und zählt mit, wie oft sie aufgerufen wird und wie oft ihre Schleifen laufen. Wird eine Methode warm, übersetzt sie der schnelle Compiler C1, der nebenbei Profildaten sammelt: Welche Typen kommen hier vor? Welcher Zweig wird genommen? Wird sie richtig heiß, übersetzt sie der optimierende Compiler C2 – mit allem, was die Profildaten erlauben. Das nennt man Tiered Compilation.
Was der JIT wegoptimiert
Der JIT-Compiler darf spekulieren: Er optimiert für das, was er bisher gesehen hat, und lässt eine Falltür offen. Trifft die Annahme irgendwann nicht mehr zu, wird der optimierte Code verworfen und die Methode läuft wieder im Interpreter – das ist die Deoptimierung aus dem Widget. Einige seiner wichtigsten Tricks, sinngemäß in Java dargestellt:
Vorab übersetzen: Native Image und Leyden
Die Aufwärmphase ist auf einem Server, der wochenlang läuft, egal. In einem Container, der für jede Anfrage neu startet, oder bei einem Kommandozeilenwerkzeug ist sie ein Problem. Dafür gibt es zwei Wege:
GraalVM Native Image
Übersetzt die Anwendung samt benötigter JDK-Teile vorab in ein einzelnes Programm. Start in Millisekunden, wenig Speicher. Preis: Beim Bauen muss alles bekannt sein – Reflection, dynamisches Laden und Proxys brauchen Konfiguration. Spring Boot und Quarkus unterstützen das.
Projekt Leyden
Der Weg im Standard-JDK: Ein Trainingslauf zeichnet auf, welche Klassen geladen und welche Methoden heiß werden; beim nächsten Start liegt das in einem AOT-Cache bereit. Seit Java 24 werden Klassen vorab geladen und verknüpft, seit Java 25 auch Profile mitgenommen. Volle Java-Dynamik bleibt erhalten.
Abwägung
Native Image: bester Start, kleinster Speicher, aber Einschränkungen und lange Bauzeit. JIT: beste Höchstleistung bei langer Laufzeit, volle Flexibilität. Leyden: ein Mittelweg ohne Einschränkungen.
Für Entwickler übersetzt
# Bytecode einer Klasse ansehen
javap -c -p MeineKlasse.class
# zusehen, was der JIT übersetzt
java -XX:+PrintCompilation -jar app.jar
# nur Interpreter – zum Staunen, wie viel der JIT ausmacht
java -Xint -jar app.jar
# nur C1 – startet schneller, erreicht aber nicht die Spitzenleistung
java -XX:TieredStopAtLevel=1 -jar app.jar
# Leyden: Trainingslauf, dann mit AOT-Cache starten (Java 25)
java -XX:AOTCacheOutput=app.aot -jar app.jar
java -XX:AOTCache=app.aot -jar app.jar| Folge für den Alltag | Warum |
|---|---|
| Benchmarks nur mit JMH | Ohne Aufwärmen misst man den Interpreter. Und der JIT entfernt Code, dessen Ergebnis niemand benutzt – dann misst man gar nichts. JMH kümmert sich um beides. |
| Lesbar schreiben, nicht „schlau“ | Kleine Methoden, Getter, Interfaces kosten nach dem Inlining nichts. Handoptimierungen wie manuell entrollte Schleifen helfen dem JIT selten und dem Leser nie. |
| Die ersten Anfragen sind langsamer | Nach einem Deployment antwortet ein Dienst einige Sekunden lang träger. Lastverteiler mit langsamem Hochfahren oder ein kurzes Aufwärmen vor der Freigabe helfen. |
| Gleichförmige Typen sind schneller | Sieht eine Aufrufstelle immer dieselbe Klasse, wird der Aufruf direkt und eingebettet. Bei vielen verschiedenen Typen an derselben Stelle bleibt es ein echter virtueller Aufruf. |
Häufige Irrtümer
final macht Methoden schneller.“ – Der JIT erkennt selbst, ob eine Methode überschrieben wird, und bindet sie direkt ein. final ist ein Werkzeug fürs Design, nicht für die Geschwindigkeit.System.nanoTime() um eine Schleife misst man die Laufzeit.“ – Man misst Interpreter, Übersetzung, Garbage Collection und womöglich wegoptimierten Code durcheinander.