W3docs

Java Type Erasure

Wie Java Generics durch Type Erasure implementiert, was zur Laufzeit gelöscht wird und welche Konsequenzen sich daraus ergeben.

Type Erasure ist die Art und Weise, wie Java Generics implementiert: Die Typparameter existieren, während der Compiler läuft, und werden dann verworfen, bevor der Bytecode geschrieben wird. Eine List<String> und eine List<Integer> kommen bei der JVM als einfache List an — zur Laufzeit austauschbar. Das Typsystem zur Kompilierzeit erzwingt die Sicherheit; zur Laufzeit ist es dieselbe List, die die Sprache 1995 hatte. Diese Designentscheidung ist die wichtigste Tatsache über Javas Generics und erklärt jede seltsame Einschränkung, der du im nächsten Kapitel begegnen wirst.

Dieses Kapitel behandelt, wodurch Erasure Typparameter ersetzt, warum Java es gegenüber reifizierten Generics gewählt hat, die Laufzeitkonsequenzen (getClass, instanceof, new T()), die vom Compiler generierten Bridge-Methoden, die das Überschreiben funktionsfähig halten, und warum Erasure bestimmte Überladungen blockiert. Wenn du neu bei Generics bist, beginne zuerst mit Java Generics.

Warum Java diesen Weg gewählt hat

Als Generics in Java 5 hinzugefügt wurden, war die Standardbibliothek bereits ein Jahrzehnt alt. Jede vorhandene List, Map und Comparator war nicht-generisch, und jedes bestehende Programm auf der Welt verwendete sie als Raw Types. Die harte Anforderung von Sun war binäre Rückwärtskompatibilität: Code, der gegen die Standardbibliothek vor Version 5 kompiliert wurde, musste weiterhin auf der neuen ohne Neukompilierung laufen.

Zwei Designs lagen auf dem Tisch:

  1. Reifizierte Generics — die Typinformationen zur Laufzeit beibehalten, so wie es C# letztendlich getan hat. Schneller, ausdrucksstärker, aber erfordert, dass jede bestehende Klassendatei auf der Welt neu ausgegebenen werden muss.
  2. Gelöschte Generics — die Typinformationen zur Kompilierzeit entfernen, die Bytecode-Form unverändert lassen. Langsamer pro Aufruf (zusätzliche Casts), weniger ausdrucksstark (kein new T()), aber jedes alte JAR läuft weiterhin unverändert.

Sun entschied sich für Erasure. Der pragmatische Preis für das Upgrade wurde in langfristiger Sprachflexibilität gezahlt. Es ist nicht das Design, das irgendjemand auf einem leeren Blatt Papier wählen würde — aber es ist das Design, das Java hat, und das Verstehen davon lässt alles andere über Generics an seinen Platz fallen.

Was Erasure tatsächlich tut

Wenn der Compiler einen generischen Typ sieht, macht er zwei Dinge:

  1. Löscht jeden Typparameter auf seine am weitesten links stehende Schranke — oder auf Object, wenn es keine Schranke gibt.
  2. Fügt Casts an jeder Stelle ein, an der ein generischer Wert gelesen wird, damit die Laufzeitwerte in die richtigen Slots gelangen.

Nimm diese generische Klasse:

public class Box<T extends Number> {
  private T value;

  public Box(T value)        { this.value = value; }
  public T   get()            { return value; }
  public void set(T value)    { this.value = value; }
}

Nach der Erasure sieht der Bytecode in etwa so aus (als Java-Quelläquivalent):

public class Box {
  private Number value;

  public Box(Number value)            { this.value = value; }
  public Number get()                  { return value; }
  public void   set(Number value)      { this.value = value; }
}

Das T ist verschwunden. Es wurde zu Number, weil das die Schranke war. Wenn es keine Schranke gegeben hätte, wäre es zu Object geworden.

Und an der Aufrufstelle:

Box<Integer> b = new Box<>(42);
int x = b.get();           // source

wird (nach Erasure):

Box b = new Box(42);
int x = (Integer) b.get();   // compiler inserted the cast

Der Cast war in der Quelle unsichtbar. Der Compiler fügt ihn hinzu, weil er weiß, dass der Typ auf Quellebene Integer war, auch wenn der Typ auf Bytecode-Ebene Number (oder Object) ist.

Was das zur Laufzeit bedeutet

Mehrere Konsequenzen ergeben sich direkt aus der Erasure, und sie sind die Quelle jedes „aber ich dachte, ich könnte…"-Moments, den ein Entwickler mit Java Generics hat:

Box<Integer> a = new Box<>(1);
Box<Double>  b = new Box<>(1.0);

a.getClass() == b.getClass();   // true — both are Box.class

Es gibt zur Laufzeit kein Box<Integer>.class oder Box<Double>.class — es gibt nur Box.class. Die zwei Instanzen sind dieselbe Klasse, weil die JVM sie buchstäblich nicht unterscheiden kann.

