Стандартный набор инструментов для профилирования Java‑приложений из коробки JDK — JFR (Java Flight Recorder), jstack, jmap, VisualVM — закрывает многие задачи, но в реальных же сценариях у каждого есть ограничения.

JFR хорош для общего обзора, но плохо показывает горячие методы из‑за безопасных точек (safepoint bias) — sampling срабатывает только на safepoint, и методы, инлайненные JIT или без safepoint‑poll, в выборку не попадают. jstack даёт срез стека на момент вызова, но один срез — это случайность, а серия с шагом 100 мс уже неудобно анализировать руками. VisualVM требует JMX‑подключения, что в проде с сетевой политикой или Kubernetes‑подами часто блокировано.

async‑profiler — внешний инструмент, который в целом решает эту проблему. К маю 2026 актуальная версия — async‑profiler 4.3, команда перешла на квартальный цикл релизов. С январских обновлений 2026 года инструмент идёт в комплекте Amazon Corretto, отдельно скачивать не нужно.

В статье обзор основных режимов профилирования, как использовать, как читать flame graph и где async‑profiler помогает, а где лучше взять другой инструмент.

Установка и базовые команды

Скачать архив:

wget https://github.com/async-profiler/async-profiler/releases/download/v4.3/async-profiler-4.3-linux-x64.tar.gz
tar xzf async-profiler-4.3-linux-x64.tar.gz
cd async-profiler-4.3-linux-x64

Бинарник называется asprof (в версиях до 3.x он был profiler.sh). На Amazon Corretto бинарник доступен в стандартной поставке JDK. Поддерживаются Linux и macOS, JDK 8 и новее, на Linux одинаковые бинарники работают и с glibc, и с musl.

Системные требования. На Linux нужны два sysctl‑параметра, иначе async‑profiler не сможет собирать стеки:

sudo sysctl kernel.perf_event_paranoid=1
sudo sysctl kernel.kptr_restrict=0

Без них вы получите ошибку perf_event_open failed: Permission denied или нулевые сэмплы. В Docker‑контейнерах это иногда требует --cap-add SYS_ADMIN или запуска с --privileged. В Kubernetes — добавления capabilities в SecurityContext пода, что обычно требует согласования с инфраструктурной командой.

Базовая команда — запустить профилирование на 60 секунд и сохранить flame graph:

asprof -d 60 -f flamegraph.html <PID>

PID Java‑процесса можно посмотреть через jps -l (там видны процессы и main‑классы). Результат — самодостаточный HTML‑файл, который можно открыть в браузере. Внутри — интерактивный SVG, в котором можно кликать по блокам и спускаться в детали, или искать по тексту метода.

Для коротких операций (несколько секунд) полезна опция -e cpu, чтобы явно задать event; по умолчанию это и так CPU, но при копировании команд явность экономит время.

CPU profiling и чтение flame graph

Команда для CPU‑профилирования:

asprof -e cpu -d 60 -f cpu.html <PID>

Каждый блок — это метод, ширина блока пропорциональна времени, которое CPU провёл в этом методе (включая вызываемые из него методы). По вертикали — стек вызовов: main‑методы внизу, более глубокие — наверху. Цвет блоков отражает категорию: зелёный — Java‑методы, жёлтый — C++ внутри JVM, красный — нативные функции ядра и системные библиотеки.

Главные паттерны, на которые смотреть:

  • Широкая плоская «вершина» — главная горячая точка, где CPU проводит большую часть времени. Обычно туда уходит первая‑вторая инвестиция в оптимизацию. Если плато даёт 30–40% общего времени, замена этого метода на более эффективный алгоритм или кэш даст пропорциональный выигрыш.

  • Узкие глубокие «пики» — много времени уходит в один глубокий стек. Часто это указывает на рекурсивный код, который делает что‑то сложное.

  • Equal‑width блоки сверху — JIT не справился оптимизировать, метод существует на одном уровне без inlining. В Java это часто полиморфные вызовы, которые JIT не смог девиртуализировать (megamorphic call site), или методы с большим bytecode size, превышающие лимиты MaxInlineSize.

  • Много мелких stacks — общая нагрузка распределена, нет единой горячей точки. Это значит, что простой подход «оптимизировать один метод» не сработает, нужна архитектурная переработка.

Один частый паттерн в Spring Boot‑сервисах — большая часть CPU уходит в Jackson serialization, JPA query building через Hibernate, или Spring AOP‑прокси. Если на flame graph видно, что 30% CPU тратится в com.fasterxml.jackson.databind.ser.std.BeanSerializerBase, имеет смысл посмотреть на размер JSON‑ответов, подумать о projection DTOs вместо полных Entity, или включить кэширование сериализаторов.

Если 20% в org.hibernate.query.sqm — это сборка SQL‑запросов из JPQL, и решение либо в HQL вместо Criteria API, либо в native query для горячих путей.

