Аннотация: Одним из самых удобных способов построения сложных строк является String.format()
. Раньше он был чрезмерно медленным, но в Java 17 стал примерно в 3 раза быстрее. В данном выпуске мы выясним, в чем разница и где это вам поможет. А также когда следует использовать format()
вместо обычного сложения строк с помощью +
.
Несколько лет назад мы с моим другом Дмитрием Вязеленко представили доклад на JavaOne, где около часа рассказывали о скромном java.lang.String
. С тех пор мы рассказывали об этом основном классе на Devoxx, Geecon, Geekout, JAX, Voxxed Days, GOTO и различных JUG по всему миру. Кто бы мог подумать, что можно легко заполнить час разговором о java.lang.String
?
Обычно я начинал выступление с викторины. Какой метод является самым быстрым при добавлении строк?
public class StringAppendingQuiz {
public String appendPlain(String question,
String answer1,
String answer2) {
return "<h1>" + question + "</h1><ol><li>" + answer1 +
"</li><li>" + answer2 + "</li></ol>";
}
public String appendStringBuilder(String question,
String answer1,
String answer2) {
return new StringBuilder().append("<h1>").append(question)
.append("</h1><ol><li>").append(answer1)
.append("</li><li>").append(answer2)
.append("</li></ol>").toString();
}
public String appendStringBuilderSize(String question,
String answer1,
String answer2) {
int len = 36 + question.length() + answer1.length() +
answer2.length();
return new StringBuilder(len).append("<h1>").append(question)
.append("</h1><ol><li>").append(answer1)
.append("</li><li>").append(answer2)
.append("</li></ol>").toString();
}
}
Аудитории предлагается выбрать один из трех вариантов, appendPlain
, appendStringBuilder
и appendStringBuilderSize
. Большинство разрывается между простой (plain) и увеличенной (sized) версией. Но это вопрос с подвохом. Для обычного случая, как сложение простых строк вместе, производительность эквивалентна, независимо от того, используем ли мы обычный + или StringBuilder
, с предварительно заданным размером или без него. Однако все меняется, когда мы добавляем смешанные типы, например, некоторые long значения и строки. В этом случае StringBuilder
с предварительным размером является самым быстрым до Java 8, а начиная с Java 9 и далее, самым быстрым является обычный +
.
Для сравнения, мы показали, что использование String.format
во много раз медленнее. Например, в Java 8 правильно подобранный StringBuilder
с append
(добавлением) выполнялся в 17 раз быстрее, чем аналогичный String.format()
, в то время как в Java 11 обычный + был в 39 раз быстрее format()
. Несмотря на такие огромные различия, наша рекомендация в конце выступления была следующей:
Конкатенация с помощью String.format()
Проще для чтения и поддержки.
Для критической производительности пока используйте +
В циклах по-прежнему используйте
StringBuilder.append()
.
В некотором смысле это была трудная идея. Зачем программисту сознательно делать то, что в 40 раз медленнее?
Нюанс в том, что инженеры Oracle знали, что String.format()
медленный и работали над его улучшением. Мы даже нашли версию Project Amber, которая компилировала код format()
с той же скоростью, что и простой оператор +
.
После выхода Java 17 я решил заново прогнать все наши предварительные бенчмарки. Сначала мне казалось, что это пустая трата времени. В конце концов, эталоны уже были сделаны. Зачем запускать их снова? Во-первых, машина, которую мы использовали изначально, уже была выведена из эксплуатации, а мне хотелось увидеть последовательные результаты на протяжении всего исследования, запустив все на своей машине для тестирования производительности. С другой стороны, я хотел посмотреть, были ли какие-либо изменения в JVM, которые могли бы повлиять на результаты. Я не предполагал, что последнее станет важным фактором.
Представьте себе мое удивление, когда я заметил, что функция String.format()
радикально улучшилась. Вместо 2170 нс/оп в Java 11, теперь она стала выполняться "всего" за 705 нс/оп. Таким образом, вместо того, чтобы быть примерно в 40 раз медленнее, чем обычный +
, String.format()
оказался всего в 12 раз медленнее. Или, если посмотреть с другой точки зрения, Java 17 String.format()
в 3 раза быстрее, чем в Java 16.
Замечательная новость, но при каких обстоятельствах это будет быстрее? Я поделился своим открытием с Дмитрием Вязеленко, и он указал мне на работу Claes Redestad в JDK-8263038 : Оптимизация String.format для простых спецификаторов. Актуальный код доступен в GitHub OpenJDK.
Claes был достаточно любезен, ответив на мой запрос, и подтвердил, что мы можем ожидать более быстрого форматирования для простых спецификаторов. Другими словами, спецификаторы - это знак процента %
, за которым следует всего одна буква в диапазоне "bBcCtTfdgGhHaAxXno%eEsS
". Если добавляется дополнительное форматирование, например, ширина, точность или выравнивание, тогда, вероятно, это уже не будет быстрее.
Как работает данная чудесная функция? Каждый раз, когда мы вызываем, например, String.format("%s, %d%n", name, age)
, необходимо сделать парсинг строки "%s, %d%n
". Это делается в методе java.util.Formatter#parse()
, который использует для парсинга элементов форматирования приведённое ниже регулярное выражение (regex):
// %[argument_index$][flags][width][.precision][t]conversion
private static final String formatSpecifier
= "%(\\d+\\$)?([-#+ 0,(\\<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])";
private static final Pattern fsPattern = Pattern.compile(formatSpecifier);
В коде до версии 17 функция parse()
всегда начиналась с применения регекса к строке формата (format String). Однако в Java 17 вместо этого мы пытаемся выполнить парсинг строки формата вручную. Если все FormatSpecifiers "простые", то можно обойтись без повторного парсинга. Когда один из них не простой, то парсинг выполняется с этого момента. Это ускоряет парсинг в 3 раза для простых строк формата. Вот тестовая программа, в которой я выполняю парсинг следующих строк:
// should be faster
"1. this does not have any percentages at all"
// should be faster
"2. this %s has only a simple field"
// might be slower
"3. this has a simple field %s and then a complex %-20s"
// no idea
"4. %s %1s %2s %3s %4s %5s %10s %22s"
Мы передаем эти строки приватному методу Formatter#parse с помощью MethodHandles и измеряем, сколько времени это занимает в Java 16 и 17.
С Java 16 мы получили следующие результаты на нашем тестовом сервере:
Best results:
1. this does not have any percentages at all
137ms
2. this %s has only a simple field
288ms
3. this has a simple field %s and then a complex %-20s
487ms
4. %s %1s %2s %3s %4s %5s %10s %22s
1557ms
Результаты, полученные с Java 17:
Best results:
1. this does not have any percentages at all
21ms // 6.5x faster
2. this %s has only a simple field
32ms // 9x faster
3. this has a simple field %s and then a complex %-20s
235ms // 2x faster
4. %s %1s %2s %3s %4s %5s %10s %22s
1388ms // 1.12x faster
Таким образом, можно рассчитывать на существенную разницу при работе со строками формата, имеющими простые поля, что составляет подавляющее большинство случаев. Спасибо Claes Redestad за усилия, приложенные к тому, чтобы сделать это быстрее. Я буду придерживаться своего совета использовать String.format()
, или, еще лучше, относительно новый метод formatted()
, и пусть разработчики JDK ускорят его для нас.
Вот тестовый код, на случай, если вы захотите попробовать сами. Мы используем следующие параметры JVM: -showversion --add-opens java.base/java.util=ALL-UNNAMED -Xmx12g -Xms12g -XX:+UseParallelGC -XX:+AlwaysPreTouch-verbose:gc
import java.lang.invoke.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
// run with
// -showversion --add-opens java.base/java.util=ALL-UNNAMED
// -Xmx12g -Xms12g -XX:+UseParallelGC -XX:+AlwaysPreTouch
// -verbose:gc
public class MixedAppendParsePerformanceDemo {
private static final Map<String, LongAccumulator> bestResults =
new ConcurrentSkipListMap<>();
public static void main(String... args) {
String[] formats = {
// should be faster
"1. this does not have any percentages at all",
// should be faster
"2. this %s has only a simple field",
// might be slower
"3. this has a simple field %s and then a complex %-20s",
// no idea
"4. %s %1s %2s %3s %4s %5s %10s %22s",
};
System.out.println("Warmup:");
run(formats, 5);
System.out.println();
bestResults.clear();
System.out.println("Run:");
run(formats, 10);
System.out.println();
System.out.println("Best results:");
bestResults.forEach((format, best) ->
System.out.printf("%s%n\t%dms%n", format,
best.longValue()));
}
private static void run(String[] formats, int runs) {
for (int i = 0; i < runs; i++) {
for (String format : formats) {
Formatter formatter = new Formatter();
test(formatter, format);
}
System.gc();
System.out.println();
}
}
private static void test(Formatter formatter, String format) {
System.out.println(format);
long time = System.nanoTime();
try {
for (int i = 0; i < 1_000_000; i++) {
parseMH.invoke(formatter, format);
}
} catch (Throwable throwable) {
throw new AssertionError(throwable);
} finally {
time = System.nanoTime() - time;
bestResults.computeIfAbsent(format, key ->
new LongAccumulator(Long::min, Long.MAX_VALUE))
.accumulate(time / 1_000_000);
System.out.printf("\t%dms%n", (time / 1_000_000));
}
}
private static final MethodHandle parseMH;
static {
try {
parseMH = MethodHandles.privateLookupIn(Formatter.class,
MethodHandles.lookup())
.findVirtual(Formatter.class, "parse",
MethodType.methodType(List.class, String.class));
} catch (ReflectiveOperationException e) {
throw new Error(e);
}
}
}
Также существуют и другие хорошие способы повышения производительности в Java 17.
Материал подготовлен в рамках курса «Java Developer. Professional». Если вам интересно узнать подробнее о формате обучения и программе, познакомиться с преподавателем курса — приглашаем на день открытых дверей онлайн. Регистрация здесь.
Комментарии (5)
Graf54r
30.11.2021 19:37Меня больше смущает, почему они не сделали """ так же как в котлине. И стринг.формат не был бы особо нужен. Хотя оптимизация то хорошая.
Filex
А в каком случае стоит использовать MessageFormat.format() ?
cartonworld
https://www.it-swarm.com.ru/ru/java/raznica-mezhdu-messageformat.format-i-string.format-v-jdk1.5/970085837/