Стандартный набор инструментов для профилирования 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.
Возможные находки:
Spring AOP‑прокси создают много объектов. Аннотации
@Transactional,@Cacheable,@Asyncоборачиваются в CGLIB или JDK‑прокси, и каждый вызов создаёт массив аргументов для invocation handler. Если метод вызывается миллионы раз и подгребает много объектов, имеет смысл вынести логику в подкласс без аннотаций для горячего пути, или вызывать через явныйTransactionTemplateвместо@Transactional.String concatenation в горячих циклах. В Java 9+ через invokedynamic есть compile‑time оптимизация для оператора
+, но если на flame graph видны промежуточныеStringBuilder.append, это часто означает, что строка собирается в цикле, и каждая итерация аллоцирует промежуточный массивchar[]. Решение — заранее создатьStringBuilderнужного размера или использоватьString.joinдля коллекций.Boxing на горячих путях.
Integer.valueOfилиLong.valueOfсоздают объекты‑обёртки. Часто видно в коде, который используетMap<String, Integer>для счётчиков — каждоеmap.put(key, count + 1)означает auto‑boxing. Решение —LongAdderдля счётчиков или примитивные коллекции из Eclipse Collections / fastutil.Реактивные стримы и WebFlux. На flame graph часто видны
MonoFlatMapиFluxConcatMap— это аллокации операторов на каждый запрос. Это нормально для реактивной модели, но если их сильно много и они дают серьёзный GC pressure, имеет смысл подумать о переходе на виртуальные потоки в Spring Boot 4, там тот же блокирующий код пишется без аллокаций реактивных операторов.Сериализация в 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 и архитектурные решения для высоконагруженных приложений.
Перед стартом можно пройти бесплатный вступительный тест — он поможет оценить текущий уровень и понять, насколько программа подходит под ваш опыт.
Также приходите на бесплатный открытый урок:
17 июня в 20:00. «Java 21: виртуальные потоки — новый уровень многопоточности».
На уроке поговорим о виртуальных потоках в Java 21: как они упрощают разработку высоконагруженных приложений, помогают писать масштабируемый код и снижают сложность управления потоками. Это хорошая возможность познакомиться с преподавателем‑практиком, протестировать формат обучения и задать вопросы по теме.