задний план
Чтобы лучше управлять проектом, мы перенесли проект в группе на платформу MDP (на основе Spring Boot), а затем обнаружили, что система часто сообщает об исключении чрезмерного использования области подкачки. Автору позвонили, чтобы помочь проверить причину, и он обнаружил, что память in-heap 4G настроена, но фактическая используемая физическая память достигает 7G, что действительно ненормально. Конфигурация параметра JVM: "-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+AlwaysPreTouch -XX:ReservedCodeCacheSize=128m -XX:InitialCodeCacheSize=128m, -Xss512k -Xmx4g -Xms4g, -XX:+UseG1GC -XX : G1HeapRegionSize=4M", фактическая используемая физическая память показана на рисунке ниже:
Процесс устранения неполадок
1. Используйте инструменты уровня Java для обнаружения областей памяти (память в куче, область кода или память вне кучи, запрашиваемая unsafe.allocateMemory и DirectByteBuffer).
Автор добавил в проект-XX:NativeMemoryTracking=detail
Параметры JVM для перезапуска проекта используйте командуjcmd pid VM.native_memory detail
Распределение памяти выглядит следующим образом:
Обнаружено, что выделенная память, отображаемая командой, меньше физической памяти, поскольку память, отображаемая командой jcmd, включает память в куче, область кода и память, запрошенную unsafe.allocateMemory и DirectByteBuffer, но не не включать память вне кучи, запрошенную другим собственным кодом (код C). Таким образом, предполагается, что проблема вызвана использованием собственного кода для обращения к памяти.
Во избежание неправильного суждения автор использовал pmap для проверки распределения памяти и обнаружил большое количество 64M адресов; эти адресные пространства не находятся в адресном пространстве, заданном командой jcmd, и в основном делается вывод, что эти 64M памяти вызваны .
2. Используйте инструменты системного уровня для поиска памяти вне кучи
Потому что автор в основном определил, что это вызвано Native Code, а инструменты на уровне Java не так просто устранить такие проблемы, и только инструменты на системном уровне могут быть использованы для локализации проблемы.
Во-первых, используйте gperftools, чтобы найти проблему
Как использовать gperftools можно обратиться кgperftools, мониторинг gperftools выглядит следующим образом:
Как видно из рисунка выше: память, применяемая malloc, освобождается до 3G, а затем всегда поддерживается на уровне 700M-800M. Первая реакция автора: нет ли приложения malloc в нативном коде и напрямую приложения mmap/brk? (Принцип gperftools использует динамическую компоновку для замены стандартного распределителя памяти операционной системы (glibc).)
Затем используйте strace для трассировки системных вызовов.
Поскольку трассировка памяти не проводилась с помощью gperftools, команда "strace -f -e "brk,mmap,munmap" -p pid" использовалась напрямую для трассировки запроса памяти к ОС, но подозрительного запроса памяти обнаружено не было. Мониторинг strace показан на следующем рисунке:
Затем используйте GDB для создания дампа подозрительной памяти.
Поскольку подозрительное приложение памяти не было отслежено с помощью strace, я подумал о том, чтобы посмотреть на ситуацию в памяти. просто используйте команду напрямуюgdp -pid pid
После входа в GDB используйте командуdump memory mem.bin startAddress endAddress
дамп памяти, где startAddress и endAddress можно найти в /proc/pid/smaps. затем используйтеstrings mem.bin
Просмотрите содержимое дампа следующим образом:
С точки зрения содержимого это похоже на информацию о распакованном пакете JAR. Чтение информации о пакете JAR должно происходить при запуске проекта, поэтому использование strace после запуска проекта не очень полезно. Таким образом, вы должны использовать strace при запуске проекта, а не после завершения запуска.
Опять же, используйте strace для трассировки системных вызовов при запуске проекта.
Проект начал использовать strace для отслеживания системных вызовов и обнаружил, что действительно требуется много места в памяти 64M. Скриншоты следующие:
Адресное пространство, примененное для использования этого mmap, соответствует следующему в pmap:
Наконец, используйте jstack для просмотра соответствующего потока.
Потому что идентификатор потока, запросившего память, был отображен в команде strace. используйте команду напрямуюjstack pid
Перейдите к просмотру стека потоков и найдите соответствующий стек потоков (обратите внимание на преобразование между десятичным и шестнадцатеричным числами) следующим образом:
Здесь вы можете в основном увидеть проблему: MCC (Meituan Unified Configuration Center) использует Reflections для сканирования пакетов, а Spring Boot используется внизу для загрузки JAR. Поскольку для распаковки JAR используется класс Inflater, вам необходимо использовать память вне кучи, а затем использовать Btrace для трассировки этого класса.Стек выглядит следующим образом:
Затем посмотрите на место, где используется MCC, и обнаружите, что путь сканирования пакетов не настроен.По умолчанию сканируются все пакеты. Поэтому измените код, настройте путь сканирования пакета и решите проблему с памятью после выпуска.
3. Почему память вне кучи не освобождается?
Хотя проблема решена, есть несколько вопросов:
- Почему нет проблем с использованием старого фреймворка?
- Почему память вне кучи не освобождается?
- Почему размер памяти 64M, размер JAR не может быть таким большим, и все они имеют одинаковый размер?
- Почему gperftools, наконец, показывает, что размер используемой памяти составляет около 700 МБ, действительно ли пакет распаковки использует malloc для обращения к памяти?
С сомнениями автор прямо смотрел наSpring Boot LoaderИсходный код этой части. Выяснилось, что Spring Boot оборачивает InflaterInputStream Java JDK и использует Inflater, а самому Inflater необходимо использовать память вне кучи для распаковки пакета JAR. Обернутый класс ZipInflaterInputStream не освобождает память вне кучи, удерживаемую Inflater. Поэтому я подумал, что нашел причину и сразу же сообщил об этом сообществу Spring Boot.эта ошибка. Но после обратной связи автор обнаружил, что сам объект Inflater реализует метод finalize, и в этом методе есть логика для вызова освобождения памяти за пределы кучи. Другими словами, Spring Boot полагается на GC для освобождения памяти вне кучи.
Когда я использую jmap для просмотра объектов в куче, я обнаруживаю, что в основном нет объекта Inflater. Поэтому, когда я подозреваю GC, finalize не вызывается. С такими сомнениями автор заменил Inflater, упакованный в Spring Boot Loader, на Inflater, упакованный им самим, и провел мониторинг в финализации, в результате метод finalize действительно вызвался. Итак, я посмотрел код C, соответствующий Inflater, и обнаружил, что malloc используется для запроса памяти для инициализации, а free также вызывается для освобождения памяти при завершении.
На данный момент автор может только подозревать, что память на самом деле не освобождается, когда она свободна, поэтому я заменил InflaterInputStream, упакованный Spring Boot, на тот, который идет с Java JDK, и обнаружил, что после замены проблема с памятью была решена.
В это время я вернулся и посмотрел на распределение памяти gperftools и обнаружил, что при использовании Spring Boot использование памяти увеличивалось, и внезапно в определенный момент использование памяти сильно упало (использование напрямую упало с 3G). примерно до 700 м). Эта точка должна быть вызвана GC, и память должна быть освобождена, но на уровне операционной системы изменений памяти не видно, она не освобождается операционной системе и удерживается распределителем памяти?
Продолжайте исследовать и обнаружите, что системный распределитель памяти по умолчанию (версия glibc 2.12) и распределение адресов памяти с использованием gperftools сильно различаются.Адрес 2,5G использует smaps, чтобы определить, что он принадлежит собственному стеку. Распределение адресов памяти выглядит следующим образом:
На этом этапе можно в основном определить, что распределитель памяти делает свое дело; я искал glibc 64M и обнаружил, что glibc вводит пул памяти для каждого потока с версии 2.11 (размер 64-битной машины составляет 64M памяти). текст следующий:
Измените переменную среды MALLOC_ARENA_MAX в соответствии с текстом и обнаружили, что это не имеет никакого эффекта. Глядя на tcmalloc (распределитель памяти, используемый gperftools), также используется подход с пулом памяти.
Чтобы убедиться, что это призрак пула памяти, автор просто пишет распределитель памяти без пула памяти. использовать командуgcc zjbmalloc.c -fPIC -shared -o zjbmalloc.so
Создайте динамическую библиотеку, затем используйтеexport LD_PRELOAD=zjbmalloc.so
Заменен распределитель памяти glibc. Демонстрационный код выглядит следующим образом:
#include<sys/mman.h>
#include<stdlib.h>
#include<string.h>
#include<stdio.h>
//作者使用的64位机器,sizeof(size_t)也就是sizeof(long)
void* malloc ( size_t size )
{
long* ptr = mmap( 0, size + sizeof(long), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0 );
if (ptr == MAP_FAILED) {
return NULL;
}
*ptr = size; // First 8 bytes contain length.
return (void*)(&ptr[1]); // Memory that is after length variable
}
void *calloc(size_t n, size_t size) {
void* ptr = malloc(n * size);
if (ptr == NULL) {
return NULL;
}
memset(ptr, 0, n * size);
return ptr;
}
void *realloc(void *ptr, size_t size)
{
if (size == 0) {
free(ptr);
return NULL;
}
if (ptr == NULL) {
return malloc(size);
}
long *plen = (long*)ptr;
plen--; // Reach top of memory
long len = *plen;
if (size <= len) {
return ptr;
}
void* rptr = malloc(size);
if (rptr == NULL) {
free(ptr);
return NULL;
}
rptr = memcpy(rptr, ptr, len);
free(ptr);
return rptr;
}
void free (void* ptr )
{
if (ptr == NULL) {
return;
}
long *plen = (long*)ptr;
plen--; // Reach top of memory
long len = *plen; // Read length
munmap((void*)plen, len + sizeof(long));
}
Спрятав точки в пользовательском распределителе, можно обнаружить, что внекучевая память, фактически используемая приложением после запуска программы, всегда составляет от 700 до 800 миллионов, а мониторинг gperftools показывает, что использование памяти также составляет около 700-800 миллионов. . Но с точки зрения операционной системы память, занимаемая процессом, сильно различается (здесь просто мониторинг памяти вне кучи).
Автор провел тест и использовал разные аллокаторы для сканирования пакетов в разной степени, занимаемая память следующая:
Почему кастомный malloc претендует на 800M, а занятая в итоге физическая память составляет 1.7G?
Поскольку настраиваемый распределитель памяти использует mmap для выделения памяти, mmap выделяет память и при необходимости округляет до целого числа страниц, что приводит к огромной трате места. Путем мониторинга установлено, что количество окончательно примененных страниц составляет около 536к, а примененная к системе память фактически равна 512к * 4к (размер страницы) = 2G. Почему эти данные больше 1,7G?
Поскольку операционная система использует метод отложенного выделения памяти, при обращении к системе через mmap система возвращает только адрес памяти и не выделяет реальную физическую память. Только когда он действительно используется, система генерирует прерывание по ошибке страницы, а затем выделяет реальную физическую страницу.
Суммировать
Весь процесс выделения памяти показан на рисунке выше. Конфигурация сканирования пакетов MCC по умолчанию — сканирование всех пакетов JAR. При сканировании пакетов Spring Boot активно не освобождает память вне кучи, что приводит к постоянному скачку использования памяти вне кучи на этапе сканирования. Когда происходит GC, Spring Boot полагается на механизм finalize для освобождения памяти вне кучи; однако по соображениям производительности glibc на самом деле не возвращает память операционной системе, а оставляет ее в пуле памяти, заставляя уровень приложения думать Произошла "утечка памяти". Поэтому измените путь конфигурации MCC на конкретный пакет JAR, и проблема будет решена. Когда я опубликовал эту статью, я обнаружил, что последняя версия Spring Boot (2.0.5.RELEASE) была изменена, а ZipInflaterInputStream активно освобождал память вне кучи и больше не полагается на GC; поэтому Spring Boot был обновлен до последняя версия, и эта проблема также может быть решена.
использованная литература
об авторе
Джи Бин, который присоединился к Meituan в 2015 году, в настоящее время в основном занимается работой, связанной с отелем.