Полный вывод байт-кода для этого класса огромен, поэтому позвольте мне показать вам наиболее интересные фрагменты.

Вот как начинается раздел 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. Посмотрите эту бумагу.