[Перевод] Как полиморфизм реализован внутри JVM

Перевод данной статьи подготовлен специально для студентов курса «Разработчик Java».

pvd6a7xzq3zopckoatmhx0ohjn8.png


В моей предыдущей статье Everything About Method Overloading vs Method Overriding («Все о перегрузке и переопределении методов») были рассмотрены правила и различия перегрузки и переопределения методов. В этой статье мы посмотрим, как обрабатывается перегрузка и переопределение методов внутри JVM.

Для примера возьмем классы из предыдущей статьи: родительский Mammal (млекопитающее) и дочерний Human (человек).

public class OverridingInternalExample {

    private static class Mammal {
        public void speak() { System.out.println("ohlllalalalalalaoaoaoa"); }
    }

    private static class Human extends Mammal {
        @Override
        public void speak() { System.out.println("Hello"); }

        // Допустимая перегрузка speak()
        public void speak(String language) {
            if (language.equals("Hindi")) System.out.println("Namaste");
            else System.out.println("Hello");
        }

        @Override
        public String toString() { return "Human Class"; }
    }

    //  Код ниже содержит вывод метода и байткод для вызова метода
    public static void main(String[] args) {
        Mammal anyMammal = new Mammal();
        anyMammal.speak();  // Output - ohlllalalalalalaoaoaoa
        // 10: invokevirtual #4 // Method org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V

        Mammal humanMammal = new Human();
        humanMammal.speak(); // Output - Hello
        // 23: invokevirtual #4 // Method org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V

        Human human = new Human();
        human.speak(); // Output - Hello
        // 36: invokevirtual #7 // Method org/programming/mitra/exercises/OverridingInternalExample$Human.speak:()V

        human.speak("Hindi"); // Output - Namaste
        // 42: invokevirtual #9 // Method org/programming/mitra/exercises/OverridingInternalExample$Human.speak:(Ljava/lang/String;)V
    }
}


На вопрос о полиморфизме мы можем посмотреть с двух сторон: с «логической» и «физической». Давайте сначала рассмотрим логическую сторону вопроса.

Логическая точка зрения


С логической точки зрения, на этапе компиляции вызываемый метод рассматривается как относящийся к типу ссылки. Но во время выполнения будет вызываться метод объекта, на который указывает ссылка.

Например, в строке humanMammal.speak(); компилятор думает, что будет вызван Mammal.speak(), так как humanMammal объявлен как Mammal. Но во время выполнения JVM будет знать, что в humanMammal содержится объект Human и фактически вызовет метод Human.speak().

Это все довольно просто, пока мы остаемся на концептуальном уровне. Но как же JVM обрабатывает это все внутри? Как JVM вычисляет, какой метод должен быть вызван?

Также мы знаем, что перегруженные методы (overload) не называются полиморфными и резолвятся во время компиляции. Хотя иногда перегрузку методов называют полиморфизмом времени компиляции или ранним/статическим связыванием.

Переопределенные методы (override) резолвятся во время выполнения, так как компилятор не знает, есть ли переопределенные методы в объекте, который присваивается ссылке.

Физическая точка зрения


В этом разделе мы попытаемся найти «физические» доказательства для всех вышеперечисленных утверждений. Для этого посмотрим на байткод, который мы можем получить, запустив javap -verbose OverridingInternalExample. Параметр -verbose позволит нам получить более наглядный байткод, соответствующий нашей java-программе.

Команда выше покажет две секции байткода.

