[Перевод] Java — быстрая. Ваш код может таким не быть

Страницы:  1

Ответить
 

Professor Seleznov


Я собрал Java-приложение для обработки заказов для доклада, с которым я выступил на DevNexus пару недель назад. Приложение работало. Тесты проходили. Я прогнал нагрузочный тест и собрал запись Java Flight Recording (JFR).
До изменений: 1 198 мс времени выполнения, 85 000 заказов в секунду, пик heap-а чуть больше 1 ГБ, 19 пауз GC.
После: 239 мс. 419 000 заказов в секунду. 139 МБ кучи. 4 паузы GC.
То же приложение. Те же тесты. Тот же JDK. Никаких архитектурных изменений. И эти цифры становятся куда более значимыми, если учесть, что такой код в продакшене не работает на одном сервере. Он работает на целой «флотилии». Давайте помотрим, какие именно вещи мы исправляли, чтобы добиться подобного.
Проблемы — это шаблоны, которые встречаются в реальных кодовых базах. Они прекрасно компилируются, незаметно проходят код-ревью, и их легко пропустить, если у вас нет данных профилирования, которые показывают, куда смотреть. Вот восемь таких.
TL;DR: исправление анти-паттернов вроде этих превратило Java-приложение, работавшее 1 198 мс, в приложение, работающее 239 мс. Вот на что стоит обращать внимание и что исправлять:
  • Конкатенация строк в циклах — O(n²) копирования из-за неизменяемости String.
  • O(n²) итерация стримов внутри циклов — прогон полного списка на каждый элемент
  • String.format() в горячих путях — самый медленный построитель строк, парсит формат при каждом вызове
  • Автобоксинг в горячих путях — миллионы одноразовых объектов-обёрток
  • Исключения как управляющая логика — fillInStackTrace() проходит весь стек вызовов
  • Слишком широкая синхронизация — одна блокировка становится узким местом
  • Пересоздание переиспользуемых объектов — ObjectMapper, DateTimeFormatter, Gson на каждый вызов
  • Пиннинг виртуальных потоков (JDK 21–23) — synchronized + блокирующий I/O «прикалывает» carrier-потоки
