|
Professor Seleznov
|
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 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. ()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 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. В отличие от обычных вызовов (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 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 = "
" + userContent + "
";
Здесь конкатенация перестаёт быть просто вопросом производительности. Она становится частью архитектуры и начинает влиять на безопасность и корректность данных. С точки зрения производительности - каждая склейка создаёт временные строки. Для формирования 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 инлайнит StringBuilder, invokedynamic даёт оверхед в цикле |
| Локальная конкатенация без утекания |
результат зависит от 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-инъекций. Они предоставляют механизм для безопасной интерполяции через кастомные процессоры (STR, FMT, собственные реализации). Но можно написать и опасный код. Это инструмент, а не магия безопасности. Шпаргалка: что и когда использовать
| Ситуация |
Инструмент |
Почему |
| 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.-Источник
|