Кстати, по умолчанию async‑profiler вроде как сэмплирует с частотой 1000 Hz (тысяча сэмплов в секунду). Для горячих сервисов этого достаточно, а вот для коротких профилей или редких методов можно поднять до -i 1ms (миллисекундный интервал, 1000 Hz) или опустить до -i 10ms (100 Hz) для снижения накладных расходов.

Allocation profiling: где аллоцируется память

Команда:

asprof -e alloc -d 60 -f alloc.html <PID>

В этом режиме async‑profiler сэмплирует аллокации, выходящие за пределы TLAB (Thread‑Local Allocation Buffer). TLAB — это локальная зона памяти каждого потока, в которую быстро аллоцируются новые объекты без блокировок. Объекты, выходящие за пределы TLAB (большие или редкие), аллоцируются медленнее и попадают в выборку.

На таком flame graph ширина блока пропорциональна объёму аллоцированной памяти, а не времени CPU, и по картинке сразу видно, какие методы создают больше всего объектов в куче и куда уходит давление на garbage collector.

Возможные находки:

  1. Spring AOP‑прокси создают много объектов. Аннотации @Transactional, @Cacheable, @Async оборачиваются в CGLIB или JDK‑прокси, и каждый вызов создаёт массив аргументов для invocation handler. Если метод вызывается миллионы раз и подгребает много объектов, имеет смысл вынести логику в подкласс без аннотаций для горячего пути, или вызывать через явный TransactionTemplate вместо @Transactional.

  2. String concatenation в горячих циклах. В Java 9+ через invokedynamic есть compile‑time оптимизация для оператора +, но если на flame graph видны промежуточные StringBuilder.append, это часто означает, что строка собирается в цикле, и каждая итерация аллоцирует промежуточный массив char[]. Решение — заранее создать StringBuilder нужного размера или использовать String.join для коллекций.

  3. Boxing на горячих путях. Integer.valueOf или Long.valueOf создают объекты‑обёртки. Часто видно в коде, который использует Map<String, Integer> для счётчиков — каждое map.put(key, count + 1) означает auto‑boxing. Решение — LongAdder для счётчиков или примитивные коллекции из Eclipse Collections / fastutil.

  4. Реактивные стримы и WebFlux. На flame graph часто видны MonoFlatMap и FluxConcatMap — это аллокации операторов на каждый запрос. Это нормально для реактивной модели, но если их сильно много и они дают серьёзный GC pressure, имеет смысл подумать о переходе на виртуальные потоки в Spring Boot 4, там тот же блокирующий код пишется без аллокаций реактивных операторов.

  5. Сериализация в logback/log4j2. Если на flame graph много ThrowableProxyConverter или похожих — это значит, что в логах часто пишутся исключения с полным stack trace, что само по себе тяжёлая операция аллокации. Решение — либо не логировать stack trace на warn‑уровне, либо использовать асинхронный appender.

Lock contention: где потоки ждут друг друга

Lock contention — это когда потоки ждут друг друга на synchronized‑блоках или java.util.concurrent локах. Часто не видно через CPU‑профилирование (поток в ожидании не нагружает CPU), но критично для latency: если поток ждёт полсекунды на захвате lock'а, запрос на этой полусекунде висит.

Команда:

asprof -e lock -d 60 -f locks.html <PID>

Дополнительно можно установить порог по длительности (записывать только сэмплы дольше N миллисекунд), чтобы не получать шумные мелкие захваты:

asprof --lock 10ms -d 60 -f locks.html <PID>

С версии 4.3 async‑profiler умеет также сэмплировать pthread mutex contention в native‑коде через --nativelock.

asprof --nativelock 10ms -d 60 -f nativelocks.html <PID>

На lock‑профиле ширина блока — это суммарное время, которое потоки провели в ожидании lock'а, а стек показывает, где случился блокирующий вызов.

Типичные находки:

  • Hot synchronized в общем кэше. Если в коде есть synchronized (cache) и кэш горячий, на flame graph видно широкое плато на этом методе. Лечится переходом на ConcurrentHashMap для простых кэшей или Caffeine для кэшей с eviction.

  • HikariCP до Java 24. Если проект на Java 21–23, lock contention в HikariCP ConcurrentBag бывает очень видна — потоки ждут друг друга на synchronized внутри пула. Лечит обновление на Java 24+ (JEP 491 убрал pinning от synchronized для виртуальных потоков и заодно уменьшил contention в общем случае).

  • Singleton initialization под нагрузкой. Если на flame graph виден Class.<clinit> под synchronized — это значит, что инициализация класса блокирует другие потоки. Часто лечится либо ленивой инициализацией через Initialization-on-demand holder, либо явной инициализацией при старте через Class.forName для классов с тяжёлым <clinit>.

Wall‑clock vs CPU: когда что использовать

CPU‑профилирование показывает, где поток нагружает CPU. Это полезно для CPU‑bound нагрузок, но бесполезно для I/O‑bound, где поток большую часть времени блокируется в ожидании.

