for (String s : data) {
    result += s;
}

Вы наверняка видели такой код сотни раз. Что с ним не так? Ведь он выглядит безобидно, почти идиоматично. Но в продакшене под нагрузкой этот цикл способен генерировать сотни мегабайт мусора в секунду - даже если сам результат никому не нужен.

И казалось бы, проблема конкатенации строк в Java давно решена. Джунам говорят: используй StringBuilder и будет тебе щастье. А статьи десятилетней давности сравнивают + и append() в бенчмарках и ставят точку.

В сегодняшней статье я копнул немного глубже и оказалось, что реальность сложнее.

Вред не исчез - он принял новые, менее очевидные формы.


Классика жива, но ее границы изменились

Начнем с базы, без которой не понять остальное. Есть те, кто до сих пор пишут так:

String result = "";
for (String item : data) {
    result += item;
}

Компилятор честно разворачивает это в нечто похожее на:

String result = "";
for (String item : data) {
    StringBuilder sb = new StringBuilder();
    sb.append(result);
    sb.append(item);
    result = sb.toString();
}

Проблема этого кода в том, что каждая итерация создаёт новый StringBuilder, копирует в него всё накопленное содержимое и возвращает новую строку. Количество операций копирования растёт квадратично относительно числа элементов: на 1000 итераций мы копируем не 1000 символов, а порядка 500 000. Разница между линейным и квадратичным ростом становится заметной очень быстро.

Но даже явный StringBuilder не всегда спасает. Сделаем так:

StringBuilder sb = new StringBuilder();
for (String item : data) {
    sb.append(item);
}
String result = sb.toString();

И казалось бы, проблема решена.

Однако new StringBuilder() создает внутренний массив символов размером 16. Когда место заканчивается, массив пересоздается с удвоенным размером, и все накопленные байты копируются заново. Эти resize-ы происходят в цикле молча и по сути воспроизводят ту же проблему, но на уровень ниже - на уровне буфера.

Можно добавить new StringBuilder(estimatedSize), с явно указанным размером массива и это уберет лишние копирования.

Но важно другое: даже самый правильно нстроенный StringBuilder остаётся StringBuilder. В байткоде всё равно будет new StringBuilderappendtoString - жёсткая цепочка, которую JVM обязана исполнить.

Так и было раньше, но с Java 9 это правило перестало быть универсальным.

JEP 280: смена модели, а не просто оптимизация

Как мы рассмотрели выше, была простая логика: оператор + - это синтаксический сахар, который компилятор молча разворачивает в цепочку вызовов StringBuilder.

Хочешь производительности - пиши StringBuilder руками. И никакой интриги.

Этот взгляд полностью соответствовал реальности Java 8:

