Полный вывод байт-кода для этого класса огромен, поэтому позвольте мне показать вам наиболее интересные фрагменты.
Вот как начинается раздел Code
метода test
:
0: aload_0 1: dup 2: invokestatic #33 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object; 5: pop 6: astore_1 7: iconst_0 8: istore_2 9: aload_1 10: iload_2 11: invokedynamic #39, 0 // InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I 16: tableswitch { // 0 to 4 0: 52 1: 85 2: 121 3: 143 4: 176 default: 236 }
Давайте пойдем шаг за шагом и посмотрим, что здесь происходит.
0: aload_0
Поскольку наш метод test
принимает аргумент Object
, ссылка на объект изначально сохраняется в массиве локальных переменных с индексом 0
. Инструкция aload_0
используется для загрузки ссылки на объект с индексом 0
в стек операндов.
1: dup
dup
предписывает продублировать верхнее значение стека:
2: invokestatic #33 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
Эта инструкция вызывает статический метод requireNonNull
, находящийся в java.util.Objects
. Метод принимает объект в качестве аргумента и возвращает объект. Он проверяет, не является ли значение нулевым, и возвращает его; в противном случае выдается исключение.
Статус нашего массива локальных переменных и стека операндов следующий:
5: pop
pop
указывает удалить верхнее значение из стека операндов:
6: astore_1
Эта операция сохраняет значение с вершины стека операндов как локальную переменную с индексом 1
:
7: iconst_0
Загрузите постоянное целочисленное значение 0
в стек:
8: istore_2
Сохраните верхнее целочисленное значение из стека как локальную переменную:
9: aload_1
Загрузите ссылку на объект из локальных переменных по индексу 1
в стек операндов:
10: iload_2
Загрузите целочисленное значение из локальных переменных по индексу 2
в стек операндов:
Следующие инструкции — invokedynamic
и tableswitch
, и они заслуживают более глубокого объяснения.
командная работа invokedynamic и tableswitch
В данном конкретном случае инструкции invokedynamic
и tableswitch
работают вместе как одна команда. Как следует из названия, invokedynamic
вызывает динамически определяемый метод, а это означает, что мы «не знаем», какой это будет метод во время компиляции.
Почему необходимо динамически определять, как именно должен работать оператор switch? Одна из идей, лежащих в основе этого подхода, состоит в том, чтобы отделить как можно больше логики переключения от нашего байт-кода, сделав его более компактным. Эта логика, включая определение типа, реализована в методах начальной загрузки.
Еще одним преимуществом этого подхода является то, что если в более новой версии Java будет представлена более продвинутая логика для операторов switch, наш ранее сгенерированный байт-код будет продолжать работать без перекомпиляции, а также сможет извлечь выгоду из новых функций.
Предполагается, что это сработает быстро, так как мы проходим логику поиска метода только в первый раз. После этого целевой метод связывается напрямую, и при тех же обстоятельствах JVM будет вызывать связанный метод как любой обычный.
Инструкция invokedynamic
используется для многих функций Java, включая конкатенацию строк, лямбда-выражения и т. д.
Специально для функциональности переключателя invokedynamic
используется вместе с одним из двух методов начальной загрузки: typeSwitch
или enumSwitch
. Они облегчают вызов методов doTypeSwitch
или doEnumSwitch
соответственно.
В нашем примере мы не имеем дело с типами enum. Однако наш оператор switch содержит смесь разных типов, что является территорией doTypeSwitch
. doTypeSwitch
берет значения Object
и int
из стека операндов и возвращает int
. Затем это целочисленное значение используется инструкциями tableswitch
или lookupswitch
для определения целевого адреса и перехода к этому местоположению.
invokedynamic для переключения типа
Давайте внимательнее посмотрим на инструкцию invokedynamic
в нашем байт-коде:
11: invokedynamic #39, 0 // InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I
Подсказка, предоставленная javap
, указывает, что ссылка #39
в пуле констант указывает на значение: InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I
, которое содержит другую ссылку #0
. Эта ссылка указывает на интересный раздел нашего байт-кода под названием BootstrapMethods
. Посмотрим его запись #0
, так как она актуальна для нас:
BootstrapMethods: 0: #109 REF_invokeStatic java/lang/runtime/SwitchBootstraps.typeSwitch:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite; Method arguments: #43 java/lang/String #43 java/lang/String #43 java/lang/String #23 com/smthelusive/PatternMatching$Test #28 com/smthelusive/PatternMatching$ParentTest
Запись #0
указывает на метод typeSwitch
, расположенный в классе java.lang.runtime.SwitchBootstraps
. Это метод начальной загрузки, который будет вызываться для нашего оператора switch.
метод начальной загрузки typeSwitch
Итак, что же такого особенного в этом методе начальной загрузки?
Он должен динамически (во время выполнения) определить и подготовить целевой метод для вызова.
Давайте проверим исходный код:
public static CallSite typeSwitch(MethodHandles.Lookup lookup, String invocationName, MethodType invocationType, Object... labels) { if (invocationType.parameterCount() != 2 || (!invocationType.returnType().equals(int.class)) || invocationType.parameterType(0).isPrimitive() || !invocationType.parameterType(1).equals(int.class)) throw new IllegalArgumentException("Illegal invocation type " + invocationType); requireNonNull(labels); labels = labels.clone(); Stream.of(labels).forEach(SwitchBootstraps::verifyLabel); MethodHandle target = MethodHandles.insertArguments(DO_TYPE_SWITCH, 2, (Object) labels); return new ConstantCallSite(target); }
Метод начальной загрузки принимает экземпляр Lookup
, который является контекстом поиска. JVM помещает контекст поиска в стек операндов перед вызовом метода.
invocationName
содержит имя метода invoke
, который фактически выполняет вызов. Однако это значение даже не используется.
MethodType
содержит дескриптор целевого метода. Это значение берется из определенной записи NameAndType
в пуле констант, на которую ссылается инструкция invokedynamic
.
Это ссылочные записи:
#39 = InvokeDynamic #0:#40 // #0:typeSwitch:(Ljava/lang/Object;I)I #40 = NameAndType #41:#42 // typeSwitch:(Ljava/lang/Object;I)I
Здесь MethodType
выражается через следующий дескриптор: (Ljava/lang/Object;I)I
, что означает, что типы аргументов — Object
и int
, а возвращаемый тип — int
.
Самый интересный аргумент метода typeSwitch
— это Object… labels
. Это массив всех типов для случаев в нашем операторе switch. Для нас это типы: String
, String
, String
, Test
и ParentTest
.
Имена этих типов находятся в пуле констант в виде Class
записей. Мы передаем ссылки на эти типы в качестве аргументов методу начальной загрузки в секции BootstrapMethods
:
Method arguments: #43 java/lang/String #43 java/lang/String #43 java/lang/String #23 com/smthelusive/PatternMatching$Test #28 com/smthelusive/PatternMatching$ParentTest
Теперь у метода typeSwitch
есть вся необходимая информация.
Что именно он делает?
Он проверяет аргументы, затем создает экземпляр CallSite
и помещает его в стек. Сайт вызова содержит MethodHandle
, который является целевым методом, вызываемым конечной целью инструкции invokedynamic
.
Как он получает этот супер важный MethodHandle
? Есть специальный механизм, который можно считать более современным, безопасным и быстрым видом отражения. Он используется для поиска экземпляра MethodHandle
.
В коде начальной загрузки для случаев переключения мы видим, что ищем определенный MethodHandle DO_TYPE_SWITCH
, который инициализируется в статическом блоке:
DO_TYPE_SWITCH = LOOKUP.findStatic(SwitchBootstraps.class, "doTypeSwitch", MethodType.methodType(int.class /* return type */, Object.class /* argument 0 */, int.class /* argument 1 */, Object[].class /* argument 2 that we didn't know about */));
Здесь мы видим, что имя целевого метода — “doTypeSwitch”
. Удивительно, но он принимает больше аргументов, чем мы ожидали. Почему? Первые два аргумента обычно берутся из стека операндов, а последний аргумент вводится методом начальной загрузки:
MethodHandle target = MethodHandles.insertArguments(DO_TYPE_SWITCH, 2, (Object) labels);
Как вы помните, мы приняли labels
(набор типов case) в качестве аргумента метода начальной загрузки, теперь мы внедряем его в качестве второго аргумента для нашего целевого метода.
Наконец, метод invokeExact
будет использовать CallSite
, ранее помещенный в стек, для вызова целевого метода.
целевой метод doTypeSwitch
Вот исходный код целевого метода doTypeSwitch
:
private static int doTypeSwitch(Object target, int startIndex, Object[] labels) { if (target == null) return -1; // Dumbest possible strategy Class<?> targetClass = target.getClass(); for (int i = startIndex; i < labels.length; i++) { Object label = labels[i]; if (label instanceof Class<?> c) { if (c.isAssignableFrom(targetClass)) return i; } else if (label instanceof Integer constant) { if (target instanceof Number input && constant.intValue() == input.intValue()) { return i; } else if (target instanceof Character input && constant.intValue() == input.charValue()) { return i; } } else if (label.equals(target)) { return i; } } return labels.length; }
Вначале мы поместили в стек следующие значения: ссылку на объект, для которого выполняется весь оператор switch, и целочисленное значение, инициализированное 0
. Если первое ясно, то для чего это целочисленное значение?
Давайте еще раз посмотрим на наш оператор switch:
switch (obj) { case String s when s.length() == 1 -> System.out.println("1 symbol: " + s); case String s when s.length() == 2 -> System.out.println("2 symbols: " + s); case String s -> System.out.println("more symbols: " + s); case Test(int value) -> System.out.println("value inside record: " + value); case ParentTest(Test(int value)) -> System.out.println("value inside nested record: " + value); default -> System.out.println("default"); }
У нас есть три случая, когда тип String
. Предположим, что входящий Object действительно является экземпляром String. Загадочное целочисленное значение (назовем его tmp
) в этот момент равно 0
. Итак, что происходит дальше?
Мы достигаем инструкции invokedynamic
, которая ищет и вызывает метод doTypeSwitch
. Результатом этого метода является целочисленное значение, находящееся в стеке операндов.
Это значение позволяет нам определить адрес и перейти к определенной инструкции байт-кода. Только после прыжка мы оценим сторожевое условие s.length() == 1
. Если это условие не выполняется, мы перезаписываем значение tmp
на 1
и возвращаемся к началу. Оказавшись там, мы снова помещаем его в стек операндов и снова вызываем метод doTypeSwitch
, где на этот раз мы принимаем startIndex
равным 1
.
Когда мы нажимаем метод doTypeSwitch
во второй раз, мы снова оказываемся в случае String. Однако разные startIndex
приведут к другому возвращаемому значению. Это приведет нас к другому адресу байт-кода. Мы перейдем ко второй ветке, где мы оценим состояние защиты для этой ветки: when s.length() == 2
.
Мы будем продолжать этот процесс до тех пор, пока не удовлетворим защитному условию или не встретим участок кода, в котором нет защитного условия. Мы можем выйти из оператора switch, как только выполним блок кода для одного из случаев.
Давайте проверим часть байт-кода, которая иллюстрирует эту логику (см. мои комментарии, выделенные жирным шрифтом):
9: aload_1 10: iload_2 11: invokedynamic #39, 0 // InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I 16: tableswitch { // 0 to 4 0: 52 // using the returned integer value 0, we find address 52 1: 85 // if the first guard failed, target returns 1, which maps to address 85 2: 121 // int value == 2, go to address 121 3: 143 // different type, the target method returns 3, which points to address 143 4: 176 // and so on default: 236 } 52: aload_1 // first case for String brings us here 53: checkcast #43 // class java/lang/String // check if type is String, so the guard on String length can be performed safely 56: astore_3 57: aload_3 58: invokevirtual #45 // Method java/lang/String.length:()I 61: iconst_1 62: if_icmpeq 70 // if guard condition is satisfied, go to 70, else: 65: iconst_1 // put integer 1 to opstack 66: istore_2 // overwrite tmp variable from 0 to 1 67: goto 9 // jump to the top and start over 70: getstatic #49 // Field java/lang/System.out:Ljava/io/PrintStream; 73: aload_3 74: invokedynamic #55, 0 // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String; 79: invokevirtual #59 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 82: goto 247 // if guard condition is satisfied and code was executed from address 70 until here, we can jump to the end of switch statement 85: aload_1 // second case for String brings us here, where we do all the same casting, condition checking, and so on ...
динамический переключатель перечисления
Как упоминалось ранее, существует еще один динамически загружаемый метод для операторов switch: doEnumSwitch
. Этот метод отвечает за обработку случаев, подобных следующим:
enum Color { YELLOW, GREEN, BLUE; } static void test(Color value) { switch(value) { case BLUE -> System.out.println("The color blue"); case YELLOW -> System.out.println("The color yellow"); case Color c -> System.out.println("Another color" + c); } }
Этот пример содержит тип перечисления и конкретные значения перечисления в одном и том же операторе switch. Процесс загрузки аналогичен. В разделе BootstrapMethods
мы увидим метод enumSwitch
, который облегчит вызов doEnumSwitch
.
tableswitch против lookupswitch
Немного изменим наш пример:
switch (obj) { case String s when s.length() == 1 -> System.out.println("1 symbol: " + s); case String s -> System.out.println("more symbols: " + s); default -> System.out.println("default"); }
Если мы его скомпилируем и проверим байт-код, то найдем lookupswitch
вместо tableswitch
:
16: lookupswitch { // 2 0: 44 1: 77 default: 99 }
В чем разница между этими двумя?
tableswitch
содержит таблицу адресов байт-кода, где мы можем получить любой адрес по индексу, как в массиве, что очень быстро. Сложность O(1)
.
С другой стороны, lookupswitch
содержит сопоставление ключей с адресами. Ключи в таблице всегда должны быть отсортированы. При поиске определенного адреса выполняет бинарный поиск по ключам. Эта операция немного медленнее, но все же достаточно быстрая, со сложностью O(log n)
.
В чем тогда преимущество использования lookupswitch
?
Ну а в нашем случае причина в размерах этой конструкции. У нас всего две записи в таблице, что занимает меньше места по сравнению с минимальным пространством, необходимым для файла tableswitch
. Таким образом, преимущество скорости tableswitch
не стоит дополнительного места, которое можно сэкономить.
Однако в природе есть более забавные случаи, когда lookupswitch
становится намного разумнее, чем tableswitch
.
Рассмотрим следующий пример:
static void test(int value) { switch (value) { case 1: System.out.println(1); case 2: System.out.println(10); case 3: System.out.println(100); case 4: System.out.println(1000); default: System.out.println(10000); } }
Это приведет к tableswitch
:
0: iload_0 1: tableswitch { // 1 to 4 1: 32 2: 39 3: 47 4: 55 default: 64 }
Само целочисленное значение используется как ключ для поиска адреса байт-кода в tableswitch
. Поскольку все значения от 1 до 4 охватываются оператором switch, они становятся отличными индексами.
Давайте изменим код на следующий:
static void test(int value) { switch (value) { case 1: System.out.println(1); case 10: System.out.println(10); case 100: System.out.println(100); case 1000: System.out.println(1000); default: System.out.println(10000); } }
В этом случае, чтобы использовать tableswitch
, нам нужно охватить все индексы от 1 до 10, от 10 до 100 и так далее в этой таблице. Это привело бы к большой структуре, что сделало бы непрактичным использование tableswitch
. Вместо этого мы ищем адрес, используя lookupswitch
, где мы сравниваем наше целочисленное значение с ключами, чтобы найти правильный адрес.
Байт-код преобразуется в следующее:
0: iload_0 1: lookupswitch { // 4 1: 44 10: 51 100: 59 1000: 67 default: 76 }
Таким образом, lookupswitch
больше подходит для случаев, когда значения более разбросаны, с промежутками между ключами.
Последние мысли
Вызов динамического метода изначально не поддерживался в JVM.
Изначально JVM разрабатывалась с расчетом на один статически типизированный язык — Java. Однако генерировать файлы классов и запускать их в JVM было настолько удобно, что стало появляться все больше и больше языков JVM.
Однако динамически типизированным языкам пришлось нелегко, потому что в JVM все нужно было знать во время компиляции. Таким образом, инструкция invokedynamic
была введена и для поддержки языков с динамической типизацией*.
Это оказалось полезным и для Java. Например, это помогает гарантировать, что определенные классы или методы генерируются во время выполнения только при необходимости (например, для лямбда-выражений). Если лямбда-функция никогда не выполняется во время выполнения, мы экономим место за счет более компактного байт-кода.
Единственным преимуществом динамического вызова операторов switch является отделение логики проверки типов от нашего байт-кода. Однако давайте еще раз подчеркнем: когда мы видим метод typeSwitch
в нашем байт-коде, мы уже точно знаем, какой метод он загрузит (поскольку в настоящее время есть только один вариант: doTypeSwitch
). То же самое относится к enumSwitch
, который исключительно загружает метод doEnumSwitch
.
Итак, почему это не может быть обычным вызовом метода, известного во время компиляции и принимающего массив строк с типами case? Возможно, это связано с требуемой в байт-коде работой по загрузке всех необходимых параметров в стек операндов перед вызовом такого метода. Другая возможная причина заключается в том, что планируется ввести дополнительные параметры, которые можно выбирать динамически. На данный момент я не уверен, почему был сделан такой выбор, поэтому, если кто-то знает лучше, поделитесь в комментариях.
Еще одна загадка связана с преимуществами использования динамического переключателя enum.
Я сделал несколько основных сравнений производительности для нового синтаксиса, использующего переключатель перечисления, и старого способа достижения того же результата без динамического вызова.
При динамическом вызове:
static void test(Color value) { switch(value) { case YELLOW -> System.out.println("The color yellow"); case BLUE -> System.out.println("The color blue"); case Color c -> System.out.println("Another color" + c); } }
Без динамического вызова:
static void test(Color value) { switch(value) { case YELLOW -> System.out.println("The color yellow"); case BLUE -> System.out.println("The color blue"); default -> System.out.println("Another color" + value); } }
Мне не удалось доказать, что новый способ для данного конкретного случая дает выигрыш в производительности. На самом деле вроде работает немного медленнее (что неудивительно), а байткод занимает больше места.
Итак, я с нетерпением жду, что изменится в отношении этой функциональности в следующих выпусках.
Оставайтесь с нами 😎.
* Я узнал, что JVM изначально разрабатывался исключительно для Java из доклада Эдоардо WebAssembly для фанатов Java на JNation 2023. Посмотрите эту бумагу.