Профилирование Java-приложений: от HeapDump до Grafana
Добрый день дорогой читатель. Сегодня я дам тебе несколько советов для поиска и анализа проблем в твоем Java приложении. Мы разберем такие вещи как: HeapDump, ThreadDump, VisualVM, Grafana и Prometheus.
Повествование будет строиться следующим образом: Я буду описывать потенциальные проблемные сценарии (use-case’ы) и шаги, которые необходимо предпринять для локализации и решения проблемы.
Первый Use Case: У вас горит CPU
Представьте ситуацию: вы спите ночью, и вдруг вам звонит ПМ, судорожно крича: “Ержан, вставай, у тебя прод запросы по 100 секунд обрабатываются!” Вскакивая в холодном поту, вы стремглав бежите к своему ноутбуку. Дрожащими от страха и стресса руками вы вводите пароль от своей доменной учетной записи и заходите в систему. Первое, что вам нужно сделать, — это проверить графики в вашей observability-платформе (Grafana, Sage и т.д.). После открытия графиков, обратите внимание на метрики JVM и CPU, так как на практике приложение чаще всего испытывает проблемы либо из-за перегрузки процессора, либо из-за нехватки памяти. В редких случаях может страдать ваш GPU, особенно если ваш проект связан с искусственным интеллектом (A.I.).
Вы открыли ваши графики и “О Ужас!” — CPU работает практически на 100%. Что же делать? Главное в таких ситуациях — не паниковать и начать применять Метод Сократа. Начните задавать себе вопросы:
Когда была замечена проблема?
Это происходит постоянно или в определенные моменты времени?
Все ли сервисы загружают CPU, или проблема в конкретном компоненте/сервисе?
Какие потоки/трейды в приложении занимают больше всего процессорного времени?
Были ли в приложении внедрены последние изменения перед началом проблемы?
Если были, что именно изменилось?
Есть ли в приложении места, которые часто приводят к высокой загрузке CPU (например, сложные вычисления или многопоточные операции)?
Какие запросы или операции чаще всего вызываются в моменты повышенной нагрузки?
Каждый кейс уникален и требует такого же уникального подхода. Но, как подсказывает мой опыт, зачастую нагрузки на CPU вызваны двумя основными источниками:
1. Проблемы, связанные с потоками.
2. Проблемы, связанные с Heap.
Окей, мы проверили наш Heap, и там всё в порядке. Теперь идём проверять потоки. Если вы используете Prometheus (а вы точно его используете, I hope so), обратите внимание на три основных показателя: JVM Thread Live, JVM Thread States, и JVM Threads Daemon.
JVM Thread Live — показывает общее количество активных (живых) потоков в JVM. Этот показатель помогает понять, сколько потоков активно работает в данный момент времени. Если число живых потоков резко возросло, это может указывать на утечку потоков или на чрезмерное создание новых потоков, что может перегружать CPU.
JVM Thread States — отображает состояние всех потоков в JVM (например, “RUNNABLE”, “BLOCKED”, “WAITING”). Это ключевой показатель для понимания того, где и как именно потоки тратят процессорное время. Например, если много потоков находятся в состоянии “RUNNABLE”, это может указывать на конкуренцию за CPU.
JVM Threads Daemon — показывает количество потоков-демонов в JVM. Потоки-демоны — это потоки, которые работают на заднем плане и завершаются автоматически при завершении работы всех обычных (non-daemon) потоков. Высокое количество потоков-демонов может указывать на некорректное управление потоками или на проблему с фоновыми задачами.
Супер (нет), вы видите скачок в JVM Thread Live и понимаете, что проблема связана с потоками. Но как теперь получить актуальные данные по потокам? Если до этого момента вы ни разу не благословляли JDK, то самое время это сделать. JDK полон инструментов, и один из них — это jstack. Jstack позволяет вам снять ThreadDump.
ThreadDump — это снимок состояния всех потоков, работающих в JVM, на момент выполнения команды. В ThreadDump содержится информация о том, какие потоки активны, в каком состоянии они находятся (например, “RUNNABLE”, “BLOCKED”, “WAITING”), какие методы они выполняют, и каковы их стеки вызовов. Это один из самых мощных инструментов для диагностики проблем с многопоточностью, позволяющий определить, какие потоки могут блокировать выполнение программы, где происходят задержки, и какие операции занимают слишком много процессорного времени.
После получения ThreadDump, вы сможете проанализировать его и найти те потоки, которые могут быть причиной проблемы. Это поможет локализовать источник перегрузки CPU и разработать план по устранению проблемы.
Для получения ThreadDump следуйте следующего алгоритма действий:
Получите PID процесса, как пример используйте: lsof -i <port>
Используйте PID для получения thread dump: jstack <PID> > threaddump.txt
Перенесите thread dump на локальную машину, как пример используйте: kubectl cp.
В итоге, вы получите файл ThreadDump, который сможете просмотреть в любом удобном для вас GUI. Лично я рекомендую использовать FastThread GUI для анализа ThreadDump. Этот инструмент предоставляет удобный интерфейс для работы с данными о потоках, позволяет легко выявить проблемные места и значительно упрощает диагностику многопоточных проблем.
Второй Use Case: Ваш Heap переполнился
Ситуация примерно та же самая. Вы спокойно сидите, никого не трогаете, и вдруг вам на почту падает 10 алертов из Grafana: “Achtung, Achtung! Heap память переполнена, ваше приложение падает с ошибкой Out Of Memory.”
Начинаем действовать по аналогичному алгоритму и применяем метод Сократа. Начните ваш анализ с следующих вопросов:
Когда была замечена проблема?
Сталкивались ли вы с подобной проблемой ранее?
Какое поведение приложения наблюдается при достижении 100% использования Heap?
Приводит ли это к частым запускам сборщика мусора (GC) или длительным паузам на сборку мусора?
Произошли ли недавно изменения в коде или конфигурации приложения?
Были ли добавлены новые библиотеки или зависимости?
Проводился ли анализ Heap (Heap dump)?
Какие классы или объекты занимают наибольшую часть памяти?
Какие объекты создаются в большом количестве?
Как настроен сборщик мусора (GC)?
После того как вы сформировали пул вопросов, бегите анализировать Grafana. В ней обратите внимание на такие показатели, как: JVM Heap Memory Usage, JVM GC Live Data, JVM GC Time.
JVM Heap Memory Usage — этот показатель отображает текущее использование памяти в куче (Heap) JVM. Он показывает, сколько памяти занято объектами, которые все еще используются, и сколько памяти свободно. Если этот показатель постоянно близок к максимальному значению, это может указывать на утечку памяти или чрезмерное создание объектов, что может привести к ошибке Out Of Memory.
JVM GC Live Data — этот показатель отражает объем данных, который остается в куче после завершения работы сборщика мусора (Garbage Collector). Высокое значение может указывать на то, что сборщик мусора не успевает освобождать память, и объекты продолжают накапливаться в куче.
JVM GC Time — этот показатель показывает, сколько времени JVM тратит на сборку мусора. Если время, затрачиваемое на GC, слишком велико, это может означать, что сборщик мусора работает слишком часто или слишком долго, что негативно сказывается на производительности приложения.
Теперь, если вы видите, что проблема действительно связана с Heap, пришло время воспользоваться еще одним мощным инструментом — HeapDump.
HeapDump — это снимок памяти кучи JVM, который содержит информацию о всех объектах, находящихся в памяти на момент снятия дампа. В нем хранится информация о типах объектов, их размерах, связях между ними и т.д. Анализ HeapDump помогает выявить утечки памяти, найти объекты, которые занимают слишком много места, и понять, какие именно данные остаются в памяти, когда они уже не нужны.
Для получения HeapDump следуйте следующему алгоритму действий:
Получите PID процесса. Для этого используйте команду: lsof -i <port>
Используйте PID для получения HeapDump. Введите команду: jmap -dump:live,format=b,file=heapdump.hprof <PID>
Перенесите HeapDump на локальную машину. Для этого, например, используйте команду: kubectl cp /:/path/to/heapdump.hprof /local/path/
После получения файла, его нужно проанализировать. Для этого лучше всего подойдут два приложения: VisualVM и MemoryAnalyzer от Eclipse. Больше всего я использую MemoryAnalyzer, так как для меня он самый удобный и функциональный. Эти инструменты помогут вам разобраться в структуре кучи, выявить утечки памяти и другие проблемы, связанные с использованием памяти в вашем приложении.
Третий Use-Case Коннекты в Базу Данных
Ситуация может выглядеть следующим образом: ваше приложение неожиданно начинает работать медленно, запросы в базу данных выполняются очень долго, или появляются ошибки, связанные с исчерпанием ресурсов для подключений к базе данных. В таком случае важно вовремя диагностировать проблему и принять меры.
Как и в предыдущих случаях, начнем с анализа с помощью метода Сократа. Задайте себе следующие вопросы:
Когда была замечена проблема?
Происходит ли это постоянно или в определенные моменты времени?
Зависит ли это от нагрузки на приложение (пиковые часы)?
Все ли запросы в базу данных работают медленно, или проблема связана с конкретными запросами/таблицами?
Какие изменения были внесены в код или конфигурацию базы данных перед началом проблемы?
Были ли изменены параметры пула подключений (например, размер пула)?
Есть ли признаки утечек подключений, когда они остаются открытыми и не возвращаются в пул?
Сколько активных подключений используется в каждый момент времени? Превышает ли это допустимый лимит?
После того как вы сформировали пул вопросов, перейдем к анализу данных в Grafana. Обратите внимание на следующие показатели:
1. DB Connection Pool Usage — этот график отображает, сколько подключений к базе данных используется в текущий момент и сколько доступно. Если использование пула подключений постоянно близко к максимальному, это может указывать на то, что пул слишком мал для текущей нагрузки, или что запросы не закрываются корректно, что приводит к утечке подключений.
2. DB Connection Wait Time — показывает время ожидания подключения из пула. Если это время начинает увеличиваться, это сигнализирует о том, что пул подключений не справляется с нагрузкой, и приложение вынуждено ждать освобождения подключений.
3. DB Query Time — показывает время выполнения запросов к базе данных. Если оно резко увеличивается, это может указывать на перегрузку базы данных, неэффективные запросы или проблемы с индексами.
РОграничение потоков с помощью HikariCP
Если вы обнаружили, что проблема связана с исчерпанием ресурсов пула подключений, одним из эффективных решений может быть настройка и ограничение потоков с помощью HikariCP — одного из наиболее популярных пулов подключений для Java приложений.
HikariCP предоставляет множество настроек, которые помогут вам контролировать и оптимизировать работу с базой данных. Вот несколько важных параметров:
maximumPoolSize — максимальное количество подключений в пуле.
minimumIdle — минимальное количество свободных (неактивных) подключений, которые должны поддерживаться в пуле.
connectionTimeout — время ожидания подключения из пула.
idleTimeout — время, в течение которого неактивное подключение может оставаться в пуле перед тем, как оно будет закрыто.
maxLifetime — максимальное время жизни подключения.