После исправлений: 5× пропускная способность, на 87% меньше кучи, на 79% меньше пауз GC. То же приложение, те же тесты, тот же JDK.
1. Конкатенация строк в циклах
String report = "";
for (String line : logLines) {
report = report + line + "\n";
}
Этот код выглядит нормально, правда? Проблема — в том, что на практике означает неизменяемость String.
Каждый раз, когда вы используете оператор +, Java создаёт совершенно новый объект String: полную копию всего предыдущего содержимого с дописанным в конец новым фрагментом. Старый объект отбрасывается. Это происходит на каждой итерации.
Объём копируемых символов растёт как O(n²). Если у вас 10 000 строк, на итерации 1 копируется почти ничего, на итерации 5 000 копируется примерно 5 000 символов накопленного содержимого, на итерации 10 000 — копируется всё. BellSoft прогнал JMH-бенчмарки ровно для этого случая и показал: когда n увеличивается в 4 раза, версия с конкатенацией в цикле замедляется более чем в 7 раз — заметно хуже линейного роста.
Исправление:
StringBuilder sb = new StringBuilder();
for (String line : logLines) {
sb.append(line).append("\n");
}
String report = sb.toString();
StringBuilder работает с одним изменяемым буфером символов. Одна аллокация. Каждый append записывает в этот буфер. Один toString() в конце.
Примечание: начиная с JDK 9 компилятор достаточно умён, чтобы оптимизировать "Order: " + id + " total: " + amount в одной строке. Но эта оптимизация не распространяется на циклы. Внутри цикла вы всё равно получаете новый StringBuilder, создаваемый и выбрасываемый на каждой итерации. Его нужно объявлять до цикла вручную — как показано в исправлении выше.
2. Случайный O(n²): стримы внутри циклов
for (Order order : orders) {
int hour = order.timestamp().atZone(ZoneId.systemDefault()).getHour();
long countForHour = orders.stream()
.filter(o -> o.timestamp().atZone(ZoneId.systemDefault()).getHour() == hour)
.count();
ordersByHour.put(hour, countForHour);
}
Выглядит разумно. Вы группируете заказы по часу. Но посмотрите, что происходит: для каждого заказа вы запускаете стрим по всему списку, чтобы посчитать, сколько заказов приходится на этот час. Если у вас 10 000 заказов, это 10 000 итераций × 10 000 элементов стрима. То есть 100 миллионов сравнений там, где должен быть один проход.
В моём демо-приложении именно этот паттерн был крупнейшим CPU-хотспотом. Он давал почти 71% стековых сэмплов CPU в записи JFR.
Исправление:
for (Order order : orders) {
int hour = order.timestamp().atZone(ZoneId.systemDefault()).getHour();
ordersByHour.merge(hour, 1L, Long::sum);
}
Один проход. O(n). Каждый заказ напрямую увеличивает счётчик своего часа. Можно также использовать Collectors.groupingBy(... Collectors.counting()), чтобы сделать это одной стрим-цепочкой, но вариант с merge нагляден и избегает оверхеда создания стрима вообще.
Если вы видите вызов .stream() внутри тела цикла — это сигнал остановиться и проверить, не делаете ли вы лишнюю работу.
3. String.format() в горячих путях
public String buildOrderSummary(String orderId, String customer, double amount) {
return String.format("Order %s for %s: $%.2f", orderId, customer, amount);
}
String.format() часто рекомендуют как чистый и читаемый способ собирать строки. Да, это читаемо — и при этом это самый медленный вариант построения строк в Java, если вы вызываете его часто.
Baeldung прогнал JMH-бенчмарки по всем подходам к конкатенации строк в Java. String.format() оказался последним во всех категориях. Каждый вызов должен заново разобрать строку формата, выполнить токенизацию на базе regex и пройти через всю «тяжёлую» механику java.util.Formatter. StringBuilder стабильно оказывался самым быстрым.
Исправление:
return "Order " + orderId + " for " + customer + ": $" + String.format("%.2f", amount); 
Используйте String.format() только для числового форматирования там, где оно действительно нужно, а остальное отдайте компилятору на оптимизацию. Или используйте StringBuilder, если нужен полный контроль.
String.format() отлично подходит для загрузки конфигураций, стартового кода, сообщений об ошибках — всего, что выполняется редко. Убирайте его из всего, что профилировщик помечает как «горячее».
4. Автобоксинг в горячих путях
Long sum = 0L;
for (Long value : values) {
sum += value;
}
На самом же деле происходит примерно следующее:
Long sum = Long.valueOf(0L);
for (Long value : values) {
sum = Long.valueOf(sum.longValue() + value.longValue());
}
Каждая итерация распаковывает sum, чтобы получить long, складывает, затем упаковывает результат обратно в новый объект Long. При миллионе элементов вы создаёте миллион объектов Long, которые потом должен убрать GC. Каждый Long на 64-битной JVM занимает примерно 16 байт в куче. Это 16 МБ «мусорной» нагрузки на кучу ради простого цикла сложения.
long sum = 0L;  // примитив, не обёртка
for (long value : values) {
sum += value;
}
Где это обычно незаметно просачивается: циклы агрегации и обработки. Суммирование метрик, накопление счётчиков, сбор статистики. Обёртки появляются потому, что где-то выше по стеку сигнатура коллекции оказалась Long, и никто не подумал, во что это превращается внизу, в цикле. Такое действительно легко пропустить.
Следите за тем, чтобы Integer, Long или Double не использовались как локальные переменные цикла или аккумуляторы. Также обращайте внимание на List<Long> и Map<String, Integer> в часто вызываемом коде. Каждый .get() и .put() — это скрытый раунд-трип боксинга/анбоксинга, за который вы платите молча.
5. Исключения как механизм control-flow 
public int parseOrDefault(String value, int defaultValue) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
Если этот метод вызывается в тесном цикле и заметная доля входных данных нечисловая, у вас есть проблема производительности, которая может выглядеть не как проблема.
Самая дорогая часть — Throwable.fillInStackTrace(), которая выполняется в конструкторе Throwable каждый раз, когда создаётся исключение. Она проходит весь стек вызовов через нативный метод и материализует его в объекты StackTraceElement. Чем глубже стек, тем дороже. 
Представьте ситуацию во фреймворке вроде Spring — там стек может быть очень глубоким. Норман Маурер из проекта Netty бенчмаркал это, и разница существенная. JMH-результаты Baeldung показывают: выбрасывание исключения делает метод на сотни раз медленнее по сравнению с обычным путём возврата.
Это не теория. Есть реальная продакшен-история про Scala/JVM-шаблонизатор, который сократил время ответа в 3 раза, обнаружив, что NumberFormatException выбрасывался на каждом поле каждого рендера шаблона. Каждый раз, когда имя поля проверяли на то, является ли оно числовым индексом, оно выбрасывало исключение.
Исправление:
public int parseOrDefault(String value, int defaultValue) {
if (value == null || value.isBlank()) return defaultValue;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (i == 0 && c == '-') continue;
if (!Character.isDigit(c)) return defaultValue;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
Или используйте NumberUtils.isParsable() из Apache Commons Lang, если он уже есть у вас в зависимостях.

Комментарий от Михаила Поливаха

От себя лишь добавлю, что автор, конечно, прав. Создание экземпляров Throwable это дорого. Строить control flow на exception-ах опасно, и если так и делать, то крайне аккуратно.
Например, иногда, прибегают к другому подходу, который весьма популярен, но про который автор тут не сказал. Речь про создание одного экземпляра Throwable и получения на него ссылки как static поля:
public class FastExceptions {
// Создаем один статический экземпляр
public static final IllegalArgumentException INVALID_AGE = new IllegalArgumentException("Возраст должен быть больше 0") {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
};
public static void checkAge(int age) {
if (age <= 0) {
throw INVALID_AGE;
}
}
}
Понятное дело, в  таком подходе мы полностю теряем информацию о stacktrace, но суть в том, что зачастую для принятия локального решения stacktrace и не нужен, т.к. control flow строится на exception-ах. 
Тут есть ещё проблема того, что выбрашенный exception это по сути shared mutable state, что тоже в общем случае довольно плохо. В общем, принимайте решение сами. Тут нет серебрянной пули.
Принцип: если некорректный ввод — это обычный сценарий в вашем приложении (пользовательские данные, внешние фиды, всё, что вы не контролируете полностью), валидируйте явно. Исключения — для действительно неожиданных условий, а не для «это может быть в неправильном формате».

Комментарий от Михаила Поливаха

Тут кстати хорошо ложиться подход "error as a value", который в Java реализуется через Either/Try/Option из библиотеки vavr.
6. Слишком широкая синхронизация
public class MetricsCollector {
private final Map<String, Long> counts = new HashMap<>();
public synchronized void increment(String key) {
counts.merge(key, 1L, Long::sum);
}
public synchronized long getCount(String key) {
return counts.getOrDefault(key, 0L);
}
}
Общему изменяемому состоянию нужна защита. Но synchronized на весь метод означает, что в любой момент времени только один поток может вызвать любой из методов. В сервисе с реальной конкурентностью каждый поток, вызывающий increment(), выстраивается в очередь, ожидая завершения всех остальных. Сама блокировка становится узким местом.
Исправление:
private final ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
public void increment(String key) {
counts.computeIfAbsent(key, k -> new LongAdder()).increment();
}
public long getCount(String key) {
LongAdder adder = counts.get(key);
return adder == null ? 0L : adder.sum();
}
ConcurrentHashMap обрабатывает конкурентные чтения и записи, не блокируя всю структуру. LongAdder специально создан для высококонкурентного инкремента. Он распределяет счётчик по внутренним «ячейкам» и под нагрузкой обгоняет AtomicLong.
Отдельно стоит отметить: обёртки Collections.synchronizedMap() имеют ту же проблему «широкой» блокировки — один замок на всю map. ConcurrentHashMap почти всегда правильная замена.
7. Повторное создание «переиспользуемых» объектов
public String serializeOrder(Order order) throws JsonProcessingException {
return new ObjectMapper().writeValueAsString(order);
}
ObjectMapper — один из самых частых примеров объекта, который кажется дешёвым в создании, но на самом деле нет. Его конструирование включает поиск модулей, инициализацию кэша сериализаторов и загрузку конфигурации. Это реальная работа, происходящая здесь на каждом вызове.
Тот же паттерн встречается с DateTimeFormatter.ofPattern("..."), new Gson(), new XmlMapper(). Они все задуманы как объекты, которые создаются один раз и переиспользуются. Создавать их в горячем методе — значит платить цену инициализации на каждом вызове.
Исправление:
private static final ObjectMapper MAPPER = new ObjectMapper();
public String serializeOrder(Order order) throws JsonProcessingException {
return MAPPER.writeValueAsString(order);
}
ObjectMapper потокобезопасен после конфигурации, поэтому общий static final-инстанс использовать нормально.

Комментарий от Михаила Поливаха

Кстати, в Jackson 3 создавать непосредственно ObjectMapper не надо. Лучше создавать конкретный mapper, например, JsonMapper или XmlMapper
Встроенные форматтеры вроде DateTimeFormatter.ISO_LOCAL_DATE уже являются синглтонами. Если вы вызываете DateTimeFormatter.ofPattern("...") в горячем методе — вынесите в константу.
Эвристика: если конструктор объекта делает заметную подготовительную работу, а сам объект после создания без состояния (или безопасно разделяем), он должен быть полем или константой, а не локальной переменной.
8. «Прикалывание» виртуальных потоков (если вы на JDK 21–23)
Этот пункт стоит включить, если вы начали использовать виртуальные потоки, ставшие production-фичей в Java 21.
Виртуальные потоки работают, монтируясь на небольшой пул платформенных (OS) потоков, называемых “carrier threads”. Когда виртуальный поток блокируется (например, ожидая I/O), планировщик размонтирует его с carrier-потока, освобождая carrier для выполнения других задач. В этом и состоит история масштабируемости виртуальных потоков.
Но есть подвох. Когда виртуальный поток входит в synchronized блок и внутри него попадает на блокирующую операцию, его нельзя размонтировать. Он «прикалывает» carrier-поток. Этот платформенный поток теперь застрял в ожидании и не может обслуживать другие виртуальные потоки столько, сколько длится блокирующая операция.
// Этот паттерн может приколоть carrier-поток на JDK 21
public synchronized String fetchData(String key) throws IOException {
return Files.readString(Path.of("/data/" + key)); // блокирующий I/O внутри synchronized
}
Если это происходит достаточно часто, все carrier-потоки оказываются «приколотыми», и приложение замирает — даже если тысячи виртуальных потоков готовы работать. Netflix столкнулся с этим в продакшене и написал пост о том, как они это отлаживали.
JFR прямо сообщает, когда это происходит. Событие jdk.VirtualThreadPinned срабатывает всякий раз, когда виртуальный поток блокируется в «прикалывающем» состоянии, и по умолчанию оно триггерится только когда операция длится дольше 20 мс — то есть уже отфильтрованы действительно значимые случаи.
Исправление на JDK 21–23:
private final ReentrantLock lock = new ReentrantLock();
public String fetchData(String key) throws IOException {
lock.lock();
try {
return Files.readString(Path.of("/data/" + key));
} finally {
lock.unlock();
}
}
ReentrantLock не использует мониторные блокировки уровня ОС, поэтому JVM может нормально размонтировать виртуальный поток при блокировке вместо того, чтобы «прикалывать» его к carrier-потоку.
Примечание про JDK 24: JEP 491, вошедший в Java 24, во многом решает эту проблему. synchronized больше не приводит к «прикалыванию» в большинстве случаев на JDK 24+. Если вы всё ещё на 21, 22 или 23 — это по-прежнему актуально и стоит проверять через JFR. Если вы на 24 — в основном можно не беспокоиться из-за synchronized, хотя нативные вызовы всё ещё могут вызывать «прикалывание».
Эффект накопления
Ни один из этих паттернов не ломает приложение. Они не выбрасывают исключения и не дают неправильных ответов. Они просто делают всё немного медленнее, съедают больше памяти и хуже масштабируются.
Сложность в том, что без профилирования их трудно найти: любой из них может быть совершенно безвреден в вашей кодовой базе. Конкатенация строк в цикле, который выполняется один раз при старте, вам ничего не стоит. String.format() в утилите, вызываемой дважды в день, — нормально. Проблема начинается, когда эти паттерны оказываются в горячих путях — коде, который выполняется на каждый запрос, каждое событие, каждую итерацию основного цикла обработки.
В моём демо-приложении такие паттерны и другие превратили операцию на 239 мс в операцию на 1 198 мс и раздули кучу с 139 МБ до более чем 1 ГБ. Ни один паттерн не был катастрофой сам по себе. Но уберите давление на кучу — и паузы GC падают с 19 до 4. Уберите контеншн — и становятся видны новые хотспоты, которые раньше тонули в шуме. Форма профиля меняется.
И эти улучшения затем «умножаются» за пределами одного приложения. Некоторые оптимизации могут казаться мелочью, когда вы смотрите на один инстанс или небольшие ускорения в тестах. Но в реальном мире Java-код часто работает не на одном сервере. В продакшене приложения запускаются на флоте машин и обрабатывают большой поток реальных запросов. Улучшение, которое срезает несколько миллисекунд или снижает давление на кучу на одном хосте, происходит одновременно на тысячах хостов. В таком масштабе суммарная разница становится впечатляющей. Эффект на стоимость может быть существенным, если учесть рост пропускной способности и потенциальное уменьшение числа инстансов по всему флоту сервисов.
pic
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.-Источник
 
Loading...
Error