Wall‑clock профилирование показывает, где поток находится во времени независимо от состояния (running, waiting, blocked, sleeping):

asprof -e wall -d 60 -f wall.html <PID>

Если ваш сервис делает HTTP‑запрос в стороннюю систему и ждёт ответа 500 мс, в CPU‑профиле этого вызова почти не видно (поток в ожидании, CPU не нагружен). В wall‑clock‑профиле он будет занимать значительную долю.

Когда что использовать:

  • CPU‑bound сервис (расчёты, обработка данных, сериализация, парсинг) — CPU profiling.

  • I/O‑heavy сервис (HTTP‑вызовы, JDBC, Kafka, Redis) — wall‑clock profiling.

  • Сервис с непонятной latency‑проблемой в духе «запрос идёт 2 секунды, не понимаю, куда время уходит» — wall‑clock на одном запросе через -t (per‑thread filtering), чтобы видеть конкретный поток обработки.

Хорошая модель: CPU‑профиль отвечает на вопрос «где жжёт процессор», wall‑clock — на вопрос «где висит время».

Differential flame graphs: сравнение до и после

Можно сравнить два профиля. Снимаете flame graph до изменения, делаете изменение, снимаете после, и async‑profiler показывает разницу: что стало быстрее, что стало медленнее.

Это делается через утилиту jfrconv, которая идёт в комплекте с async‑profiler:

asprof -e cpu -d 60 -f before.jfr <PID>
# делаете оптимизацию, перезапускаете сервис, повторяете нагрузку
asprof -e cpu -d 60 -f after.jfr <PID>

jfrconv --diff before.jfr after.jfr -o diff.html

В получившемся flame graph красные блоки — то, что стало хуже (медленнее или больше аллокаций), зелёные — то, что стало лучше.

Differential работает не только с JFR. async‑profiler 4.x сохраняет данные в JFR‑формате по умолчанию, но jfrconv принимает и flamegraph, что позволяет сравнивать профили из разных запусков.

Подключение к live‑процессу в проде

Принято так, что async‑profiler запускается не при старте JVM, а в момент диагностики — без перезапуска сервиса.

Способы:

  • По PID на хосте. Самый простой: asprof -d 60 -f out.html <PID>. Работает, если бинарь и Java‑процесс на одном хосте и доступ есть с правами того же пользователя.

  • Через docker exec. Если сервис в контейнере, копируем async‑profiler внутрь и запускаем:

docker cp async-profiler-4.3-linux-x64/. <container>:/opt/asprof/
docker exec <container> /opt/asprof/asprof -d 60 -f /tmp/out.html 1
docker cp <container>:/tmp/out.html ./out.html

PID внутри контейнера обычно 1, потому что JVM запускается как entry point.

  • Через kubectl exec для Kubernetes. Аналогично — копируем в под и запускаем. Главное, у пода должен быть Bash или хотя бы /bin/sh, и достаточно capabilities для perf_events. На managed Kubernetes (EKS, GKE) последнее часто требует privileged‑mode или sidecar‑контейнера с правильными настройками.

  • Через JVM‑агент. Async‑profiler можно подгрузить как agent через jcmd без shell‑доступа:

jcmd <pid> JVMTI.agent_load /path/to/libasyncProfiler.so start,event=cpu,file=/tmp/out.jfr
# через минуту:
jcmd <pid> JVMTI.agent_load /path/to/libasyncProfiler.so stop,file=/tmp/out.jfr
  • Через AP‑agent (отдельный проект dpsoft/ap-agent) — мини REST API для запуска профилирования по HTTP. Удобно для встроенных диагностических эндпоинтов:

java -javaagent:/path/to/ap-agent.jar -jar app.jar
# потом:
curl "http://localhost:8080/profiler/profile?event=cpu&duration=60&output=flamegraph" -o flame.html

Главное, что отличает async‑profiler от других профайлеров, — возможность увидеть полную картину: Java‑методы, нативный код, системные вызовы на одном графе. Когда у сервиса latency непонятного происхождения, async‑profiler даёт первый ответ за минуту: где висит время, какие методы горячие, есть ли lock contention. Дальше уже копать конкретные слои — JDBC, HTTP‑клиенты, GC, JIT.

Если тема производительности Java‑приложений вам близка, присмотритесь к курсу «Java разработчик. Продвинутый уровень». На курсе рассмотрим инструменты и подходы, которые нужны Java‑разработчику для работы со сложными production‑системами: многопоточность, производительность, современные возможности JVM и архитектурные решения для высоконагруженных приложений.

Перед стартом можно пройти бесплатный вступительный тест — он поможет оценить текущий уровень и понять, насколько программа подходит под ваш опыт.

Также приходите на бесплатный открытый урок:

На уроке поговорим о виртуальных потоках в Java 21: как они упрощают разработку высоконагруженных приложений, помогают писать масштабируемый код и снижают сложность управления потоками. Это хорошая возможность познакомиться с преподавателем‑практиком, протестировать формат обучения и задать вопросы по теме.

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