Представьте, сервис на Java стал медленнее или нестабильнее после релиза. Какие language/runtime-specific причины вы проверите?
Проверяют JIT-прогрев, давление на GC (heap dump + GC-логи), thread contention (jstack/jcmd), изменение флагов JVM, рост Metaspace и регрессии в зависимостях.
Диагностика деградации производительности Java-сервиса после релиза
Когда сервис начинает тормозить или нестабильно работать именно после релиза, первым делом важно отделить регрессию в коде от изменения нагрузки. Ниже — систематический список Java/JVM-специфичных причин, которые стоит проверить в первую очередь.
1. JIT-компиляция и прогрев (Warm-up)
После перезапуска JVM начинает интерпретировать байткод и только постепенно JIT-компилирует горячие методы. Если метрики сняты сразу после деплоя, они покажут деградацию, которая исчезнет через 5–15 минут. Проверяйте графики с разбивкой по времени жизни пода.
Если прогрев стал длиннее, чем раньше, — возможно, в новом коде появились новые горячие пути или методы стали полиморфными (один интерфейс — много реализаций), что мешает инлайнингу.
2. Утечка памяти и давление на GC
Самая частая причина — новый код создаёт больше объектов или удерживает ссылки дольше, чем нужно. Симптомы: растущий heap, учащающиеся Full GC, длинные паузы STW (Stop-The-World).
Диагностика:
- Включить GC-логирование:
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=20m - Снять heap dump:
jcmd <pid> GC.heap_dump /tmp/heap.hprofи открыть в Eclipse MAT или VisualVM - Проверить метрику
jvm_gc_pause_secondsв Prometheus
3. Изменение политики или алгоритма GC
Если в новом релизе обновили версию JDK (например, с 17 на 21), или изменили флаги запуска, алгоритм GC мог поменяться. G1 стал дефолтом с Java 9, ZGC и Shenandoah — опциональные низколатентные коллекторы. Убедитесь, что флаги в docker-образе совпадают с желаемыми:
java -XX:+UseZGC -Xms512m -Xmx2g -jar app.jar
# или для G1
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jar
4. Блокировки и contention
Новый код мог ввести синхронизацию там, где её раньше не было: synchronized-блок, ReentrantLock, или неосторожное использование Collections.synchronizedMap вместо ConcurrentHashMap. Под нагрузкой это превращается в узкое горло.
Диагностика через thread dump:
jcmd <pid> Thread.print > /tmp/threads.txt
# или через jstack
jstack <pid> | grep -A5 "BLOCKED"
В Prometheus смотрите jvm_threads_states_threads{state="blocked"}.
5. ClassLoader и метаспейс
Активное использование рефлексии, Proxy, Groovy/scripting или фреймворков, генерирующих классы в runtime (например, Hibernate Proxy), может привести к росту Metaspace. Если Metaspace переполняется, JVM выполняет Full GC для его очистки.
Симптом: OutOfMemoryError: Metaspace или резкие Full GC при стабильном heap.
java -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -jar app.jar
6. Компиляция с другими флагами оптимизации
Убедитесь, что production-сборка не компилируется с -g (debug info) или без -O. Также проверьте, не включён ли агент наподобие Jacoco или AspectJ в production-образе — они серьёзно замедляют выполнение.
7. Проблемы с профилем загрузки классов (CDS / AppCDS)
Если проект использует Class Data Sharing (-Xshare:on), устаревший archive после релиза может вызывать падения или игнорироваться JVM, лишая выигрыша в старте.
8. Регрессия в зависимостях
Обновлённая версия библиотеки (Jackson, Hibernate, Netty) может иметь регрессию производительности. Сравните pom.xml или build.gradle между релизами. Используйте mvn dependency:tree для проверки транзитивных зависимостей.
Практический чеклист диагностики
- Сравнить GC-логи до и после релиза: частота Full GC, паузы STW
- Снять профиль CPU через async-profiler:
./profiler.sh -d 30 -f /tmp/profile.html <pid> - Сравнить thread dumps под нагрузкой: искать BLOCKED/WAITING потоки
- Проверить heap dump: топ объектов по retained size
- Сверить JVM-флаги в новом и старом образе
- Проверить агенты и javaagent-параметры в Dockerfile/Helm
Подводные камни
- Снятие метрик сразу после старта даёт ложную деградацию из-за отсутствия JIT-прогрева — дождитесь нескольких минут стабильной нагрузки.
- Heap dump под нагрузкой временно останавливает JVM — не снимайте его на единственном экземпляре в prod.
- async-profiler требует
perf_eventsили-XX:+PreserveFramePointer— без этого CPU-профиль будет неполным. - Увеличение MaxMetaspaceSize без ограничения может привести к OOM на уровне ОС, а не JVM.
- Переход с Serial/Parallel GC на G1 при той же настройке heap может увеличить паузы в случае небольшого heap (<1GB).
- Synchronized-блок на
this— распространённая ловушка: если объект используется в разных контекстах, блокировка распространяется шире, чем ожидалось. - Transitive dependency bump (например, logback через Spring) может незаметно изменить поведение сериализации/логирования.
- AppCDS archive привязан к конкретному JAR-файлу; при смене артефакта его нужно перегенерировать.
What hurts your answer
- Сразу обвинять Java, не проверив соседние слои системы
- Чинить симптом без минимального воспроизведения и evidence
- Не учитывать версии, конфигурацию, окружение и recent changes
What they're listening for
- Умеет локализовать проблему вокруг Java
- Двигается от симптома к гипотезам и проверкам
- Отличает баг инструмента от ошибки использования или окружения