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 StringBuilder, append, toString — жёсткая цепочка, которую 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, три append, toString. 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. В отличие от обычных вызовов (invokevirtual,invokestatic), она не жёстко привязана к конкретному методу на этапе компиляции. Вместо этого JVM при первом исполнении вызывает специальный bootstrap-метод, который выбирает, что именно вызывать, и только потом связывает вызов.
Как JVM выбирает стратегию
Изначально, с выходом Java 9, существовало шесть стратегий. Они разделялись на генерирующие байт-код в духе StringBuilder (BC_SB, BC_SB_SIZED, BC_SB_SIZED_EXACT) и использующие более гибкий механизм MethodHandle (MH_SB_SIZED, MH_SB_SIZED_EXACT, MH_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() и многократные прогоны.
Поэтому относитесь к цифрам как к оценке порядка, а не как к абсолютной истине.
Сводная таблица
|
Сценарий |
Победитель |
Разница |
Причина |
|---|---|---|---|
|
|
|
катастрофическая |
O(n²) копирований, лавина аллокаций |
|
|
|
до 30% |
resize буфера |
|
Одиночные вызовы (возврат строки) |
|
в 1.5-2 раза |
JIT инлайнит |
|
Локальная конкатенация без утекания |
результат зависит от JIT |
от ~0% до ~50% |
escape analysis может сработать, но не гарантирован |
|
Stream.reduce vs joining |
|
в десятки-сотни раз |
reduce — скрытый O(n²), joining — один |
|
Логирование (конкатенация) |
Параметризованные сообщения |
бесконечность (при выключенном DEBUG) |
конкатенация происходит всегда, параметры — только при включённом уровне |
|
|
|
в 5-6 раз |
парсинг формата, |
|
|
|
в 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-инъекций. Они предоставляют механизм для безопасной интерполяции через кастомные процессоры (STR, FMT, собственные реализации). Но можно написать и опасный код. Это инструмент, а не магия безопасности.
Шпаргалка: что и когда использовать
|
Ситуация |
Инструмент |
Почему |
|---|---|---|
|
1–2 склейки вне цикла |
|
JVM через invokedynamic знает размер, аллокации минимальны |
|
Цикл / много элементов |
|
Один объект, без resize буфера, линейное время |
|
Потоковая обработка |
|
Один |
|
Логирование |
Параметризованные сообщения ( |
Конкатенация только при включённом уровне |
|
Сложный шаблон многократно |
|
Парсинг шаблона один раз |
|
Многострочные литералы |
Text Blocks (Java 15+) |
Константа на этапе компиляции, ноль склеек |
|
Формирование SQL / JSON / HTML |
Text Blocks, PreparedStatement, сериализаторы |
Безопасность, читаемость, отсутствие ручных склеек |
|
String Templates (после финализации) |
|
Шаблон разбирается один раз, производительность на уровне |
Главное правило: если код не находится в горячем цикле — читаемость важнее микропроизводительности. StringBuilder с capacity и Collectors.joining() нужны там, где аллокации умножаются на тысячи итераций. Всё остальное — вопрос контекста, а не догмы.
Ключевой вывод
Раньше выбор между + и StringBuilder определял производительность. Сегодня производительность определяется тем, позволяет ли код JVM применить оптимизации.
И в значительной части реального кода — не позволяет.
Поэтому выбор между + и StringBuilder теперь должен происходить более осознанно, с пониманием всех глубинных процессов.
Материал подготовлен автором telegram-канала о изучении Java.
ссылка на оригинал статьи https://habr.com/ru/articles/1031336/