Устранение неполадок и сводка опыта «Утечка памяти вне кучи», вызванная Spring Boot

Spring Boot

задний план

Чтобы лучше управлять проектом, мы перенесли проект в группе на платформу 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", фактическая используемая физическая память показана на рисунке ниже:

top命令显示的内存情况

Процесс устранения неполадок

1. Используйте инструменты уровня Java для обнаружения областей памяти (память в куче, область кода или память вне кучи, запрашиваемая unsafe.allocateMemory и DirectByteBuffer).

Автор добавил в проект-XX:NativeMemoryTracking=detailПараметры JVM для перезапуска проекта используйте командуjcmd pid VM.native_memory detailРаспределение памяти выглядит следующим образом:

jcmd显示的内存情况

Обнаружено, что выделенная память, отображаемая командой, меньше физической памяти, поскольку память, отображаемая командой jcmd, включает память в куче, область кода и память, запрошенную unsafe.allocateMemory и DirectByteBuffer, но не не включать память вне кучи, запрошенную другим собственным кодом (код C). Таким образом, предполагается, что проблема вызвана использованием собственного кода для обращения к памяти.

Во избежание неправильного суждения автор использовал pmap для проверки распределения памяти и обнаружил большое количество 64M адресов; эти адресные пространства не находятся в адресном пространстве, заданном командой jcmd, и в основном делается вывод, что эти 64M памяти вызваны .

pmap显示的内存情况

2. Используйте инструменты системного уровня для поиска памяти вне кучи

Потому что автор в основном определил, что это вызвано Native Code, а инструменты на уровне Java не так просто устранить такие проблемы, и только инструменты на системном уровне могут быть использованы для локализации проблемы.

Во-первых, используйте gperftools, чтобы найти проблему

Как использовать gperftools можно обратиться кgperftools, мониторинг gperftools выглядит следующим образом:

gperftools监控

Как видно из рисунка выше: память, применяемая malloc, освобождается до 3G, а затем всегда поддерживается на уровне 700M-800M. Первая реакция автора: нет ли приложения malloc в нативном коде и напрямую приложения mmap/brk? (Принцип gperftools использует динамическую компоновку для замены стандартного распределителя памяти операционной системы (glibc).)

Затем используйте strace для трассировки системных вызовов.

Поскольку трассировка памяти не проводилась с помощью gperftools, команда "strace -f -e "brk,mmap,munmap" -p pid" использовалась напрямую для трассировки запроса памяти к ОС, но подозрительного запроса памяти обнаружено не было. Мониторинг strace показан на следующем рисунке:

strace监控

Затем используйте GDB для создания дампа подозрительной памяти.

Поскольку подозрительное приложение памяти не было отслежено с помощью strace, я подумал о том, чтобы посмотреть на ситуацию в памяти. просто используйте команду напрямуюgdp -pid pidПосле входа в GDB используйте командуdump memory mem.bin startAddress endAddressдамп памяти, где startAddress и endAddress можно найти в /proc/pid/smaps. затем используйтеstrings mem.binПросмотрите содержимое дампа следующим образом:

gperftools监控

С точки зрения содержимого это похоже на информацию о распакованном пакете JAR. Чтение информации о пакете JAR должно происходить при запуске проекта, поэтому использование strace после запуска проекта не очень полезно. Таким образом, вы должны использовать strace при запуске проекта, а не после завершения запуска.

Опять же, используйте strace для трассировки системных вызовов при запуске проекта.

Проект начал использовать strace для отслеживания системных вызовов и обнаружил, что действительно требуется много места в памяти 64M. Скриншоты следующие:

strace监控

Адресное пространство, примененное для использования этого mmap, соответствует следующему в pmap:

strace申请内容对应的pmap地址空间

Наконец, используйте jstack для просмотра соответствующего потока.

Потому что идентификатор потока, запросившего память, был отображен в команде strace. используйте команду напрямуюjstack pidПерейдите к просмотру стека потоков и найдите соответствующий стек потоков (обратите внимание на преобразование между десятичным и шестнадцатеричным числами) следующим образом:

strace申请空间的线程栈

Здесь вы можете в основном увидеть проблему: MCC (Meituan Unified Configuration Center) использует Reflections для сканирования пакетов, а Spring Boot используется внизу для загрузки JAR. Поскольку для распаковки JAR используется класс Inflater, вам необходимо использовать память вне кучи, а затем использовать Btrace для трассировки этого класса.Стек выглядит следующим образом:

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, чтобы определить, что он принадлежит собственному стеку. Распределение адресов памяти выглядит следующим образом:

gperftools显示的内存地址分布

На этом этапе можно в основном определить, что распределитель памяти делает свое дело; я искал glibc 64M и обнаружил, что glibc вводит пул памяти для каждого потока с версии 2.11 (размер 64-битной машины составляет 64M памяти). текст следующий:

glib内存池说明

Измените переменную среды 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 был обновлен до последняя версия, и эта проблема также может быть решена.

использованная литература

  1. GNU C Library (glibc)
  2. Native Memory Tracking
  3. Spring Boot
  4. gperftools
  5. Btrace

об авторе

Джи Бин, который присоединился к Meituan в 2015 году, в настоящее время в основном занимается работой, связанной с отелем.