Java 21 предлагает много классных функциональностей, и одна из них  —  шаблоны строк (англ. String Templates). При том, что их целевое назначение не ограничивается только строковой интерполяцией, для Java-разработчиков они служат еще одним “правильным” способом конкатенации строк. 

Что значит “правильный” способ? Поизучав байт-код, я узнала кое-что интересное и удивительное о различных техниках конкатенации и интерполяции строк в современном языке Java. 

Кроме того, сравнила эти техники с аналогами в Kotlin.

Но начнем с Java.

Оператор + 

Известно, что использование оператора + считается плохой практикой. Дело в том, что строки неизменяемы, и в соответствии с внутренним механизмом новая строка создается для каждой объединяемой части. Однако “meten is weten”, что в переводе с голландского означает “хочешь знать  —  измерь”. Посмотрим, что на самом деле происходит внутри: 

// Пример #1:
String example1 = "some String " + 42;

// Пример #2:
int someInt = 42;
String example2 = "some String " + someInt + " other String " + someInt;

// Пример #3:
String example3 = "";
for (int i = 0; i < 10; i++) {
example3 += someInt;
}

В байт-коде примера #1 осуществляется одно выделение строки. Компилятор Java понимает, что магическое число является константой, поэтому оно загружается как часть строки String в стек операндов:

0: ldc     #7  // Строка some String42

Но мы не намерены применять магические значения, поэтому посмотрим, что происходит с переменными. 

В примере #2 при использовании переменной компилятор Java не может выполнить такую же оптимизацию, но зато совершает какую-то сложную операцию с помощью invokedynamic:

0: bipush        42
2: istore_1
3: iload_1
4: iload_1
5: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:
(II)Ljava/lang/String;

...

BootstrapMethods:
0: #22 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:
#23 одна String \u0001 другая String \u0001

Эта инструкция позволяет во время выполнения вызвать метод bootstrap для конкатенации. Мы даем ему “рецепт”: some String \u0001 other String \u0001, который в данном случае содержит 2 плейсхолдера. При конкатенации большего числа переменных увеличивается и число плейсхолдеров, но это все равно одна строка String в пуле констант.

Фишка подхода invokedynamic состоит в том, что при появлении обновленных версий JDK с техниками конкатенации JDK, байт-код может оставаться прежним, тогда как действия метода bootstrap усложняются (чуть позже о деталях текущей реализации).

В примере #3 в цикле выполняется следующая инструкция: 

16: invokedynamic #9,  0  // InvokeDynamic #0:makeConcatWithConstants:
(Ljava/lang/String;I)Ljava/lang/String;

Она приводит к выделению ненужного количества экземпляров String.

String::format

На мой взгляд, String::format  —  более эффективная альтернатива оператору +. Данный метод предлагает действительно улучшенную читаемость в ряде случаев и поддерживает локализацию. Основные тесты показывают, что его производительность немного превосходит конкатенацию. Однако реализация метода format создает новую строку String для каждого параметра.

Проведем небольшой эксперимент: 

int firstValue = 12345;
int secondValue = 987654321;
int thirdValue = 117117;
String test = String.format("test %s and %s and %s", firstValue, secondValue, thirdValue);

В байт-коде помещаем все значения в стек операндов и просто вызываем статический метод: 

34: invokestatic  #15  // Метод java/lang/String.format:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;

Посмотрим на дамп (“снимок”) кучи, полученный после вызова метода. Для этого скомпилируем программу и запустим ее с отключенным сборщиком мусора (чтобы он не собирал экземпляры String, пока мы их не изучим): 

javac --enable-preview --source=21 Main.java
java --enable-preview -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC
Main

Для создания дампа кучи используется VisualVM. В разделе экземпляров String видны следующие значения: 

Новейшие шаблоны строк Java 

Шаблоны строк  —  отличная новая функциональность не только из-за эффективного использования памяти. По факту, в базовых случаях она работает так же, как и конкатенация срок. Строковые шаблоны задействуют инструкцию invokedynamic для передачи “рецепта” методу bootstrap, позволяя ему творить свою магию. 

