String.format() в 3 раза быстрее в Java 17

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/592585/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *