Дано: долгоживущее серверное приложение на Java (
https://github.com/bozaro/git-as-svn). Приложеньке выдано 12GB под Java-объекты (-Xmx12g). Сверху ожидается некоторый оверхед от инфраструктурных вещей которые JVM обеспечивает приложению (сборка мусора, компиляция байткода в натив, етц). Ну не знаю, 10-20% кажется разумным. Также есть сервер с Ubuntu 14.04 о 24 ядрах на котором запущено это приложение под Oracle JDK 1.8u45.
Далее запускаем приложение и видим что оно под нагрузкой улетает хорошо за 40GB резидентной памяти (колонка RES в top(1)) и в какой-то момент за ним приходит OOM killer. 40GB, Карл, при -Xmx12g!
"Течёт память" подумали мужики и пошли чесать репу.
1. Первым делом была обновлена JVM 1.8u45 -> 1.8u181 (наисвежайшая на тот момент). Помогло никак.
2. Почитали логи GC. Всё чинно-мирно, внутри хипа полно свободного места и ограничитель в 12GB на месте.
3. "Память жрёт что-то за пределами heap'а" подумали мужики. Нашли
статью про поиск утечек в DirectBuffer'ах при помощи jxray. Сняли хип-дамп, насчитали несколько десятков MB памяти аллоцированной DirectBuffer'ами. Мало, не то.
4. "Память жрёт что-то в других потрохах JVM". Нашли механизм
Native Memory Tracking, который позволяет отслеживать на что JVM потратила память за пределами хипа. Выяснили что в момент когда процесс суммарно занимает 20GB, Native Memory Tracking может нам рассказать куда потрачено 14.8GB из них (это, конечно, не 10-20% оверхеда, а вполне себе 25%). Где ещё 5GB неясно.
5. "Память ТЕЧЁТ?" Нашли
статью про поиск утечек в нативном коде JVM через компиляцию jemalloc с включенной профилировкой, запуском JVM под этим jemalloc и вдумчивым курением логов. Можно, но как-то сложно и грустно. Запомнили мысль и пошли дальше, искать ключи под фонарём.
6. "Память жрёт что-то за пределами JVM". Каким-то невероятным чудом нашли багрепорт в
другом Java-проекте (presto) который тоже испытывал проблемы с неудержимым потреблением памяти. И открылось замечательное. glibc берёт память у ядра большими кусками и очень нехотя отдаёт их обратно. Причём количество "недоотданной памяти" зависит от количества ядер в системе, размеров порций которыми приложение делает malloc и чёрт знает чего ещё. Получить внятного ответа на вопрос "сколько максимум памяти может занимать одна арена" пока не вышло. Больше всего страдают приложения с большим количеством потоков которое аллоцирует/деаллоцирует память большими порциями. Как бы то ни было, уменьшение MALLOC_ARENA_MAX с дефолтных 16 до 4 привело к тому что свыше 20GB приложение расти перестало.
7. Попутно было обнаружено что в приложении живёт (в основном, естественно, спит) порядка 100 потоков:
30 порождённых самим приложением
5 от JVM C1 compiler
9 от JVM C2 compiler
24 "gang worker" для сборщика мусора G1
20 "G1 Concurrent Refinement Thread" для сборщика мусора G1
Тут в общем-то возникает справедливый WTF - зачем сборщику мусора потоков почти вдвое больше чем ядер в системе. Внятного ответа пока нет, зато нашёлся
баг в JVM по которому складывается впечатлнение что столько много "G1 Concurrent Refinement Thread" не нужно и вообще баг. Нужно ли столько gang worker'ов тоже неясно. И нужно ли C2 compiler'у?
8. Финальное решение: уменьшить MALLOC_ARENA_MAX до единицы. Уменьшить количество "G1 Concurrent Refinement Thread". Уменьшить количество потоков запускаемых самим приложением (примерно вдвое, причём совершенно безболезненно для самого приложения). Вообще кажется так что MALLOC_ARENA_MAX будет доставлять боль многим большим долгоживущим многопоточным серверным приложениям.
Мораль: раньше надо было тюнить сборщик мусора и количество одновременно открытых файловых дескрипторов, а теперь ещё и glibc :(
Бонусные ссылки по теме:
Кажется, это самый масштабный продолб оперативы вникуда который мне когда-либо встречался.