Шаблоны строк удивительны тем, что могут переопределять способ обработки шаблонов и позволяют создавать другие типы помимо строк (если необходимо). 

Подход invokedynamic 

Как мы уже выяснили, invokedynamic применяется в большинстве современных техник конкатенации/интерполяции строк в Java.

Можно ли считать такой подход идеальным? Нет, поскольку он сопровождается избыточным выделением строк.  

Мы передаем “рецепт” (шаблон с плейсхолдерами) как одну строку. Если значения, которые нужно вставить в плейсхолдеры, поступают из пула констант (код плейсхолдера \u0002), тогда дополнительные строки не выделяются.

С другой стороны, если применяются обычные переменные, то код плейсхолдера  —  \u0001. В этом случае во время выполнения метод bootstrap создает отдельный экземпляр строки String для каждого фрагмента между плейсхолдерами, и эти строки объединяются с параметрами для создания окончательной строки.

Чтобы в этом убедиться рассмотрим небольшой пример: 

int firstValue = 12345;
int secondValue = 987654321;
int thirdValue = 117117;

// мы можем воспользоваться этими шаблонами строк:
String test = STR."test \{firstValue} and \{secondValue} and \{thirdValue}";

// но эта строка приведет к абсолютно идентичному байт-коду:
// String test = "test " + firstValue + " and " + secondValue + " and " + thirdValue;

В байт-коде видим invokedynamic с одной строкой, содержащей “рецепт”: 

13: invokedynamic #9,  0  // InvokeDynamic #0:makeConcatWithConstants:
(III)Ljava/lang/String;

...

BootstrapMethods:
0: #27 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:
#25 test \u0001 and \u0001 and \u0001

Если запустить программу с отключенным сборщиком мусора и сделать дамп кучи, то увидим следующие экземпляры String (и плюсом получившуюся строку String, конечно):

Для сравнения, если бы мы использовали StringBuilder, это выглядело бы так: 

String test = new StringBuilder()
.append("test ")
.append(firstValue)
.append(" and ")
.append(secondValue)
.append(" and ")
.append(thirdValue)
.toString();

Выделяется одно значение " and ", даже если ввести его дважды. Получаются 3 экземпляра String: 2 фрагмента и результат. 

Теперь о Kotlin

Данный раздел не принесет каких-то “открытий чудных”, так как с точки зрения внутреннего устройства Kotlin (1.9.0) ведет себя аналогично Java. Оператор +, а также функция plus() и синтаксис интерполяции строк (например, val testStr = “this is $testNum test”)  —  все они используют invokedynamic.

В старых версиях и Java, и Kotlin применяли StringBuilder для оптимизации конкатенации строк. Теперь они задействуют invokedynamic, позволяющий отделять логику конкатенации от байт-кода (она находится в методах bootstrap и target). Реализация, скорее всего, будет совершенствоваться, и другие языки JVM смогут извлечь из нее преимущества без внесения каких-либо изменений (или с минимальными изменениями). 

Заключение 

Что касается лучших практик, то, вероятно, не следует нарушать общепринятые соглашения. Но вот узнать, что происходит внутри, очень даже следует. 

Что применять? На этот вопрос есть классический ответ: зависит от ситуации! 

Иногда стоит воспользоваться оператором +. В каких-то случаях (очень редких) он улучшает читаемость.  

Если важна производительность, то больше подойдет StringBuilder или StringBuffer. StringBuffer также обеспечивает все виды потокобезопасности. String::format довольно быстро работает, но StringBuilder намного быстрее. В качестве недостатка StringBuilder отметим многословность. 

Шаблоны строк  —  отличный выбор для эффективного решения задач форматирования и читаемости при условии, что вы не слишком беспокоитесь по поводу памяти и скорости. Следует помнить, что в новых версиях их возможности будут улучшаться и выходить за рамки простой интерполяции строк. 

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Nataliia Dziubenko: Java 21: So How Should We Construct Strings Now?

Предыдущая статьяРецензирование кода Flutter: лучшие практики
Следующая статьяВстроенная поддержка контейнеров для .NET 7  —  контейнеризация приложений .NET без Dockerfile