📋 Раздел 20 · Вопрос #13

Что такое type erasure (стирание типов)

В исполняемом байткоде (.class-файле) и во время работы JVM дженериков практически не существует. Виртуальная машина оперирует сырыми типами (raw types) и вставленными компилято...


🟢 Junior Level

Type Erasure (стирание типов) — это архитектурный механизм компилятора Java, при котором вся информация об аргументах дженерик-типов удаляется (стирается) на этапе компиляции.

В исполняемом байткоде (.class-файле) и во время работы JVM дженериков практически не существует. Виртуальная машина оперирует сырыми типами (raw types) и вставленными компилятором инструкциями приведения типов (checkcast).

// Что пишет разработчик:
List<String> list = new ArrayList<>();
list.add("Hello");
String s = list.get(0);

// Что на самом деле компилируется в байткод:
List list = new ArrayList();
list.add("Hello");
String s = (String) list.get(0); // Компилятор неявно вставил приведение типа!

Зачем было выбрано стирание типов

В 2004 году при релизе Java 5 стояла задача обеспечить 100% обратную совместимость с миллионами строк кода на Java 1.4. Благодаря стиранию типов, новые дженерик-библиотеки могли прозрачно работать со старыми скомпилированными модулями без пересборки всей экосистемы.


🟡 Middle Level

1. Правила стирания типов (JLS §4.6)

Компилятор заменяет переменную типа $T$ по строгому алгоритму:

  1. Неограниченный тип (<T>) стирается до java.lang.Object:
    public class Box<T> { private T val; } 
    // Превращается в:
    public class Box { private Object val; }
    
  2. Ограниченный сверху тип (<T extends Number>) стирается до своей первой границы (First Bound):
    public class NumberBox<T extends Number> { private T val; }
    // Превращается в:
    public class NumberBox { private Number val; }
    
  3. Множественные ограничения (<T extends Comparable<T> & Serializable>) стираются до самого первого типа в списке (в данном случае до Comparable).

2. Практические ограничения, вызванные стиранием типов

Из-за того, что в рантайме информация о конкретном $T$ отсутствует, в Java запрещены следующие конструкции:

public class Restrictions<T> {
    // ❌ 1. Нельзя создать экземпляр через new:
    // T obj = new T(); // Ошибка: JVM не знает конструктор T

    // ❌ 2. Нельзя создать массив дженерик-типа:
    // T[] arr = new T[10]; // Ошибка: массивы reified, им нужен точный тип в рантайме

    // ❌ 3. Нельзя проверять точный дженерик-тип через instanceof:
    // if (x instanceof List<String>) // Ошибка: в рантайме это просто List

    // ❌ 4. Нельзя использовать T в статическом контексте:
    // private static T instance; // Ошибка: static принадлежит классу, а не экземпляру с типом T

    // ❌ 5. Нельзя перегружать методы, если их типы совпадают после стирания:
    // void print(List<String> l) {}
    // void print(List<Integer> l) {} // Ошибка: оба стираются в print(List)
}

🔴 Senior Level

Анатомия байткода: Мостовые методы (Bridge Methods)

Главная архитектурная проблема стирания типов — угроза полиморфизму виртуальных вызовов.

Рассмотрим наследование:

public class Node<T> {
    public void setData(T data) { ... }
}

public class StringNode extends Node<String> {
    @Override
    public void setData(String data) { ... }
}

После компиляции и стирания типов:

  • В Node метод превращается в public void setData(Object data).
  • В StringNode объявлен метод public void setData(String data).
  • Для виртуальной машины setData(Object) и setData(String) — это два совершенно разных перегруженных метода, а не переопределение (override)! Полиморфизм ломается: при вызове через базовую ссылку Node n = new StringNode(); n.setData("test"); метод в StringNode не вызвался бы.

Решение компилятора: Синтетический Bridge Method

Компилятор автоматически генерирует в потомке скрытый мостовой метод (ACC_BRIDGE | ACC_SYNTHETIC):

// Синтетический метод, генерируемый javac в StringNode.class:
public void setData(Object data) {
    // 1. Проверяет тип аргумента через checkcast
    // 2. Делегирует вызов пользовательскому методу с правильной сигнатурой:
    this.setData((String) data);
}

Где дженерики сохраняются: Атрибут Signature

Хотя типы стираются из инструкций выполнения методов, метаданные сигнатур сохраняются в файле .class в атрибуте Signature:

  • Для полей класса (Field.getGenericType()).
  • Для параметров и возвращаемых типов методов (Method.getGenericReturnType()).
  • Для предков и интерфейсов класса (Class.getGenericSuperclass()).
