Разберите медленную утечку памяти Spring Boot-сервиса по гистограмме кучи
Spring Boot-сервис падает с OutOfMemoryError примерно каждые 36 часов; перезапуск обнуляет счётчик. Занятость кучи неуклонно растёт, а полные сборки освобождают всё меньше. Два снимка jmap -histo:live <pid> с разницей в 12 часов при одинаковом трафике:
t0 t0 + 12h
num #instances #bytes class name num #instances #bytes class name
--------------------------------------- ----------------------------------------
1: 418,222 40.1M byte[] 1: 6,904,551 612.4M byte[]
2: 190,880 15.2M java.lang.String 2: 3,118,402 249.5M java.lang.String
3: 42,015 3.4M c.a.web.AuditEntry 3: 2,884,190 230.7M c.a.web.AuditEntry
4: 11,904 1.1M java.util.HashMap$Node 4: 2,901,663 92.9M java.util.HashMap$Node
AuditEntry создаётся интерсептором один раз на HTTP-запрос и кладётся в бин, который команда описывает как «просто небольшой буфер в памяти для админской страницы».
Определите причину.
Гистограмма показывает, что AuditEntry — и удерживаемые им byte[]/String — растут без ограничений синхронно с числом запросов, удерживаемые через HashMap$Node. Бин аудита копит по записи на запрос и ничего не вытесняет. Singleton-бин Spring живёт столько же, сколько контекст приложения, поэтому всё, что он накопил, остаётся достижимым, и GC правомерно это не освобождает. Подтвердите путь удержания по dominator tree в heap dump и ограничьте буфер размером или политикой вытеснения.
- ✗Считать коллекцию внутри singleton-бина короткоживущим состоянием уровня запроса
- ✗Поднимать
-Xmxили менять сборщик ради отсрочки вместо поиска удерживающего корня - ✗Читать самую большую строку гистограммы (
byte[]) как утечку, а не как то, что удерживается
- →Почему удерживающего владельца называет dominator tree, а не гистограмма?
- →Как ограничить буфер, не потеряв записи аудита, нужные админской странице?
Разбор
Читаем гистограмму как дельту, а не как снимок.
| Класс | t0 | t0 + 12h | Рост |
|---|---|---|---|
AuditEntry | 42 тыс. | 2.88 млн | ×69 |
HashMap$Node | 12 тыс. | 2.90 млн | ×244 |
byte[] | 418 тыс. | 6.9 млн | ×17 |
byte[] — самая жирная строка по байтам, и это ловушка: массивы не утекают сами, их кто-то удерживает. Показателен другой признак: число AuditEntry и число HashMap$Node совпадают почти один в один и растут пропорционально числу обработанных запросов. То есть на каждый запрос в карту добавляется запись, и ничего не удаляется.
Почему GC бессилен. -histo:live уже прогоняет полную сборку — всё, что в списке, достижимо. Бин аудита — обычный Spring singleton: он живёт ровно столько, сколько живёт ApplicationContext, то есть весь процесс. Значит, он — фактически GC root, и его карта удерживает AuditEntry, а те — свои String и byte[]. Сборщик поступает правильно, не освобождая их.
«Небольшой буфер в памяти» без ограничения размера — это неограниченный кэш.
Подтверждение.
jcmd <pid> GC.heap_dump /tmp/heap.hprof
В Eclipse MAT: Leak Suspects → dominator tree → Path to GC Roots (exclude weak/soft). Путь пройдёт через singleton-бин к его карте — это и есть владелец. Гистограмма показывает что занимает память, dominator tree — кто её держит; для утечки нужен второй ответ.
Исправление. Ограничить удержание: кольцевой буфер фиксированного размера, LRU с потолком, TTL-вытеснение, — или вынести записи аудита за пределы кучи (лог, брокер, БД), а на админской странице читать их оттуда.