Конкатенация строк в Java: почему советы 2008 года всё ещё работают — и почему этого уже недостаточно

от автора

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 8public 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 21public 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 msBuilder: 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 msBuilder: 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.780msconcatAndReturn: 19 msconcatAndGetLength: 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.186msconcatAndReturn: 29 msconcatAndGetLength: 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 msa + 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 — PreparedStatementPreparedStatement 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.

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