Java 21: Как же нам теперь работать со строками строки?
В Java 21 появилось много интересных функций, и одна из них – шаблоны строк. Хотя они служат не только для классической интерполяции строк, для нас, разработчиков Java, это еще один способ “правильной” конкатенации строк.
Но что такое “правильный” способ? Покопавшись в байткоде, я узнал несколько интересных и удивительных вещей о различных методах конкатенации и интерполяции строк в современной Java.
Я также сравнил их со способом Kotlin (под капотом) 😏.
Но начнем с Java.
Оператор +
Мы всегда знали, что использование оператора + – плохая практика, поскольку строки неизменяемы, а в Kotlin для каждой конкатенируемой части создается новая строка. Однако, как говорят в голландском языке, “meten is weten”, что означает “измерять – значит знать”. Давайте посмотрим, что же на самом деле происходит внутри:
// example #1:
String example1 = "some String " + 42;
// example #2:
int someInt = 42;
String example2 = "some String " + someInt + " other String " + someInt;
// example #3:
String example3 = "";
for (int i = 0; i < 10; i++) {
example3 += someInt;
}
Компилятор Java достаточно умен, чтобы понять, что магическое число является константой, поэтому оно загружается в стек операндов как часть String:
0: ldc #7 // String 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 some String \u0001 other String \u0001
Эта инструкция позволяет загрузить во время выполнения метод, который необходимо вызвать для конкатенации. Мы даем ему рецепт: some String \u0001 other String \u0001, который в данном случае содержит два placeholders. Если мы конкатенируем больше переменных, то будет больше заполнителей, но это все равно будет одна строка в пуле констант.
Замечательность подхода invokedynamic заключается в том, что при появлении новых версий 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 // Method 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 instances я вижу следующие значения:
Java новейшие шаблоны строк
Новая функция шаблона String хороша, но не потому, что она эффективно использует память. На самом деле, в базовых случаях она ведет себя точно так же, как и конкатенация строк. Она использует инструкцию invokedynamic и передает рецепт методу bootstrap, позволяя ему творить свою магию.
Шаблоны String удивительны тем, что они могут переопределить способ обработки шаблонов и позволяют нам создавать другие типы, помимо String (если мы этого хотим). Я с удовольствием прочитал эту статью, в которой рассказывается об этом подробнее.
инвокированный динамический подход
Мы выяснили, что invokedynamic используется для большинства современных методов конкатенации/интерполяции строк в Java.
Идеально ли это? С точки зрения избыточного выделения String – нет.
Мы видели, что передаем рецепт (шаблон с заполнителями) в виде одной String. Теперь, если значения, которые нужно вставить в плейсхолдеры, берутся из пула констант (код плейсхолдера \u0002), то никаких дополнительных строк выделяться не будет.
С другой стороны, если мы используем обычные переменные, то код placeholder будет \u0001. В этом случае во время выполнения метод bootstrap создает отдельный экземпляр String для каждого фрагмента между placeholder’ами, и эти Strings объединяются с параметрами для построения конечной String.
Чтобы убедиться в этом, рассмотрим такой небольшой пример:
int firstValue = 12345;
int secondValue = 987654321;
int thirdValue = 117117;
// alright, we can use the fancy string templates:
String test = STR."test \{firstValue} and \{secondValue} and \{thirdValue}";
// but this line would result in exactly identical bytecode:
// String test = "test " + firstValue + " and " + secondValue + " and " + thirdValue;
В байткоде мы видим invokedynamic с единственной строкой String, содержащей рецепт:
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();
Будет выделено только одно значение ” и “, даже если мы введем его дважды. Будет три экземпляра String: два фрагмента и результат.
А как насчет 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 является многословность.
Если мы не слишком озабочены памятью и скоростью, но хотим использовать мощную помощь в форматировании и читабельности, то шаблоны String будут отличным выбором. Помните, что в новых версиях они могут стать более эффективными и представляют собой нечто большее, чем просто механизм интерполяции строк.