Du kannst nicht fragen „ist das ein instanceof Box<Integer>":

if (obj instanceof Box<Integer>) { ... }   // ❌ does not compile
if (obj instanceof Box<?>)        { ... }   // ✓ — wildcard is allowed
if (obj instanceof Box)           { ... }   // ✓ — raw form works

Du kannst new T() nicht schreiben:

public class Factory<T> {
  public T create() { return new T(); }    // ❌ — no T at runtime
}

Du kannst keinen generischen Ausnahmetyp abfangen:

try { ... }
catch (MyException<String> e) { ... }      // ❌

Alle diese Kompilierungsfehler lassen sich auf dieselbe Tatsache zurückführen: Die JVM hat den Typparameter in dem Moment nicht, in dem der Code ausgeführt werden müsste. Der Compiler weigert sich, Code zu erzeugen, von dem er weiß, dass er nicht erfolgreich sein kann.

Hinweis
Die Standard-Umgehungslösung für „Ich muss ein T erstellen" ist, den Typ explizit zu übergeben — üblicherweise als Class<T>-Parameter und clazz.getDeclaredConstructor().newInstance(), oder eine Supplier<T>-Factory. Die Informationen, die Erasure entfernt hat, müssen vom Aufrufer bereitgestellt werden; der Compiler kann sie nicht rekonstruieren.

Bridge-Methoden — die versteckte Buchführung der Erasure

Es gibt eine Feinheit, bei der Erasure mit dem Überschreiben interagiert. Angenommen, du hast:

interface Container<T> {
  void put(T value);
}

class IntContainer implements Container<Integer> {
  public void put(Integer value) { ... }
}

Auf Quellebene überschreibt IntContainer.put(Integer) die Methode Container.put(T). Aber nach der Erasure hat die Interface-Methode die Signatur put(Object) — und IntContainer hat nur put(Integer). Wie funktioniert Polymorphismus noch, wenn jemand put über eine Container-Referenz aufruft?

Der Compiler generiert eine Bridge-Methode in IntContainer:

// Generated by the compiler, invisible in source:
public void put(Object value) {
  put((Integer) value);   // delegate to the real one
}

Diese Bridge-Methode wird aufgerufen, wenn der polymorphe Dispatch auf der gelöschten Signatur landet. Du schreibst sie nicht, du siehst sie nicht, aber javap wird sie dir zeigen. Sie ist der Klebstoff, der Erasure mit virtuellem Dispatch funktionsfähig macht.

Erasure und Überladung

Eine direkte Konsequenz der Erasure: Du kannst zwei Methoden nicht überladen, wenn ihre Signaturen sich nur in ihren generischen Parametern unterscheiden, weil sie nach der Erasure dieselbe Signatur haben:

public void process(List<String> list)  { ... }
public void process(List<Integer> list) { ... }
// ❌ both erase to process(List) — compile error

Dies ist dasselbe lauernde Problem mit Überschreibungen. Wenn zwei Methoden auf dieselbe Signatur gelöscht werden würden, weigert sich der Compiler, sie zu kompilieren. Es gibt keine Umgehungslösung auf Sprachebene — du bräuchtest verschiedene Methodennamen oder einen Parameter, der nach der Erasure tatsächlich unterschiedlich ist.

Ein ausgearbeitetes Beispiel: Erasure in Aktion

Das Programm demonstriert die Dinge, die aus der Erasure folgen — Laufzeitgleichheit von getClass, ein funktionierendes instanceof in der Raw-Form und der unkontrollierte Cast, den der Bytecode bei jedem Lesevorgang für dich durchführt.

java— editable, runs on the server

Die getClass()-Zeilen bestätigen, dass die Laufzeit Box<Integer> nicht von Box<String> unterscheiden kann — es gibt nur eine Box-Klasse. Der Raw-Form-List-Trick ist die klassische Erasure-Geschichte: Das fehlerhafte add(99) schlüpft an der Typprüfung vorbei, und der Fehler taucht beim nächsten Lesevorgang auf, weil dort der vom Compiler eingefügte Cast liegt. Die Fehlermeldung sagt sogar, was der Cast war: Es wurde versucht, Integer in String zu casten.

Wie es weitergeht

Erasure ist kein akademisches Detail — es ist der Grund hinter fast jedem „das kannst du nicht tun", das der Compiler dir entgegenwirft, wenn du nach elegantem generischem Code greifst. Das letzte Kapitel dieses Teils katalogisiert die vollständige Liste dieser Einschränkungen und erklärt jede einzelne in Bezug auf das, was Erasure verhindert. Weiter zu Java Generics Restrictions.

Übungen

Übung
Warum lehnt Java `public T first() { return new T(); }` innerhalb einer generischen Klasse `Foo<T>` ab?
Warum lehnt Java `public T first() { return new T(); }` innerhalb einer generischen Klasse `Foo<T>` ab?
Was this page helpful?