1. Пул констант. Содержит почти все, что необходимо для выполнения программы. Например, ссылки на методы (#Methodref), классы (#Class), литералы строк (#String).

k7nfx7oupro69w7nazss_icos9o.png

2. Байткод программы. Выполняемые инструкции байткода.

6c1y5gzslpkiodz_w7b7zh10yzw.png

Почему перегрузка методов называется статическим связыванием


В приведенном выше примере компилятор думает, что метод humanMammal.speak() будет вызван из класса Mammal, хотя во время выполнения он будет вызываться из объекта, ссылка на который содержится в humanMammal — это будет объект класса Human.

Посмотрев на наш код и результат javap, мы видим, что для вызова методов humanMammal.speak(), human.speak() и human.speak("Hindi") используется разный байткод, так как компилятор может различить их на основании ссылки на класс.

Таким образом, в случае перегрузки метода, компилятор способен идентифицировать инструкции байткода и адреса методов во время компиляции. Именно поэтому это называют статическим связыванием или полиморфизмом времени компиляции.

Почему переопределение методов называется динамическим связыванием


Для вызова методов anyMammal.speak() и humanMammal.speak() байткод одинаковый, так как с точки зрения компилятора оба метода вызываются для класса Mammal:

invokevirtual #4 // Method org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V


Итак, теперь возникает вопрос, если у обоих вызовов одинаковый байткод, как JVM узнает, какой метод вызвать?

Ответ спрятан в самом байткоде и в инструкции invokevirtual. Согласно спецификации JVM (прим. переводчика: ссылка на JVM spec 2.11.8):

Инструкция invokevirtual вызывает метод экземпляра через диспетчеризацию по (виртуальному) типу объекта. Это нормальная диспетчеризация методов в языке программирования Java.

JVM использует инструкцию invokevirtual для вызова в Java методов, эквивалентных виртуальным методам C++. В C++ для переопределения метода в другом классе, метод должен быть объявлен как виртуальный (virtual). Но в Java по умолчанию все методы виртуальные (кроме final и static методов), поэтому в дочернем классе мы можем переопределить любой метод.

Инструкция invokevirtual принимает указатель на метод, который нужно вызвать (#4 — индекс в пуле констант).

invokevirtual #4   // Method org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V


Но ссылка #4 ссылается дальше на другой метод и Class.

#4 = Methodref   #2.#27   // org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V
#2 = Class   #25   // org/programming/mitra/exercises/OverridingInternalExample$Mammal
#25 = Utf8   org/programming/mitra/exercises/OverridingInternalExample$Mammal
#27 = NameAndType   #35:#17   // speak:()V
#35 = Utf8   speak
#17 = Utf8  ()V


Все эти ссылки используются совместно для получения ссылки на метод и класс, в котором находится нужный метод. Это также упоминается в спецификации JVM (прим. переводчика: ссылка на JVM spec 2.7):

Java Virtual Machine не требует какой-либо определённой внутренней структуры объектов.
В некоторых реализациях Java Virtual Machine, выполненных компанией Oracle, ссылка на экземпляр класса представляет собой ссылку на обработчик, который сам по себе состоит из пары ссылок: одна указывает на таблицу методов объекта и указатель на объект Class, представляющий тип объекта, а другая на область данных в куче, содержащую данные объекта.


Это означает, что каждая ссылочная переменная содержит два скрытых указателя:

  1. Указатель на таблицу, которая содержит методы объекта и указатель на объект Class, например, [speak(), speak(String) Class object]
  2. Указатель на память в куче, выделенную для данных объекта, таких как значения полей объекта.


Но опять возникает вопрос: как с этим работает invokevirtual? К сожалению, никто не может ответить на этот вопрос, потому что все зависит от реализации JVM и варьируется от JVM к JVM.

Из приведенных выше рассуждений можно сделать вывод, что ссылка на объект косвенно содержит ссылку/указатель на таблицу, которая содержит все ссылки на методы этого объекта. Java позаимствовала эту концепцию из C ++. Эта таблица известна под различными именами, такими как таблица виртуальных методов (VMT), таблица виртуальных функций (vftable), виртуальная таблица (vtable), таблица диспетчеризации.

Мы не можем быть уверены в том, как vtable реализован в Java, потому что это зависит от конкретной JVM. Но мы можем ожидать, что стратегия будет примерно такая же, как и в C ++, где vtable — это структура, похожая на массив, которая содержит имена методов и их ссылки. Всякий раз, когда JVM пытается выполнить виртуальный метод, она запрашивает его адрес в vtable.

Для каждого класса существует только одна vtable, это означает, что таблица уникальна и одинакова для всех объектов класса, аналогично объекту Class. Объекты Class подробнее рассмотрены в статьях Why an outer Java class can«t be static и Why Java is Purely Object-Oriented Language Or Why Not.

Таким образом, существует только одна vtable для класса Object, которая содержит все 11 методов (если не учитывать registerNatives) и ссылки, соответствующие их реализации.

n1kyhyiy2gta8yqfnkns5z_8swy.png

Когда JVM загружает класс Mammal в память, она создает для него объект Class и создает vtable, которая содержит все методы из vtable класса Object с такими же ссылками (поскольку Mammal не переопределяет методы из Object) и добавляет новую запись для метода speak().

abwbpgthcjjnzyjhzbpvi-kpzsw.png

Потом наступает очередь класса Human, и JVM копирует все записи из vtable класса Mammal в vtable класса Human и добавляет новую запись для перегруженной версии speak(String).

JVM знает, что класс Human переопределил два метода: toString() из Object и speak() из Mammal. Теперь для этих методов, вместо создания новых записей с обновленными ссылками, JVM изменит ссылки на уже существующие методы в том же индексе, в котором они присутствовали ранее, и сохранит те же имена методов.

qvqjzyrkzsg26tvb3kxqfr56ooe.png

Инструкция invokevirtual заставляет JVM обрабатывать значение в ссылке на метод # 4 не как адрес, а как имя метода, которое нужно искать в vtable для текущего объекта.
Я надеюсь, теперь стало более понятно то, как JVM использует пул констант и таблицу виртуальных методов для определения того, какой метод вызывать.
Код примера вы можете найти в репозитории Github.

© Habrahabr.ru