
Аннотация: Одним из самых удобных способов построения сложных строк является 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». Если вам интересно узнать подробнее о формате обучения и программе, познакомиться с преподавателем курса — приглашаем на день открытых дверей онлайн. Регистрация здесь.
