Однажды мне довелось выступать на конференции DevNexus, и в качестве примера я написал на Java приложение для обработки заказов. Приложение было исправным. Тесты проходили. Я прогнал нагрузочный тест, и на его основе собрал файл Java Flight Recording (JFR).

До того, как я внёс какие‑либо изменения: истекло 1 198 мс, обрабатывалось 85 000 заказов в секунду, пиковый размер кучи — немного более 1 ГБ, 19 остановок на сборку мусора.

После изменений: 239 мс, 419 000 заказов в секунду, куча 139 МБ, 4 остановки на сборку мусора.

Одно и то же приложение. Одинаковые тесты. JDK не изменился. Архитектура тоже не менялась. Но эти цифры получаются гораздо более осмысленными, если учесть, что в продакшене этот код работает не на одной машине, а на целом парке серверов.

Проблема заключалась в паттернах, которые проявляются только в реальных базах кода. Они не мешают компиляции, проскальзывают в код на этапе ревью, и это именно такие вещи, которые упускаются из виду, если не профилировать код и не знать, куда именно смотреть. В этом посте мы обсудим восемь таких примеров.

TL;DR: Исправив антипаттерны, подобные перечисленным здесь, удалось ускорить выполнение конкретного приложения на Java c 1 198 мс до 239 мс. Вот некоторые вещи, которые следует отлавливать и исправлять:

1. Конкатенация строк в циклах — сложность при копировании O(n²) обусловлена неизменяемостью

2. Сложность O(n²) при потоковом переборе внутри циклов — потоковая передача целого списка на каждый элемент

3. String.format() на активных путях — самый медленный сборщик строк, при каждом вызове выполняет разбор формата

4. Автоупаковка на активных путях — миллионы одноразовых объектов‑обёрток

5. Исключения при работе с потоком управления — fillInStackTrace() обходит весь стек вызовов

6. Слишком широкая синхронизация — одна блокировка превращается в узкое место

7. Многократное создание переиспользуемых объектов ObjectMapper, DateTimeFormatter, Gson на каждом вызове

8. Закрепление виртуальных потоков (JDK 21–23) — синхронизированный + блокирующий ввод/вывод закрепляет несущие потоки операционной системы

После того, как всё это было исправлено: пропускная способность возросла впятеро, куча уменьшилась на 87%, также на 79% сократилось количество пауз на сборку мусора. Приложение, тесты, JDK — всё то же самое.

1. Конкатенация строк в циклах

String report = "";
for (String line : logLines) {
    report = report + line + "\n";
}

Этот код выглядит хорошо, так? Проблема в том, чем оборачивается на практике неизменяемость String.

Всякий раз, когда вы ставите +, Java создаёт совершенно новый объект String, в котором полностью копирует всё уже имеющееся содержимое и добавляет небольшой довесок. Более старый объект отбрасывается. И так происходит на каждой итерации.

Копируемые символы масштабируются со сложностью O(n²). Если у вас 10 000 строк, то на первой итерации не копируется почти ничего, на 5000-й итерации копируется 5 000 символов накопившегося содержимого, а на 10 000-й итерации копируется уже весь материал. В BellSoft проверили именно этот случай на бенчмарках JMH и показали, что, когда n вырастает вчетверо, работа версии с конкатенацией строк в циклах замедляется более чем в семь раз — ситуация значительно хуже, чем линейный рост.

Исправляем

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 миллионов операций сравнения.

В моём демо‑приложении именно на эту закономерность приходилась самая крупная горячая точка в работе ЦП. К ней относился почти 71% образцов из стека, попавших в запись 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()), чтобы провести эту операцию в одном потоковом конвейере, но подход со слиянием ясный, и в таком случае мы вообще обходимся без создания потока.

Если вы видите .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() пришёл последним во всех категориях. Ему приходится при каждом вызове распарсивать формат строки, сопоставлять токены на основе регулярных выражений и диспетчеризовать вызов через все механизмы java.util.FormatterStringBuilder последовательно оказывался самым быстрым.

Исправляем

return "Order " + orderId + " for " + customer + ": $" + String.format("%.2f", amount);

Метод String.format() используйте для форматирования чисел, когда он действительно требуется, а компилятору позвольте оптимизировать всё остальное. Или просто пользуйтесь StringBuilder, если вам нужен полный контроль.

String.format() хорошо подходит для загрузки конфигов, кода запуска, сообщений об ошибках — то есть, для любых задач, выполняемых нечасто. Убирайте его из всех мест, которые ваш профилировщик помечает как горячие.

4. Автоупаковка в активных путях

Long sum = 0L;
for (Long value : values) {
    sum += value;
}

Что именно здесь происходит на уровне JVM:

