|
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-код часто работает не на одном сервере. В продакшене приложения запускаются на флоте машин и обрабатывают большой поток реальных запросов. Улучшение, которое срезает несколько миллисекунд или снижает давление на кучу на одном хосте, происходит одновременно на тысячах хостов. В таком масштабе суммарная разница становится впечатляющей. Эффект на стоимость может быть существенным, если учесть рост пропускной способности и потенциальное уменьшение числа инстансов по всему флоту сервисов.
 Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.-Источник
|