// class version 52.0 (52) — Java 8
public static concat(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
  NEW java/lang/StringBuilder
  DUP
  INVOKESPECIAL java/lang/StringBuilder.<init> ()V
  ALOAD 0
  INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
  ALOAD 1
  INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
  ALOAD 2
  INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
  INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
  ARETURN

Стратегия склейки жёстко зафиксирована в байткоде.

Видно new StringBuilder, три appendtoString. JVM может пытаться оптимизировать это через escape analysis, но сама структура не оставляет пространства для манёвра.

С Java 9 (JEP 280) всё изменилось. Вот байткод того же метода на JDK 21:

// class version 65.0 (65) — Java 21
public static concat(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
  ALOAD 0
  ALOAD 1
  ALOAD 2
  INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; [
    java/lang/invoke/StringConcatFactory.makeConcatWithConstants(...)
    // arguments: "\u0001\u0001\u0001"
  ]
  ARETURN

Вместо жёсткой цепочки вызовов - инструкция invokedynamic, которая говорит JVM: склей три строки любым способом, который ты сочтёшь лучшим в этот момент. Решение принимает не компилятор, а сама виртуальная машина во время исполнения.

invokedynamic(JSR 292) - это инструкция байт-кода, появившаяся в Java 7. В отличие от обычных вызовов (invokevirtualinvokestatic), она не жёстко привязана к конкретному методу на этапе компиляции. Вместо этого JVM при первом исполнении вызывает специальный bootstrap-метод, который выбирает, что именно вызывать, и только потом связывает вызов.

Как JVM выбирает стратегию

Изначально, с выходом Java 9, существовало шесть стратегий. Они разделялись на генерирующие байт-код в духе StringBuilder (BC_SBBC_SB_SIZEDBC_SB_SIZED_EXACT) и использующие более гибкий механизм MethodHandle (MH_SB_SIZEDMH_SB_SIZED_EXACTMH_INLINE_SIZED_EXACT).

Однако начиная с Java 15 все альтернативы перестали использовать по умолчанию: наиболее эффективной и простой в поддержке оказалась MH_INLINE_SIZED_EXACT. Она полностью отказывается от промежуточного StringBuilder и собирает итоговую строку в заранее выделенном байтовом массиве, сводя аллокации и копирования к минимуму.

Аллокация (allocation) - это процесс выделения памяти в куче (heap) под новый объект. В Java аллокация дёшева, но не бесплатна: каждый созданный объект занимает место, а когда он становится не нужен, сборщик мусора тратит процессорное время на его удаление. Чем больше аллокаций в секунду, тем чаще просыпается GC и тем выше latency приложения.

Какую стратегию выбирает JVM на практике, можно увидеть, добавив флаг -Djava.lang.invoke.stringConcat.debug=true. В консоли появляется строка:

StringConcatFactory MH_INLINE_SIZED_EXACT is here for (String,String,String)String

Это означает, что JVM знает суммарную длину всех трёх аргументов, выделив ровно нужный массив байт и скопировав всё напрямую, без явного StringBuilder и с минимальным числом промежуточных аллокаций. Эта стратегия работает, когда количество частей известно на этапе компиляции, а выражение не размазано по нескольким операциям.

Важно: выбор стратегии происходит только тогда, когда компилятор генерирует invokedynamic - то есть когда вы используете оператор +.

Если вы напишете new StringBuilder().append(...) вручную, JVM будет работать с ним как с обычным вызовом методов и не сможет применить те же оптимизации уровня StringConcatFactory, потому что никакого invokedynamic там нет.

Разница скрыта не в том, что быстрее, а в том, что + даёт JVM свободу выбора, а ручной StringBuilder — нет.

Что показали замеры

Из профессионального интереса, при подготовке статьи, я проверил три сценария. Во всех сценариях с JDK 21StringBuilder оказался быстрее.

Сценарий 1: горячий цикл с накоплением:

for (int i = 0; i < 100_000; i++) {
    String s = a + b + c;
    //против new StringBuilder().append(a).append(b).append(c).toString()
}
Plus:    11.693 ms
Builder: 4.978 ms

Сценарий 2: многократные одиночные вызовы с возвратом строки:

static String builder() {
  for (int i = 0; i < 100_000; i++) {
    String s = a + b + c;   
    //против new StringBuilder().append(a).append(b).append(c).toString()
    return s; //результат утекает из выражения
  }
}
Plus:    7.682 ms
Builder: 4.678 ms

Сценарий 3: результат используется только локально, строка не утекает:

int len = (a + b + c).length();//казалось бы, объект не нужен
// против
int len = new StringBuilder().append(a).append(b).append(c).toString().length();
Plus:    155.659 ms  (10 млн итераций)
Builder: 99.952 ms

Важно: и не смотря, что результаты оказались вполне ожидаемы (ведь это в основном случаи с утеканием результата или циклическим использованием, где StringBuilder закономерно выигрывает), целью моих замеров был не поиск победителя.

Замеры показали, что сравнение +и StringBuilderбольше не даёт универсального ответа. Раньше + был хуже всегда, а сегодня его поведение зависит условий выполнения. Стоимость конкатенации сегодня определяется не оператором, а контекстом выполнения: циклами, есть ли утекание, логированием, стримами.

В целом, это больше не универсальное правило - это результат конкретной JVM и кода.

Escape Analysis: почему на магию JIT нельзя полагаться

Все, что мы раньше разобрали выглядело просто: конкатенация создаёт объекты, объекты нагружают GC.

Но есть нюанс: в ряде случаев JVM может вообще не создавать эти объекты.

JIT-компилятор способен выбрасывать целые объекты, если докажет, что они не покидают пределов метода. Эта оптимизация называется Escape Analysis (анализ утекания), а её результат - Scalar Replacement (объект заменяется на отдельные примитивные поля, живущие в регистрах или на стеке). Иногда говорят allocation elimination - устранение аллокаций.

Когда аллокации исчезают, а когда нет

Рассмотрим два почти одинаковых метода:

//метод 1 результат утекает вызывающему коду
public static String concatAndReturn(String a, String b, String c) {
    return a + b + c;
}

// метод 2 результат используется только внутри метода
public static int concatAndGetLength(String a, String b, String c) {
    String s = a + b + c;
    return s.length();   //сам объект String наружу не отдаётся
}

В первом случае строка должна стать доступной за пределами метода - она утекает наружу. JIT не может устранить аллокацию.

Во втором случае строка нужна лишь для того, чтобы взять её длину. JIT может вообще не создавать полноценный объект String, а сразу посчитать сумму длин трёх строк. И никакого мусора.

Что показал эксперимент

Продолжая любопытничать, я запустил оба метода в цикле с флагом -Xlog:gc=info, чтобы видеть каждую остановку сборщика мусора. Ожидая увидеть, что во втором случае будет отсутствие аллокаций и минимум GC, а в первом - регулярные сборки, оказался не правым.

Реальность оказалась другой.

Результаты двух запусков на JDK 21 - с включённым и выключенным Escape Analysis:

С включённым Escape Analysis (ключ -Xlog:gc=info):

[0.086s][info][gc] GC(0) Pause Full (System.gc()) 22M->1M(40M) 2.833ms
[0.205s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 13M->1M(40M) 0.616ms
[0.211s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 17M->1M(40M) 0.511ms
[0.216s][info][gc] GC(3) Pause Full (System.gc()) 7M->1M(16M) 2.405ms
[0.327s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 9M->1M(16M) 0.256ms
[0.330s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 9M->1M(16M) 0.229ms
[0.333s][info][gc] GC(6) Pause Young (Normal) (G1 Evacuation Pause) 9M->1M(16M) 0.259ms
[0.338s][info][gc] GC(7) Pause Young (Normal) (G1 Evacuation Pause) 9M->1M(264M) 0.780ms
concatAndReturn: 19 ms
concatAndGetLength: 18 ms

С выключенным Escape Analysis (ключ -XX:-DoEscapeAnalysis):

[0.082s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->1M(512M) 1.654ms
[0.085s][info][gc] GC(1) Pause Full (System.gc()) 4M->1M(16M) 2.444ms
[0.199s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 5M->1M(16M) 0.543ms
[0.207s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 5M->1M(16M) 0.403ms
[0.210s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 5M->1M(16M) 0.281ms
[0.211s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 5M->1M(16M) 0.175ms
[0.212s][info][gc] GC(6) Pause Young (Normal) (G1 Evacuation Pause) 5M->1M(264M) 0.669ms
[0.228s][info][gc] GC(7) Pause Full (System.gc()) 35M->1M(28M) 2.213ms
[0.354s][info][gc] GC(8) Pause Young (Normal) (G1 Evacuation Pause) 17M->1M(28M) 0.581ms
[0.358s][info][gc] GC(9) Pause Young (Normal) (G1 Evacuation Pause) 17M->1M(28M) 0.198ms
[0.360s][info][gc] GC(10) Pause Young (Normal) (G1 Evacuation Pause) 13M->1M(28M) 0.186ms
concatAndReturn: 29 ms
concatAndGetLength: 25 ms

Разница в количестве GC-пауз и времени выполнения минимальна.

Но это как раз тот результат, который лучше всего иллюстрирует поведение JVM - даже в контролируемом синтетическом примере, где один сценарий явно утекает наружу, а другой нет, JIT не обязан применять escape analysis.

Он может заинлайнить методы, оптимизировать короткоживущие объекты в young generation или принять решение, что выгода от scalar replacement незначительна. В итоге оба варианта оказываются близки по поведению, и это ломает ожидания, что JIT автоматически уберёт лишние аллокации.

Почему эта магия почти никогда не работает в реальном коде

Escape analysis - это эвристическая оптимизация, а не гарантия. Она работает только в очень узком коридоре условий, и JIT сам решает, применять её или нет. Как только строка покидает метод - передаётся в логер, возвращается наружу, сохраняется в поле, то JIT теряет возможность её применить. Именно поэтому большинство реального кода в этот коридор не попадает.

Конкретные ситуации, которые ломают escape analysis:

  • Возврат строки из метода - ссылка утекает вызывающему коду.

  • Передача в любой метод за пределами текущегоlogger.info(result)map.put(key, result)response.setBody(result) - строка уходит в чужой код, и JIT не может отследить её судьбу.

  • Сохранение в поле объекта или статическую переменную - ссылка покидает метод.

  • Накопление в цикле: если результат конкатенации используется как аккумулятор на следующей итерации, объект живёт между итерациями и не может быть устранён.

  • Сложный поток управления: исключения, синхронизация, виртуальные вызовы могут заставить JIT отказаться от scalar replacement.

Промежуточный вывод

И здесь мы подошли к пониманию, что фактически, теперь конкатенация больше не имеет фиксированной стоимости.

Раньше + всегда означал StringBuilder, а значит - аллокации и копирования. Сегодня JVM может устранить аллокации через escape analysis, собрать строку напрямую в массив через JEP 280 или оставить всё как есть. Но все эти оптимизации работают только в узком, хорошо определённом контексте.

Если результат уходит наружу, передаётся в другой метод или используется как аккумулятор - аллокации становятся неизбежными. В реальном коде это происходит повсеместно: логирование, возврат значения, сохранение в коллекцию.

Поэтому вопрос что быстрее, + или StringBuilder больше не даёт универсального ответа. Правильный вопрос: позволяет ли этот код JVM применить оптимизации? И ответ почти всегда отрицательный, если вы вышли за пределы локального выражения.

Можно ли управлять этим поведением напрямую? Нет.

JIT-компилятор принимает решения на основе собственных эвристик: профиля выполнения, инлайнинга, анализа утекания и десятков других факторов. Эти решения не специфицированы и меняются от версии JVM, флагов и даже формы байткода.

Но полной случайности здесь нет. Границы применимости оптимизаций известны и стабильны. Мы не управляем JIT, но можем понимать, когда он способен помочь, а когда — нет. Именно это понимание отделяет код, который внезапно генерирует гигабайты мусора под нагрузкой, от кода, работающего предсказуемо.

Антипаттерны: где прячется вред сегодня

Мы поняли, что JVM умеет оптимизировать конкатенацию, но только в очень узких условиях. Теперь перейдём к реальному коду - тому, что пишут каждый день в продакшене.

Stream API: квадратичный рост в декларативной обёртке

Стримы в Java - удобный инструмент. Но с конкатенацией строк они сочетаются опасно. Вот типичный код, который я встречал в нескольких проектах:

String result = items.stream().reduce("", (acc, item) -> acc + item);

Выглядит как декларативная агрегация - чисто, функционально, по всем правилам современной Java. Но по факту это классический аккумулятор с повторным копированием строки на каждой итерации. Каждый шаг редукции создаёт новую строку, копируя всё, что было накоплено до этого. На 10 000 элементов - около 50 миллионов операций копирования символов и 10 000 временных строк.

Главная проблема в том, что API выглядит декларативно, а поведение - императивное и дорогое. Разработчик думает: я описываю результат, а JVM выполняет команду: копируй заново на каждом шагу.

Правильное решение - Collectors.joining(), который внутри держит один StringBuilder и собирает всё за линейное время без промежуточных аллокаций:

String result = items.stream().collect(Collectors.joining());

Цифры из реального проекта: в сервисе обработки событий замена reduce на joining снизила allocation rate с ~800 МБ/с до ~40 МБ/с и полностью убрала периодические minor GC-паузы, которые пользователи ощущали как кратковременные подвисания.

Вот еще разок строка, разбросанная по тысячам Java-проектов:

logger.debug("Processing user " + user.getId() + " at " + Instant.now());

Проблема здесь не только в JVM и оптимизациях.

В Java аргументы метода вычисляются до вызова метода. Это значит, что конкатенация происходит всегда, независимо от того, включён DEBUG или нет. Вы создаёте строку, вызываете getId(), вычисляете Instant.now(), склеиваете - и только потом логер решает: DEBUG выключен, строку игнорирую.

В одном конкретном вызове это не страшно. Но когда такая конкатенация стоит в цикле, обрабатывающем миллионы событий, счёт идёт на гигабайты мусора в минуту:

for (Event event : events) {
    logger.debug("Processing event " + event.getId() + " at " + Instant.now());
}

Даже при выключенном DEBUG создаются строки, вызываются getId() и toString() у всех объектов, вычисляются временные метки. Всё ради результата, который никто никогда не увидит.

Правильное решение сделать параметризованные сообщения, где конкатенация происходит только если логер действительно будет писать сообщение:

logger.debug("Processing event {} at {}", event.getId(), Instant.now());

Цифры из жизни: В одном high-load API замена безусловной конкатенации в логах на параметризованные плейсхолдеры сократила генерацию мусора примерно на 1.2 ГБ в минуту на пике нагрузки. Бизнес-логика не менялась - только способ передачи аргументов в логер.

String.format() и formatted() - не просто парсинг

String.format() любят за аккуратный синтаксис. Но под капотом скрыто больше, чем кажется:

String message = String.format("User %s logged in from %s", username, ip);

Основная стоимость - не только разбор строки формата на каждом вызове, но и создание Formatter, обработка varargs, boxing примитивов, работа с локалями. Эти накладные расходы повторяются при каждом вызове, даже если шаблон не меняется годами.

С появлением Java 15 добавился удобный метод String.formatted(), который особенно полюбили в связке с Text Blocks:

String json = """
    {
        "user": "%s",
        "ip": "%s"
    }
    """.formatted(username, ip);

Выглядит очень лаконично. Но важно понимать: formatted() - это синтаксический сахар над String.format(this, args). Внутри происходит ровно та же работа: парсинг шаблона, создание Formatter, varargs и boxing. Никакой магии или предварительной компиляции - платим ту же цену, что и при вызове String.format(), только с более приятным синтаксисом.

Для однократного вызова всё это незаметно. Но в горячем коде разница с обычной конкатенацией становится ощутимой:

//100 000 вызовов:
String.format / formatted:  45.2 ms
a + b + c:                  7.6 ms

Для сложных шаблонов, которые используются многократно, есть смысл предкомпилировать формат через MessageFormat:

private static final MessageFormat fmt = new MessageFormat("{0} connected from {1}");

Ручная сборка SQL, JSON, HTML

Отдельная категория - когда строки собирают вручную для структурированных данных:

String query = "SELECT * FROM users WHERE name='" + userName + "' AND active=" + active;
String json  = "{\"user\":\"" + userName + "\", \"ip\":\"" + ip + "\"}";
String html  = "<div class='" + cssClass + "'>" + userContent + "</div>";

Здесь конкатенация перестаёт быть просто вопросом производительности. Она становится частью архитектуры и начинает влиять на безопасность и корректность данных.

С точки зрения производительности - каждая склейка создаёт временные строки. Для формирования JSON-а из сотни полей это ощутимо нагружает GC. Но ещё важнее, что ручная сборка SQL открывает дорогу инъекциям, а ручная сборка HTML - XSS-атакам.

Правильные альтернативы давно существуют:

//SQL — PreparedStatement
PreparedStatement ps = connection.prepareStatement(
    "SELECT * FROM users WHERE name = ? AND active = ?"
);

//JSON/HTML/SQL — Text Blocks (Java 15+), ноль рантайм-склеек
String json = """
    {
        "user": "%s",
        "ip": "%s"
    }
    """.formatted(username, ip);

Бенчмарки: что я замерил и чему можно верить

До этого момента я приводил цифры, полученные на конкретной машине. Теперь соберу их вместе, и добавлю контекста.

Методология

Все тесты мной запускались на JDK 21 (Corretto) с флагом -Xlog:gc=info для отслеживания аллокаций.

Перед каждым замером JVM прогревалась (методы вызывались вхолостую сотни тысяч раз), чтобы JIT успел применить оптимизации. Для предотвращения dead code elimination в горячих циклах использовался барьер вроде проверки длины результата. Никакого JMH - только честный System.nanoTime() и многократные прогоны.

Поэтому относитесь к цифрам как к оценке порядка, а не как к абсолютной истине.

Сводная таблица

Сценарий

Победитель

Разница

Причина

+= в цикле (накопление)

StringBuilder

катастрофическая

O(n²) копирований, лавина аллокаций

StringBuilder без capacity

StringBuilder с capacity

до 30%

resize буфера

Одиночные вызовы (возврат строки)

StringBuilder

в 1.5-2 раза

JIT инлайнит StringBuilderinvokedynamic даёт оверхед в цикле

Локальная конкатенация без утекания

результат зависит от JIT

от ~0% до ~50%

escape analysis может сработать, но не гарантирован

Stream.reduce vs joining

Collectors.joining()

в десятки-сотни раз

reduce - скрытый O(n²), joining - один StringBuilder

Логирование (конкатенация)

Параметризованные сообщения

бесконечность (при выключенном DEBUG)

конкатенация происходит всегда, параметры -- только при включённом уровне

String.format() vs +

+

в 5-6 раз

парсинг формата, Formatter, boxing

String.format() vs MessageFormat (кэшированный)

MessageFormat

в 3-4 раза

однократный разбор шаблона

Что означают эти цифры

Самые неожиданные потери производительности не там, где я выбирал между + и StringBuilder.

Они проявились, в симуляции ситуации где разработчик не осознаёт, что делает: reduce вместо joining, конкатенация в логах, String.format() в горячем цикле. Ошибки архитектуры, а не синтаксиса.

В то же время, разница между + и StringBuilder в локальных выражениях оказалась стабильной, но не драматичной. И это важно: в моих замерах StringBuilder оказался быстрее во всех сценариях.

По мне, так этого достаточно, чтобы перестать воспринимать + как безусловное зло.

Важное предостережение

Цифры из этого раздела нельзя копировать в свои расчёты. Результаты бенчмарков зависят от версии JDK, флагов JVM, железа, настроек GC и даже фазы луны.

То, что на моей машине StringBuilder выиграл во всех трёх сценариях, не означает, что на вашей всё будет так же. Но тенденции - O(n²) у reduce, бесплатная конкатенация в логах при выключенном DEBUG, дороговизна String.format() останутся неизменными.

Эволюция инструментов

До сих пор я говорил о том, как писать код правильно в мире, где конкатенация может быть опасной.

Но и Java не стоит на месте - многие из описанных проблем разработчики JDK увидели и предложили инструменты, которые устраняют саму необходимость в ручных склейках.

Text Blocks (Java 15)

До Java 15 многострочные строки были болью. Экранирование, \n, конкатенация - всё это было не только неудобно, но и порождало кучу временных объектов:

String json = "{\n"
    + "    \"user\": \"" + userName + "\",\n"
    + "    \"ip\": \"" + ip + "\"\n"
    + "}";

Каждый + создавал промежуточную строку, а читать и поддерживать такой код было тяжело.

Text Blocks решили эту проблему радикально. Многострочный литерал компилируется в константу - одну, неделимую, без единой рантайм-склейки:

String json = """
    {
        "user": "%s",
        "ip": "%s"
    }
    """.formatted(userName, ip);

Сам Text Block - это одна строковая константа, которая попадает в пул на этапе компиляции.

String Templates (Preview в 21 и 24, ожидается финал)

String message = STR."User \{userName} logged in from \{ip}";

Компилятор видит шаблон и разбирает его один раз. Переменные подставляются напрямую, без промежуточных StringBuilder-ов (в рантайме используется invokedynamic и StringConcatFactory).

Это даёт производительность на уровне ручной конкатенации, но с читаемостью на уровне String.format().

История String Templates интересна сама по себе: они были в preview в Java 21 и 22, затем исключены из Java 23 из-за переработки дизайна, возвращены в переработанном виде в Java 24. Финализация ожидается в ближайших LTS-релизах.

Важное уточнение: String Templates сами по себе не защищают от SQL-инъекций. Они предоставляют механизм для безопасной интерполяции через кастомные процессоры (STRFMT, собственные реализации). Но можно написать и опасный код. Это инструмент, а не магия безопасности.

Шпаргалка: что и когда использовать

Ситуация

Инструмент

Почему

1–2 склейки вне цикла

+ или concat()

JVM через invokedynamic знает размер, аллокации минимальны

Цикл / много элементов

StringBuilder с capacity

Один объект, без resize буфера, линейное время

Потоковая обработка

Collectors.joining()

Один StringBuilder внутри, никакого скрытого O(n²)

Логирование

Параметризованные сообщения ({})

Конкатенация только при включённом уровне

Сложный шаблон многократно

MessageFormat (предкомпилированный)

Парсинг шаблона один раз

Многострочные литералы

Text Blocks (Java 15+)

Константа на этапе компиляции, ноль склеек

Формирование SQL / JSON / HTML

Text Blocks, PreparedStatement, сериализаторы

Безопасность, читаемость, отсутствие ручных склеек

String Templates (после финализации)

STR. вместо String.format()

Шаблон разбирается один раз, производительность на уровне +

Главное правило: если код не находится в горячем цикле - читаемость важнее микропроизводительности. StringBuilder с capacity и Collectors.joining() нужны там, где аллокации умножаются на тысячи итераций. Всё остальное - вопрос контекста, а не догмы.

Ключевой вывод

Раньше выбор между + и StringBuilder определял производительность. Сегодня производительность определяется тем, позволяет ли код JVM применить оптимизации.

И в значительной части реального кода - не позволяет.

Поэтому выбор между + и StringBuilder теперь должен происходить более осознанно, с пониманием всех глубинных процессов.


Материал подготовлен автором telegram-канала о изучении Java.

Комментарии (5)


  1. TimurZhoraev
    04.05.2026 15:34

    Шпаргалка - это прямой путь захардкодить её в виде @ArchTest, public static Collector<CharSequence, ?, String> , @Builder. Скорее нужно иметь некий внешний инструмент которые эти кейсы умеет парсить и идентифицировать bottleneck. То есть не перекладывать проблемы компилятора и анализа проекта на ручной код. Фактически в таблице имеются скрытые численные параметры позволяющие точно идентифицировать что использовать при статике и в динамике, определяя количество строк, их размер и количество конкатенаций


  1. Xbolt
    04.05.2026 15:34

    Уже java 26, а тут только 24. Хочется большего


    1. Snaret Автор
      04.05.2026 15:34

      А дальше по это теме пока не особо) StringTemplates и все)


  1. matrixnorm
    04.05.2026 15:34

    быстрее всего работает StringConcatFactoryBuilderManagerProxyServiceExecutorSingletonAdaptor


    1. Snaret Автор
      04.05.2026 15:34

      это если его запустить через EscapeAnalysisInvokeDynamicStringTemplatesGarbageCollector