Long sum = Long.valueOf(0L);
for (Long value : values) {
    sum = Long.valueOf(sum.longValue() + value.longValue());
}

На каждой итерации мы распаковываем sum, чтобы получить long, складываем, а затем упаковываем результат обратно в новый объект Long. Если у вас миллион элементов, то придётся создать миллион объектов Long, которые затем попадут под сборку мусора. Каждый Long на 64-разрядной JVM занимает в куче примерно 16 байт. Получается 16 МБ текучки данных в куче на месте обычного цикла для сложения.

long sum = 0L;  // примитив, а не обёртка
for (long value : values) {
    sum += value;
}

Такая неприятность вкрадывается в код в циклах агрегации и обработки. Например, при суммировании метрик, аккумуляции счётчиков, построении статистики. Упакованные типы проскальзывают в код потому, что кто‑то вышестоящий использовал Long в сигнатуре коллекции и не подумал, во что это обойдётся ниже в цикле. Пропустить такое вполне простительно.

Следите за IntegerLong или Double, которые используются в качестве локальных переменных цикла или аккумуляторов. Также смотрите за List<Long> и Map<String, Integer> в часто вызываемом коде. При каждом .get() и .put() требуется потратиться как на упаковку, так и на распаковку, и вы за это незаметно платите.

5. Исключения для потока управления

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: в том случае время отклика удалось сократить втрое, как только выяснилось, что исключение 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, если он уже включён в ваш путь классов.

Обновление: некоторые комментаторы с Hacker News правильно указали, что в предложенном выше исправлении изначально отсутствовал try-catch, из‑за чего необработанные исключения могли выбрасываться при переполнении значений и в таких пограничных случаях как одиночный ”-”. Обновил пример так, чтобы блок try‑catch охватывал конечный parseInt в качестве подстраховки. На этапе перед валидацией всё‑таки удаётся не сворачивать на дорогостоящий путь с исключением в абсолютном большинстве случаев, когда программа получает плохой ввод — в этом и суть.