ClassFile {
    ...
    fields {
        field_info {
            u2 name_index;
            ...
            attributes {
                Signature_attribute { // <--- Хранит Ljava/util/List<Ljava/lang/String;>;
                    u2 attribute_name_index;
                    u2 signature_index;
                }
            }
        }
    }
}

Именно поэтому паттерн TypeToken (Jackson TypeReference, Spring ResolvableType) работает: создавая анонимный подкласс new TypeReference<List<User>>() {}, мы заставляем компилятор сохранить сигнатуру предка в метаданных класса, откуда рефлексия извлекает параметры типов в runtime!


4 Tricky Questions

1. Если у типа несколько ограничений (<T extends Comparable<T> & Serializable>), до какого типа компилятор сотрет T?

Ответ: Компилятор всегда стирает тип $T$ до первой объявленной границы (First Bound).

В выражении <T extends Comparable<T> & Serializable> первой границей является Comparable, поэтому в байткоде поле или метод превратится в Comparable. Во всех местах, где требуется использование второго интерфейса (Serializable), компилятор будет генерировать дополнительные явные инструкции приведения типов checkcast.

Именно поэтому спецификация Java требует, чтобы класс (если он есть среди bounds) всегда стоял на первом месте: <T extends Number & Runnable> допустимо, а <T extends Runnable & Number> вызовет ошибку компиляции.

2. Сохраняется ли информация о дженериках для локальных переменных методов внутри байткода?

Ответ: Нет, для локальных переменных метаданные дженериков не сохраняются.

Атрибут Signature генерируется компилятором только для элементов постоянной структуры класса: для объявлений полей, заголовков методов и интерфейсов. Локальные переменные внутри тел методов стираются полностью и окончательно: List<String> list = new ArrayList<>() в байткоде становится локальной переменной типа java.util.List. Никакая рефлексия в рантайме не способна узнать, какой тип был указан в локальной переменной.

3. Можно ли объявить два перегруженных метода, отличающихся только аргументами дженериков (void sort(List<String> l) и void sort(List<Integer> l))?

Ответ: Нет, это вызовет ошибку компиляции: «both methods have the same erasure».

После стирания типов оба метода получат абсолютно идентичную сигнатуру на уровне JVM: void sort(List l). Наличие двух методов с одинаковым именем и одинаковым дескриптором параметров в одном классе запрещено спецификацией байткода Java (JVMS).

Обходные пути: либо дать методам разные имена (sortStrings, sortIntegers), либо использовать фиктивный dummy-параметр.

4. Почему в Java разрешена перегрузка методов, отличающихся возвращаемым типом на уровне байткода (ковapиантные возвращаемые типы), хотя в исходном Java-коде это запрещено?

Ответ: В спецификации языка Java (JLS) сигнатура метода состоит только из его имени и типов параметров (возвращаемый тип в сигнатуру не входит). Поэтому в Java-коде нельзя объявить два метода с одинаковым именем и аргументами, но разным возвращаемым типом.

Однако в спецификации виртуальной машины (JVMS) дескриптор метода включает в себя и возвращаемый тип: (Ljava/lang/Object;)Ljava/lang/String; и (Ljava/lang/Object;)Ljava/lang/Object; для JVM являются двумя разными методами. Именно на этом трюке компилятор строит ковариантные возвращаемые типы и мостовые методы (Bridge Methods).


🎯 Шпаргалка для интервью

Главное о Type Erasure за 1 минуту

  • Что это: Процесс удаления параметров дженериков при компиляции в байткод.
  • Во что стираются типы:
    • Без ограничений: T $\rightarrow$ Object.
    • С ограничением: T extends Number $\rightarrow$ Number (первый bound).
  • Главная цель: 100% бинарная обратная совместимость кода Java 5 со старым кодом Java 1.4.
  • Следствия стирания:
    • Нельзя: new T(), new T[], x instanceof List<String>, class Ex<T> extends Exception.
    • Нельзя перегружать методы с одинаковым стиранием: foo(List<String>) и foo(List<Integer>).
  • Bridge Methods: Синтетические методы, генерируемые компилятором для сохранения виртуального полиморфизма в байткоде.
  • Сохранение метаданных: В атрибуте Signature class-файла (для полей, методов и классов). Используется паттерном TypeToken в Jackson/Spring.

Связанные темы