Есть класс проблем, который проходит мимо всей остальной наблюдаемости. Тесты зелёные, логи чистые, ошибок нет — а сервис через три дня после выката перезапускается сам. Или отвечает всё медленнее, хотя запросов столько же.
Метрики этот случай видят, но объясняют плохо. График памяти показывает, что она растёт; график задержки — что стало медленнее. На вопрос кто именно держит память и где именно тратится процессор метрика не отвечает по своей природе: она агрегат, в ней нет ни объектов, ни строк кода.
Отвечают на него два инструмента — снимок памяти и запись профилировщика. Про них и статья.
Обе половины истории — как утечка выглядит на графике и во что обходится ответ на вопрос «кто держит» — умещаются в одну картинку.
Признак утечки — не высокий пик, а нижняя точка после сборки мусора: подъёмы одинаковые, но возвращается 3 ГБ вместо 4, и за трое суток низ уезжает с 2 до 5 ГБ. Гистограмма классов стоит одной сборки мусора, снимок кучи — паузы подольше и файла размером с живую часть кучи.
Как отличить утечку от нормальной работы
Прежде чем что-то снимать, полезно убедиться, что проблема вообще есть. Растущий график памяти сам по себе ни о чём не говорит: JVM берёт память у операционной системы охотно и отдаёт неохотно, так что «занято много» — обычное состояние здорового сервиса.
Смотреть надо на другое — на уровень, до которого память падает после сборки мусора. Здоровый сервис даёт картинку в форме пилы: память растёт, приходит сборщик, память падает примерно к одному и тому же уровню, и так по кругу. Утечка выглядит как та же пила, у которой нижние зубцы медленно ползут вверх: после каждой уборки остаётся чуть больше, чем в прошлый раз.
Второй признак — поведение сборщика. При утечке он работает всё чаще и всё дольше, а толку всё меньше: доля времени, потраченного на сборку, растёт. В какой-то момент JVM сдаётся и падает с OutOfMemoryError, но до этого сервис успевает долго и мучительно тормозить.
Посмотреть текущее состояние кучи можно, ничего не устанавливая, — утилитой jcmd, которая идёт в комплекте с JDK:
jcmd <pid> GC.heap_info
Она печатает размер кучи, сколько занято, и отдельно — Metaspace, куда JVM складывает метаданные классов. Про Metaspace вспоминают редко, а он утекает в приложениях, которые много генерируют классов на лету.
Первый взгляд: кто занимает память
Прежде чем снимать полный дамп, стоит посмотреть на гистограмму классов. Она заметно дешевле и часто сразу отвечает на вопрос:
jcmd <pid> GC.class_histogram
Вывод — список классов, отсортированный по занимаемым байтам:
num #instances #bytes class name
1: 8620094 7935600288 [B
2: 470564 488822744 [F
3: 8344625 200271000 java.lang.String
4: 952060 83781280 java.lang.reflect.Method
Читается это так: [B — массивы байтов, [F — массивы чисел с плавающей точкой. Восемь миллионов массивов байтов на восемь гигабайт — это не «Java прожорливая», это конкретные данные, которые кто-то держит в памяти.
Одна оговорка про цену. «Дешевле» не значит «бесплатно»: чтобы в списке оказались только живые объекты, JVM перед подсчётом делает полную сборку мусора, а на куче в девять гигабайт это пауза в секунды, и приложение в это время стоит. Если паузу позволить нельзя, добавьте -all — тогда сборки не будет, но в список попадут и объекты, которые уже никто не держит.
Гистограмма показывает что лежит, но не кто держит. Строк в памяти всегда много, и сами по себе они ни в чём не виноваты. Чтобы найти держателя, нужен полный снимок.
Снимок памяти: как снять и не уронить сервис
Полный снимок кучи — это файл со всеми объектами и ссылками между ними:
jcmd <pid> GC.heap_dump /var/tmp/heap.hprof
Здесь важно понимать цену. По умолчанию JVM и тут сначала делает полную сборку и пишет только живые объекты — значит, размер файла равен не «занято сейчас», а нижней точке пилы. На здоровом сервисе разница бывает в разы: занято восемь гигабайт, а в файле два. На том, который течёт, живого как раз много — потому он и течёт, — и файл выходит размером почти со всю кучу, девять гигабайт из нашего примера. Если нужен снимок ровно того, что в памяти сейчас, вместе с мусором, есть опция -all. Пока снимок пишется, приложение остановлено — не «немного подтормаживает», а стоит целиком. На большой куче это десятки секунд, за которые оркестратор успеет решить, что сервис умер, и перезапустить его.
Пауза на запись снимка длиннее, чем терпит проверка живости: смотрите на пятнадцатую секунду, где порог таймаутов пробит и сервис перезапускают до конца записи.
Отсюда правила, которые стоит соблюдать, а не узнавать на практике:
Снимайте с одного экземпляра, а не со всех сразу, и лучше с того, который выведен из-под нагрузки.
Проверьте, что на диске есть место — иначе получите сервис без места под журналы.
Увеличьте на время порог проверки живости. Сама проверка лёгкая и ни от чего внешнего не зависит, но пока пишется снимок, стоит весь процесс: HTTP не отвечает вовсе, зонд получает таймаут — и оркестратор убивает сервис ровно посреди снятия.
И помните, что в дампе лежат реальные данные пользователей: токены, телефоны, содержимое запросов. Это файл с персональными данными, и обращаться с ним нужно соответственно — не класть в общую папку и не отправлять в переписку.
Есть и способ получить снимок автоматически ровно в момент падения:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/tmp
Флаг стоит поставить заранее на всех сервисах. Первое падение по памяти обычно происходит ночью, и снимок, снятый автоматически, экономит неделю ожидания следующего раза.
В контейнерах у флага есть важная оговорка. Срабатывает он тогда, когда нехватку памяти признала сама JVM, то есть на OutOfMemoryError. А в Kubernetes процесс чаще убивает не JVM, а ядро — по лимиту памяти контейнера; в событиях пода это видно как OOMKilled. Сигнал приходит снаружи и мгновенно, никакого снимка написаться не успевает, и диагностировать нечего. Чтобы у флага был шанс, лимит контейнера должен быть заметно больше -Xmx: кроме кучи процессу нужны метаданные классов, стеки потоков и буферы вне кучи, и именно на этом запасе всё обычно и ломается.
Смотрят снимок в отдельном инструменте — Eclipse MAT, VisualVM или профилировщике из IDE. Место VisualVM — на вашем ноутбуке, над снятым файлом или над локально запущенным сервисом; к проду его не подключают, там работают jcmd и JFR. Ключевое, что в них ищут, — не «сколько объектов», а кто их удерживает: цепочка ссылок от корня до объекта. Именно она отвечает на вопрос «почему это не собирается».
Совет «искать цепочку ссылок» звучит безадресно, пока не знаешь, что нажимать. В Eclipse MAT порядок такой, и он занимает минуты:
- Leak Suspects — отчёт, который MAT предлагает сразу при открытии файла. В половине случаев этого достаточно: он сам находит объект, удерживающий большую долю кучи, и печатает цепочку до него. Начинать стоит отсюда, а не с ручного разглядывания.
- Dominator tree — главный экран. Он показывает объекты, отсортированные по удерживаемому размеру: сколько памяти освободится, если этот объект исчезнет. Разница с обычной гистограммой принципиальна: сам объект может весить сто байт, а удерживать два гигабайта — и в гистограмме вы его не увидите никогда, потому что там он мелкий.
- Path to GC Roots — на найденном объекте: цепочка ссылок от корня (статическое поле, стек потока, загрузчик классов) до него. Отвечает на вопрос «почему не собирается». Смотреть надо вариант, исключающий слабые и мягкие ссылки: если держит только слабая, объект соберётся сам и виноват не он.
- Retained set и группировка по классам на подозреваемом: чем именно заполнена коллекция, какие ключи — и здесь обычно и становится видно, что ключом стал идентификатор запроса.
Два слова про терминологию, без которой экраны не читаются: shallow size — размер самого объекта, retained size — размер всего, что освободится вместе с ним. Искать надо по второму. Разбор этих экранов по шагам — в отдельной статье про дампы потоков и кучи.
Пять утечек, которые встречаются чаще остальных
Полезно знать типовые случаи: в девяти расследованиях из десяти находится один из них.
Кэш без ограничения. Обычная HashMap, в которую складывают результаты «чтобы быстрее». Пока ключей мало, всё хорошо; когда ключом становится идентификатор пользователя или запроса, карта растёт вечно. Лечится кэшем с ограничением по размеру и времени жизни.
Выглядит это как совершенно нормальный код — и в этом вся сложность темы:
@Component
public class PriceCache {
private final Map<String, Price> cache = new ConcurrentHashMap<>();
public Price get(String sku, Customer customer) {
var key = sku + ":" + customer.id(); // ключ уникален на пару
return cache.computeIfAbsent(key, k -> priceService.calculate(sku, customer));
}
}
Ревью этот код проходит легко: потокобезопасная карта, вычисление по требованию, ничего подозрительного. Утечка спрятана в ключе: sku — конечное множество, а customer.id() — нет, и карта растёт со скоростью новых покупателей, никогда ничего не удаляя. Через месяц работы это гигабайты, а в снимке кучи будет видно одну карту, удерживающую всё.
Лечится не «не кешировать», а ограничением, и оно всегда явное:
private final Cache<String, Price> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
Три строки здесь и есть решение: ограничение по числу записей (вытесняется самое ненужное), срок жизни (данные не устаревают навсегда) и учёт статистики, чтобы потом увидеть в метриках, помогает ли кеш вообще. Правило на будущее: любая коллекция, живущая дольше запроса, обязана иметь ограничение, и если его нет — это не кеш, а утечка с отложенным сроком.
ThreadLocal в пуле потоков. Поток в пуле не умирает после запроса, а возвращается в пул вместе со всем, что в нём оставили. Если значение положили и не убрали, оно живёт до конца жизни приложения — и таких значений столько, сколько потоков. Ровно поэтому в статье про проброс контекста очистка стоит в блоке finally.
Подписки и слушатели, которые никто не отменяет. Компонент подписался на события, потом был пересоздан — а подписка осталась и держит ссылку на старый экземпляр. Со временем в памяти живут десятки поколений одного и того же объекта.
Статические коллекции. Любое static поле живёт столько же, сколько приложение. Складывать туда что-то в рантайме — почти гарантированная утечка, потому что удалять оттуда обычно забывают.
Незакрытые ресурсы. Соединения, потоки, курсоры. Утечка тут двойная: держится и память, и ограниченный ресурс на той стороне — и второе обычно бьёт раньше.
Две утечки, которых не видно в куче
График кучи ровный, снимок кучи чистый, а процесс всё равно растёт и в итоге погибает по лимиту контейнера. Значит, память утекает не в куче, и снимок кучи её не покажет по определению. Два места, где это бывает.
Метапространство: утечка классов. Здесь JVM хранит метаданные классов, и они собираются только вместе со своим загрузчиком классов. Утекает это в приложениях, которые создают классы во время работы: генерация прокси, компиляция выражений или шаблонов на лету, скрипты, динамические сериализаторы, библиотеки, создающие класс на каждый тип запроса. Каждый сгенерированный класс живёт, пока жив его загрузчик, а загрузчик держится случайной ссылкой — и метапространство растёт при совершенно спокойной куче.
Как увидеть: jcmd <pid> GC.heap_info печатает метапространство отдельно, метрика jvm_memory_used{area="nonheap"} показывает его же на графике. Признак утечки тот же, что у кучи, — нижняя точка ползёт вверх. Подтверждается это включением журнала загрузки классов: если число загруженных классов растёт линейно со временем работы, вопрос закрыт.
-Xlog:class+unload=info # кто выгружается
jcmd <pid> VM.classloader_stats
Лечится это в коде (кешировать сгенерированное, а не создавать на каждый запрос), а не увеличением размера: предел метапространства не ограничен по умолчанию, поэтому расти он будет до лимита контейнера. Кстати, ограничение стоит поставить (-XX:MaxMetaspaceSize): тогда вы получите понятную ошибку нехватки метапространства с дампом вместо необъяснимого убийства процесса снаружи.
Прямые буферы. Буферы вне кучи, которыми пользуются сетевые библиотеки, файловый ввод-вывод и драйверы: сама область памяти выделена операционной системой, а в куче лежит лишь небольшой объект-держатель. Освобождается она, когда держателя соберёт сборщик мусора, — то есть не сразу и не предсказуемо. Отсюда типичная картина: куча наполовину свободна, сборки редки, держатели не собираются, буферы копятся, процесс убит по лимиту.
Как увидеть: метрика jvm_buffer_memory_used{id="direct"} и учёт памяти вне кучи через NMT. Что делать: поставить предел (-XX:MaxDirectMemorySize) — тогда вместо убийства снаружи получите ошибку внутри JVM, которую видно в журнале; проверить, что библиотека буферы переиспользует, а не выделяет на запрос (у сетевых библиотек это настройка пула буферов); и убедиться, что код, который берёт буфер напрямую, его освобождает, а не надеется на сборщик.
Общее правило для обоих случаев: если куча спокойна, а процесс растёт, вопрос решается не снимком кучи, а учётом памяти вне кучи (-XX:NativeMemoryTracking=summary плюс jcmd <pid> VM.native_memory summary), который называет категорию — классы, потоки, буферы, код — и сразу сужает поиск до одной из них. Подробный расчёт лимитов контейнера — в разделе «Глубже» ниже.
Профилирование процессора: где тратится время
Вторая половина темы — не память, а скорость. Метрика говорит, что метод стал медленнее, но не говорит, на чём именно.
Здесь работает выборочное профилирование: профилировщик много раз в секунду смотрит, какой код выполняется прямо сейчас, и складывает статистику. Метод, который попадается в большинстве снимков, и есть тот, где проводится время. Накладные расходы у такого подхода небольшие, поэтому его допустимо включать в продакшене — в отличие от подробной трассировки каждого вызова, которая замедляет приложение в разы.
В JDK встроен Flight Recorder, для которого ничего ставить не нужно:
jcmd <pid> JFR.start name=probe settings=profile duration=60s filename=/var/tmp/probe.jfr
jcmd <pid> JFR.check
Через минуту получится файл, который открывается в JDK Mission Control. Настройка settings=profile пишет заметно больше событий, чем settings=default: минутная запись — это от пары мегабайт на скромном сервисе до десятков на нагруженном. Немного, но место под файл лучше проверить заранее — как и перед снимком кучи.
Смотреть в записи стоит четыре вещи: какие методы чаще всего оказываются на процессоре; кто больше всех выделяет память (частые короткие аллокации — типичная причина частых сборок мусора); сколько времени ушло на сборку мусора; и где потоки простаивают в блокировках.
Отдельный инструмент, который стоит знать, — async-profiler. Он даёт более честную картину, чем встроенные средства, потому что видит и нативный код, и умеет строить диаграмму-«пламя»: широкая полоса на такой диаграмме — это метод, в котором проведено много времени. Ставится отдельно, подключается к работающему процессу.
«Более честная картина» — утверждение, у которого есть конкретная причина, и её стоит знать, чтобы понимать границы обоих инструментов. Профилировщики, работающие через стандартный интерфейс JVM, умеют снимать стек потока только в безопасной точке — месте, где JVM гарантирует согласованное состояние (вызов метода, проверка в цикле). Если поток в этот момент находится там, где безопасной точки нет (длинный цикл по массиву, который компилятор оптимизировал, нативный вызов), профилировщик подождёт, пока поток до неё дойдёт, и запишет ту точку, а не настоящую. Получается систематический перекос: время приписывается не тем методам, причём стабильно, так что ошибку не видно — картинка выглядит правдоподобной и указывает мимо.
async-profiler снимает стек по сигналу от операционной системы в любой момент, без ожидания безопасной точки, и поэтому перекоса не имеет. Отсюда практическое правило: расхождение между «метрика говорит, что медленно здесь» и «профилировщик показывает другое место» — повод перепроверить вторым инструментом, а не поверить первому. И второй его плюс из той же природы: он видит стек нативного кода, то есть покажет время, проведённое в библиотеке сжатия или в драйвере, которое встроенные средства показывают как «неизвестно где».
Ещё две команды из того же набора пригодятся раньше профилировщика. Дамп потоков показывает, чем заняты все потоки в данную секунду, — незаменим, когда сервис «завис» и не отвечает:
jcmd <pid> Thread.print
Как снять его в Kubernetes, что на самом деле значат состояния потоков, как по дампу увидеть дедлок, исчерпанный пул и виртуальные потоки — в отдельной статье Thread dump и heap dump; там же по шагам разобран дамп кучи в Eclipse MAT.
А если подозрение падает на память вне кучи — буферы, нативные библиотеки, — её учёт включается флагом при старте, иначе команда честно скажет, что учёт выключен:
-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary
Как поймать утечку до продакшена
Всё выше — про расследование на живом сервисе. Половину таких историй можно закрыть раньше, и это стоит дешевле, чем ночной разбор.
Тест на утечку. Идея простая: выполнить операцию много раз и проверить, что память возвращается к исходному уровню. Работает это не на любом коде, а на том, где подозрение есть, — кеш, реестр слушателей, пул:
@Test
void repeatedCallsDoNotGrowCache() {
var runtime = Runtime.getRuntime();
for (int i = 0; i < 1_000; i++) {
service.priceFor("SKU-1", customer("customer-" + i)); // разные покупатели
}
System.gc(); // подсказка, не гарантия
long after = runtime.totalMemory() - runtime.freeMemory();
assertThat(cacheSize()).isLessThanOrEqualTo(10_000);
assertThat(after).isLessThan(200 * 1024 * 1024);
}
Честно про границы этого теста: измерять память в тесте ненадёжно (сборщик работает когда хочет), поэтому проверять лучше наблюдаемое свойство — размер коллекции, число зарегистрированных слушателей, число открытых соединений, — а память оставить как грубую страховку. Такой тест ловит именно тот класс дефекта, который потом стоит ночи: ключ, растущий без границ.
Профиль выделения памяти в нагрузочном тесте. Второй приём и, пожалуй, самый полезный: во время нагрузочного прогона записать Flight Recorder на несколько минут и посмотреть не процессор, а выделения памяти: какие типы объектов создаются чаще всего и из какого кода. Здесь обычно и обнаруживается, что на каждый запрос создаётся большой временной массив, лишняя копия строки или объект, который можно переиспользовать. Это не утечка, но это причина частых сборок мусора — то есть тех самых пауз, которые потом видны в хвосте задержки.
jcmd <pid> JFR.start settings=profile duration=5m filename=/var/tmp/load.jfr
Долгий прогон. Утечка по определению видна только со временем: короткий тест её не покажет. Поэтому перед крупным выпуском гоняют тест на выносливость — умеренную нагрузку часами — и смотрят ровно один график: нижнюю точку кучи после сборки. Ползёт вверх за четыре часа — будет ползти и в проде, только медленнее и до аварии.
Что поставить заранее, один раз. Флаг снятия дампа при нехватке памяти, ограничение метапространства и прямой памяти, журнал сборщика мусора, метрики памяти на дашборде. Ни одна из этих настроек не требует работы после установки, а вместе они превращают «сервис умер ночью, непонятно почему» в «есть дамп и журнал, разберём утром».
Порядок действий, когда «сервис ест память»
Чтобы не метаться, полезно держать в голове последовательность.
Сначала убедитесь по графику, что нижняя точка после сборки мусора действительно ползёт вверх, — иначе искать нечего. Затем посмотрите гистограмму классов: она дешевле снимка — цена одна сборка мусора — и иногда сразу называет виновника. Если ответа нет, снимите снимок кучи — с одного экземпляра, помня про размер файла и остановку приложения. В снимке ищите не крупные объекты, а того, кто их держит.
Когда виновник найден, проверьте его по списку типовых утечек выше: почти всегда это кэш, ThreadLocal или подписка. И поставьте -XX:+HeapDumpOnOutOfMemoryError, если его ещё нет, — чтобы в следующий раз снимок появился сам.
Глубже: сборка мусора и память контейнера: читать журнал GC и различать два OOMрасширенное
Утечка, о которой статья, это медленный рост. Есть вторая история про память, которая выглядит как периодические замедления при здоровом графике кучи, и её разбирают по журналу сборщика мусора.
Включить журнал. -Xlog:gc*:stdout:time,uptime пишет каждую сборку одной строкой в stdout, откуда её забирает конвейер логов; объём небольшой, и в проде его держат включённым. Строка вида Pause Young (Normal) (G1 Evacuation Pause) 612M->218M(1024M) 14.3ms читается так: молодая сборка, куча до и после, размер кучи, пауза. Смотрят на три вещи: длительность пауз (десятки миллисекунд у G1 норма, сотни это вклад в p99, который видно на графике задержки ровно в те же секунды), их частоту (сборки каждую секунду означают, что куча мала для потока объектов) и строки Pause Full: полная сборка с паузой в секунды это признак, что куча заполнена живыми объектами, и дальше либо утечка, либо просто нужен больший лимит. Та же информация есть в метриках jvm_gc_pause_seconds, а журнал нужен, когда важно, что именно происходило в конкретную секунду.
Два разных OOM. OutOfMemoryError: Java heap space в логе приложения означает, что куча заполнена и сборщик не смог освободить место: это внутренняя ошибка JVM, после неё процесс обычно жив, но непредсказуем, и с -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps в момент ошибки записывается дамп, по которому утечку находят как в разделе выше. OOMKilled с кодом выхода 137 означает, что весь процесс превысил лимит памяти контейнера и убит ядром снаружи: в логе приложения ничего нет, дампа нет, а куча в этот момент могла быть заполнена наполовину. Превысила не куча, а процесс целиком: куча плюс метапространство, стеки потоков, кэш кода, прямые буферы и память самой JVM, и всё это вне графика jvm_memory_used{area="heap"}.
Почему куча не та, что ожидали. JVM берёт под кучу долю от памяти контейнера, и по умолчанию это четверть; -XX:MaxRAMPercentage=75 отдаёт три четверти, остальное нужно всему перечисленному выше. Поставили 90, и процесс упирается в лимит контейнера при полупустой куче; оставили 25, и куча забита при свободном контейнере. Вне кучи память считают через -XX:NativeMemoryTracking=summary и jcmd <pid> VM.native_memory summary: там видно, сколько ушло в потоки (по мегабайту на каждый из тысячи потоков) и прямые буферы. Подробный расчёт лимитов в статье про JVM в контейнерах.
Порядок разбора периодических замедлений: график p99 рядом с jvm_gc_pause_seconds_max; совпали, значит сборщик, и дальше журнал GC, размер кучи и число живых объектов; не совпали, значит не память, и дальше дамп потоков в момент замедления, о чём статья про Actuator.
Коротко
- Метрики показывают, что память растёт; кто её держит — отвечают только снимок кучи и профилировщик. Утечка видна не по высокому потреблению, а по тому, что нижняя точка после сборки мусора ползёт вверх.
jcmd <pid> GC.heap_infoбесплатен,GC.class_histogramстоит одной полной сборки — и всё равно это дешевле снимка и часто сразу называет виновника.- Снимок кучи содержит только живые объекты (перед записью идёт полная сборка) и останавливает приложение на время записи: снимать с одного экземпляра, следить за местом и порогом проверки живости. В снимке ищут не крупные объекты, а цепочку ссылок, которая мешает их собрать.
- Типовые утечки: кэш без ограничения,
ThreadLocalв пуле потоков, неотменённые подписки, статические коллекции, незакрытые ресурсы.-XX:+HeapDumpOnOutOfMemoryErrorставят заранее: первое падение обычно случается ночью. - Выборочное профилирование (
JFR,async-profiler) достаточно дёшево для продакшена;Thread.printвыручает, когда сервис завис. - Журнал GC (
-Xlog:gc*) читают по паузам, частоте иPause Full;OutOfMemoryErrorэто полная куча внутри JVM с дампом по флагу,OOMKilledэто весь процесс сверх лимита контейнера без дампа; куча по умолчанию четверть контейнера,MaxRAMPercentage=75и учёт памяти вне кучи через NMT. - В снимке смотрят Leak Suspects, потом dominator tree по удерживаемому размеру и путь до корня: объект на сто байт может держать два гигабайта и в гистограмме не виден.
- Кеш течёт из-за ключа: любая коллекция, живущая дольше запроса, обязана иметь ограничение по размеру и сроку, иначе это утечка с отложенным сроком.
- Если куча спокойна, а процесс растёт, утечка вне кучи: метапространство от генерации классов и прямые буферы, освобождаемые только сборщиком; обоим ставят предел, чтобы получить ошибку с дампом вместо убийства снаружи.
- До прода это ловят тестом на наблюдаемое свойство (размер коллекции), профилем выделений в нагрузочном прогоне и часовым тестом на выносливость с одним графиком — нижней точкой кучи.
Что почитать дальше
- Метрики — что снимать постоянно, чтобы заметить проблему до падения.
- Проброс контекста — почему очистка
ThreadLocalстоит вfinally. - От алерта до строки лога — как связать метрику, трассировку и журнал в одном расследовании.
- Настройка наблюдаемости — почему
/actuator/heapdumpне должен быть открыт наружу.