Принцип таков: если недопустимый ввод в вашем приложении попадается в порядке вещей (в виде данных, предоставляемых пользователем, внешних потоков данных, чего угодно, что вы полностью не контролируете — то предварительно валидируйте его, и делайте это явно. Исключения оставьте на по‑настоящему неожиданные случаи, а не на «возможно, это неверный формат».

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() страдают от той же самой проблемы обширной блокировки: одна блокировка на весь словарь. Почти всегда их можно корректно заменить на 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. Встроенные варианты DateTimeFormatter, например, DateTimeFormatter.ISO_LOCAL_DATE — уже и так одиночки. Если вы вызываете DateTimeFormatter.ofPattern(“...”) в активном методе, перенесите его в константы.

Эвристика: если конструктор объекта выполняет немалую работу по начальной настройке, а объект не сохраняет состояния (или безопасно поддаётся совместному использованию), то, сконструировав такой объект, следует сделать его полем или константой, а не локальной переменной.

8. Закрепление виртуальных потоков (если вы работаете под JDK 21–23)

Этот пункт рассмотрим на тот случай, если вы уже начали использовать виртуальные потоки, которые были введены как готовые к применению в проде в версии Java 21.

Виртуальные потоки действуют так: прикрепляются к небольшому пулу потоков платформы (ОС), которые называются «несущими» (carrier). Когда виртуальный поток блокируется, например, в ожидании ввода/вывода, планировщик открепляет его от несущего потока, освобождая несущий, чтобы он тем временем успел сделать что‑то ещё. Вот и вся история с масштабируемостью виртуальных потоков.

Но есть нюанс. Когда виртуальный поток входит в блок synchronized и, будучи внутри него, натыкается на блокирующую операцию, открепить его нельзя. Он закрепляет несущий поток. В таком случае, этот поток платформы застыл в ожидании и не может обслуживать другие виртуальные потоки до тех пор, пока не завершится заблокировавшая его операция.

// При возникновении такого паттерна в JDK 21 может быть заблокирован несущий поток платформы
public synchronized String fetchData(String key) throws IOException {
    return Files.readString(Path.of("/data/" + key)); // блокирующий ввод/вывод внутри блока synchronized
}

Если такое происходит достаточно часто, то рано или поздно все ваши несущие потоки окажутся закреплены, и приложение застрянет, даже в случае, когда есть тысячи виртуальных потоков, готовых взяться за работу. 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 может в обычном порядке открепить виртуальный поток, если он заблокируется, а не прикреплять его к несущему потоку.

Замечание к JDK 24: JEP 491, сданный в Java 24, в основном решает эту проблему. В JDK 24+ synchronized, как правило, не вызывает закрепления потоков. Если вы по‑прежнему работаете под 21, 22 или 23, то для вас это по‑прежнему актуально, и этот момент стоит проверять по данным JFR. Если вы работаете под 24, то, как правило, можно не беспокоиться об этом synchronized, хотя, при вызове нативных методов по‑прежнему может происходить закрепление.

Накопительный эффект

Ни один из этих паттернов не приведёт к отказу вашего приложения. Они не выбрасывают исключений и не выдают неверных ответов. Они просто всё несколько замедляют, приводят к повышенному расходу памяти и масштабируются хуже, чем должны были бы.

Их сложно найти без профилирования потому, что каждый из них в отдельности может не причинять базе кода никакого вреда. Если при каждом запуске программы в цикле выполняется конкатенация строк, то вам это ничего не стоит. Если String.format() во вспомогательном классе вызывается дважды в день — это нормально. Проблема возникает, когда такие паттерны залегают на активных путях, оказываются в коде, который выполняется при каждом запросе, каждом событии, на каждой итерации вашего главного цикла обработки.

В моём демо‑приложении приведённые здесь паттерны и подобные им превратили 239-миллисекундную операцию в 1198-миллисекундную, а расход памяти кучи довели от 139 МБ до более чем 1 ГБ. Ни одна из этих закономерностей в отдельности не была катастрофической. Но, как только удалось снизить давление кучи, количество пауз на сборку мусора также сократилось с 19 до 4. Устранили конкуренцию за ресурсы — и выступили те горячие точки, которые ранее были совершенно не видны на фоне шума. Очертания профиля изменились.

Причём, эти улучшения также дают накопительный эффект. Некоторые из этих оптимизаций могут показаться тривиальными, если вы изучаете всего один инстанс или видите небольшие улучшения во время выполнения набора тестов. Но реальный код Java зачастую не работает «из коробки». В продакшене бывают приложения, обслуживающие целый парк машин, на которых приходится обрабатывать большой объём реальных пользовательских запросов. Если за счёт улучшения удалось скостить несколько миллисекунд или снизить давление кучи на одном хосте, то в реальности оно затронет тысячи хостов одновременно. В большом масштабе такая совокупная разница может быть невероятной. Оптимизация помогает сильно снизить расходы, если вы раздумываете, как бы повысить пропускную способность и потенциально уменьшить размеры инстансов в пределах всего парка.

Комментарии (4)


  1. nv13
    16.08.2026 22:41

    6 пример какой то неудачный, имхо - противопоставляется синхронизация метода конкретной имплементации контейнера, специализированного для многопоточного использования. Выглядит это как просто "осторожней с синхронизациями!")


  1. Sazonov
    16.08.2026 22:41

    Да сама джава никогда медленной не была (относительно) в реальных задачах. Узким местом очень часто становится сборщик мусора. Особенно когда срабатывает в самый неподходящий момент.


  1. Gaikotsu
    16.08.2026 22:41

    С 5 примером сам столкнулся однажды, при работе над одним из эмуляторов L2.

    Как-то раз, ковыряясь по одному делу в профайлере, отслеживающем работу запущенного сервера, обратил внимание на то что в нем сразу же после запуска и загрузки сервера наблюдается несколько миллионов перехваченных исключений вида IllegalArgumentException. От увиденного я немного скажем так, удивился и стал копать - что это вобще за нафиг то такое и какого фига так много то?

    Причина в итоге оказалась очень даже занятной. В серверах на базе овера (OverWorld/LostWorld - одна из веток эмуляторов) есть такой класс как MultiValueSet и часто используемый наследный от него StatsSet. По сути это банальный HashMap, с доп. методами позволяющими возвращать из него значения нужного типа. И используется он во многих парсерах данных из тех же хмлок - предметы, умения, нпс и т.д. и т.п. И как выяснилось, для многих типов значений там очень "творчески" сделана обработка ситуаций, когда запрашиваемого значения в мапе нет или оно ошибочное и надо вернуть значение, заданное по умолчанию.

    Т.е. сделано все было абсолютно по той же логике как и в 5 примере - если к примеру значения в мапе нет или оно не подходит по типу/формату - будет брошено исключение IllegalArgumentException, далее оно будет перехвачено и будет возвращено значение по умолчанию.

    Ну и еще из-за этого возникала проблема с тем что в файлах данных вполне могли быть не отлавливаемые ошибки в значениях - при ошибке разбора значения из файла, через перехваченное исключение просто тихо возвращало значение по умолчанию, никак об этом не сообщая.

    После переписывания всего этого во вменяемый вид сразу стало заметно значительное ускорение загрузки и разбора данных из файлов при загрузке сервера - время, затрачиваемое на эти этапы, сократилось раз в 5-6.