Аннотация: Одним из самых удобных способов построения сложных строк является 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)


  1. Filex
    30.11.2021 17:57

    А в каком случае стоит использовать MessageFormat.format() ?


    1. cartonworld
      06.12.2021 01:32
      +1

      Просто взгляните на javadoc обоих методов, чтобы узнать больше деталей.

      https://www.it-swarm.com.ru/ru/java/raznica-mezhdu-messageformat.format-i-string.format-v-jdk1.5/970085837/


  1. Graf54r
    30.11.2021 19:37

    Меня больше смущает, почему они не сделали """ так же как в котлине. И стринг.формат не был бы особо нужен. Хотя оптимизация то хорошая.


    1. Filex
      01.12.2021 10:00

      в 17 java же добавили текстовые блоки """. Или их поведение отличается от котлина?


      1. Graf54r
        04.12.2021 14:11
        +1

        Отличается — это обычный текст. В котлин можно написать так ""«Hello, ${user.name}»"", в итоге будет строка «Hello, Filex» а в java будет «Hello, ${user.name}».