1. Обзор
Компиляторы и среды выполнения стремятся оптимизировать все, даже самые маленькие и, казалось бы, менее важные части. Когда дело доходит до такого рода оптимизаций, JVM и Java могут многое предложить.
В этой статье мы собираемся оценить одну из этих относительно новых оптимизаций: конкатенацию строк с помощью invokedynamic
.
2. До Java 9
До Java 9 нетривиальные конкатенации строк реализовывались с помощью StringBuilder
. Например, рассмотрим следующий метод:
String concat(String s, int i) {
return s + i;
}
Байт-код для этого простого кода выглядит следующим образом (с javap -c
):
java.lang.String concat(java.lang.String, int);
Code:
0: new #2 // class StringBuilder
3: dup
4: invokespecial #3 // Method StringBuilder."<init>":()V
7: aload_0
8: invokevirtual #4 // Method StringBuilder.append:(LString;)LStringBuilder;
11: iload_1
12: invokevirtual #5 // Method StringBuilder.append:(I)LStringBuilder;
15: invokevirtual #6 // Method StringBuilder.toString:()LString;
Здесь компилятор Java 8 использует StringBuilder
для объединения входных данных метода, даже несмотря
на то, что мы не использовали StringBuilder
в нашем коде. ``
Честно говоря, объединение строк с помощью StringBuilder
довольно эффективно и хорошо спроектировано.
Давайте посмотрим, как Java 9 меняет эту реализацию и каковы мотивы такого изменения.
3. Вызвать динамический
Начиная с Java 9 и как часть JEP 280 , конкатенация строк теперь выполняется с помощью invokedynamic
.
Основной мотивацией изменений является более динамичная реализация . То есть можно изменить стратегию конкатенации без изменения байт-кода. Таким образом, клиенты могут воспользоваться новой оптимизированной стратегией даже без перекомпиляции.
Есть и другие преимущества. Например, байт-код для invokedynamic
более элегантный, менее хрупкий и компактный.
3.1. Большая фотография
Прежде чем углубляться в детали того, как работает этот новый подход, давайте посмотрим на него с более широкой точки зрения.
В качестве примера предположим, что мы собираемся создать новую строку
, присоединив другую строку
с помощью типа int
. Мы можем думать об этом как о функции, которая принимает String
и int
, а затем возвращает конкатенированную String
.
Вот как работает новый подход для этого примера:
- Подготовка сигнатуры функции, описывающей конкатенацию. Например,
(Строка, интервал) -> Строка
- Подготовка фактических аргументов для конкатенации. Например, если мы собираемся соединить
«Ответ равен»
и 42, то эти значения будут аргументами - Вызов метода начальной загрузки и передача ему сигнатуры функции, аргументов и некоторых других параметров.
- Генерация фактической реализации для этой сигнатуры функции и инкапсуляция ее внутри
MethodHandle
- Вызов сгенерированной функции для создания окончательной объединенной строки
Проще говоря, байт-код определяет спецификацию во время компиляции. Затем метод начальной загрузки связывает реализацию с этой спецификацией во время выполнения. Это, в свою очередь, даст возможность менять реализацию, не трогая байт-код.
В этой статье мы раскроем детали, связанные с каждым из этих шагов.
Во-первых, давайте посмотрим, как работает привязка к методу начальной загрузки.
4. Связь
Давайте посмотрим, как компилятор Java 9+ генерирует байт-код для того же метода:
java.lang.String concat(java.lang.String, int);
Code:
0: aload_0
1: iload_1
2: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(LString;I)LString;
7: areturn
В отличие от наивного подхода StringBuilder
, здесь используется значительно меньшее количество инструкций .
В этом байт-коде довольно интересна сигнатура (LString;I)LString .
Он принимает String
и int
( I
представляет int
) и возвращает конкатенированную строку. Это связано с тем, что метод объединяет одну строку
и целое число
вместе.
Как и в других реализациях динамического вызова, большая часть логики перемещается из времени компиляции во время выполнения.
Чтобы увидеть эту логику времени выполнения, давайте проверим таблицу методов начальной загрузки (с помощью javap -c -v
):
BootstrapMethods:
0: #25 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:
(Ljava/lang/invoke/MethodHandles$Lookup;
Ljava/lang/String;
Ljava/lang/invoke/MethodType;
Ljava/lang/String;
[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
Method arguments:
#31 \u0001\u0001
В этом случае, когда JVM впервые видит инструкцию invokedynamic
, она вызывает метод начальной загрузки makeConcatWithConstants
. Метод начальной загрузки, в свою очередь, вернет ConstantCallSite
, который указывает на логику конкатенации.
Среди аргументов, передаваемых методу начальной загрузки, выделяются два:
Ljava/lang/invoke/MethodType
представляет сигнатуру конкатенации строк. В данном случае это(LString;I)LString
, так как мы объединяем целое число сострокой .
\u0001\u0001
— это рецепт построения строки (подробнее об этом позже)
5. Рецепты
Чтобы лучше понять роль рецептов, рассмотрим простой класс данных:
public class Person {
private String firstName;
private String lastName;
// constructor
@Override
public String toString() {
return "Person{" +
"firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' +
'}';
}
}
Чтобы сгенерировать строковое
представление, JVM передает поля firstName
и lastName в инструкцию
invokedynamic
в качестве аргументов:
0: aload_0
1: getfield #7 // Field firstName:LString;
4: aload_0
5: getfield #13 // Field lastName:LString;
8: invokedynamic #16, 0 // InvokeDynamic #0:makeConcatWithConstants:(LString;LString;)L/String;
13: areturn
На этот раз таблица методов начальной загрузки выглядит немного иначе:
BootstrapMethods:
0: #28 REF_invokeStatic StringConcatFactory.makeConcatWithConstants // truncated
Method arguments:
#34 Person{firstName=\'\u0001\', lastName=\'\u0001\'} // The recipe
Как показано выше, рецепт представляет базовую структуру конкатенированной строки String
. Например, предыдущий рецепт состоит из:
- Постоянные строки, такие как «
Person
».
Эти литеральные значения будут присутствовать в объединенной строке как есть. - Два
тега \u0001
для представления обычных аргументов. Они будут заменены фактическими аргументами, такими какfirstName
Мы можем думать о рецепте как о шаблонной строке
, содержащей как статические части, так и переменные заполнители.
Использование рецептов может значительно сократить количество аргументов, передаваемых методу начальной загрузки, поскольку нам нужно передать только все динамические аргументы плюс один рецепт.
6. Варианты байт-кода
Существует два варианта байт-кода для нового подхода к конкатенации. До сих пор мы были знакомы с одной разновидностью: вызовом метода начальной загрузки makeConcatWithConstants
и передачей рецепта. Этот вариант, известный как indy с константами, используется по умолчанию в Java 9.
Вместо использования рецепта второй аромат передает все в качестве аргументов . То есть он не различает постоянную и динамическую части и передает их все в качестве аргументов.
Чтобы использовать второй вариант, мы должны передать параметр -XDstringConcat=indy
компилятору Java . Например, если мы скомпилируем тот же класс Person
с этим флагом, то компилятор сгенерирует следующий байт-код:
public java.lang.String toString();
Code:
0: ldc #16 // String Person{firstName=\'
2: aload_0
3: getfield #7 // Field firstName:LString;
6: bipush 39
8: ldc #18 // String , lastName=\'
10: aload_0
11: getfield #13 // Field lastName:LString;
14: bipush 39
16: bipush 125
18: invokedynamic #20, 0 // InvokeDynamic #0:makeConcat:(LString;LString;CLString;LString;CC)LString;
23: areturn
На этот раз методом начальной загрузки является makeConcat
. Более того, сигнатура конкатенации принимает семь аргументов. Каждый аргумент представляет одну часть из toString
:
- Первый аргумент представляет часть перед переменной
firstName
— литерал«Person{firstName=\'».
- Второй аргумент — это значение поля
firstName .
- Третий аргумент — одинарная кавычка.
- Четвертый аргумент — это часть перед следующей переменной —
«, lastName=\'»
- Пятый аргумент — поле
lastName .
- Шестой аргумент — символ одинарной кавычки.
- Последний аргумент - закрывающая фигурная скобка
Таким образом, метод начальной загрузки имеет достаточно информации, чтобы связать соответствующую логику конкатенации.
Довольно интересно , что также можно вернуться в мир до Java 9 и использовать StringBuilder
с параметром компилятора -XDstringConcat=inline
.
7. Стратегии
В конечном итоге метод начальной загрузки предоставляет MethodHandle
, указывающий на реальную логику конкатенации . На момент написания этой статьи существует шесть различных стратегий для создания этой логики:
Стратегия BC_SB
или «bytecodeStringBuilder
» генерирует один и тот же байт-кодStringBuilder
во время выполнения. Затем он загружает сгенерированный байт-код с помощью методаUnsafe.defineAnonymousClass.
Стратегия BC_SB_SIZED
попытается угадать необходимую емкость дляStringBuilder
. В остальном он идентичен предыдущему подходу. Предположение о емкости потенциально может помочьStringBuilder
выполнить конкатенацию без изменения размера базовогоbyte[]
BC_SB_SIZED_EXACT
— это генератор байт-кода на основеStringBuilder
, который точно вычисляет необходимое хранилище. Чтобы вычислить точный размер, сначала он преобразует все аргументы вString
MH_SB_SIZED
основан наMethodHandle
и в конечном итоге вызывает APIStringBuilder
для конкатенации. Эта стратегия также делает обоснованное предположение о требуемой мощности.MH_SB_SIZED_EXACT
аналогичен предыдущему, за исключением того, что он вычисляет необходимую емкость с полной точностью.MH_INLINE_SIZE_EXACT заранее
вычисляет требуемый объем памяти и напрямую поддерживает свойbyte[]
для хранения результата конкатенации.
Эта стратегия является встроенной, потому что она повторяет то, чтоStringBuilder
делает внутри .
Стратегия по умолчанию — MH_INLINE_SIZE_EXACT
. Однако мы можем изменить эту стратегию, используя системное свойство -Djava.lang.invoke.stringConcat=<strategyName>
.
8. Заключение
В этой подробной статье мы рассмотрели, как реализована новая конкатенация строк
и преимущества использования такого подхода.
Для еще более подробного обсуждения рекомендуется ознакомиться с заметками об экспериментах или даже с исходным кодом .