Примечания по оптимизации проблем JVM GC с использованием памяти вне кучи

JVM

Ключевой сервис в недавнем проекте вызвал ряд проблем с GC из-за специфики бизнеса. После долгих поисков и попыток проблема наконец-то была решена. Ниже приведены записи процесса и сбора урожая.

Справочная информация

Эта услуга предназначена для обеспечения функции сортировки продуктов, и бизнес-требования заключаются в следующем:

  1. Товары делятся на страны, и товары в каждой стране разные.
  2. Каждый элемент имеет поле первичного ключаgoodsId, и есть одномерная матрица признаков, которая сохраняется как одномерный массив с плавающей запятой длиной 128.
  3. При сортировке предоставьте матрицу признаков условий запросаA, и партия альтернативных товаровgoodsId(до 5000), то взять входную матрицуAУмножьте его на матрицу характеристик всех продуктов-кандидатов, чтобы получить соответствующий балл каждого продукта и вернуть его.
  4. Коллекция продуктов нуждается в регулярном обновлении, и коллекция продуктов каждой страны обновляется отдельно.

Здесь вы можете увидеть особенность этого сервиса: каждый запрос должен найти максимум 5000 массивов float[128]! Как хранить эти данные, действительно проблема.

Решение, которое мы приняли, состоит в том, чтобы создать в памяти большую карту, а структура представляет собойMap<String, Map<String, float[128]>. Внешний слой содержит сопоставление стран с товарными коллекциями, а внутренний MapgoodsIdк его матрице признаков. Мы подсчитали количество данных, и приблизительная оценка такова, что память, занимаемая одной внутренней картой, составляет около 350 МБ, а память всей внешней большой карты составляет около 2 ГБ.

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

nation1:
  goodsId1: 特征矩阵1
  goodsId2: 特征矩阵2
  ...
nation2:
  goodsId1: 特征矩阵1
  goodsId2: 特征矩阵2
  ...

вахтенный офицерНа этом этапе мы должны спросить, почему мы не используем централизованные кеши, такие как Redis, а напрямую помещаем данные в память?

Что ж, перед написанием этой статьи я провел стресс-тестирование и обнаружил, что производительность Redis на самом деле не так высока. Например, заявленное официальное значение OPS для одного экземпляра 100 000+ действительно может быть достигнуто, но что означает это число? Это означает, что запрос на получение занимает 0,01 мс, а MGET размером 1000 — 10 мс! Это все еще без задержки сети. Я измерил ключи MGET 5000 локально (сервер и клиент находятся на локальной физической машине и виртуальной машине соответственно), и задержка составляет от 40 до 60 мс (в этом сценарии значение не слишком велико, около 1 КБ, и это не вызвало значительная производительность. снижение). Разместите статью здесь:Фантазия производительности и суровая реальность Redis

Другая идея — использовать Redis плюс локальный кеш. Однако в этом сценарии есть миллионы фрагментов данных и нет горячих точек, поэтому локальное кэширование сложно сделать эффективным.

Ближе к дому. С этой картой основной интерфейс сервиса прост в обращении:

  • Вход: принять одинnationпараметры, наборgoodsIdи матрица признаков условий запросаA, также с плавающей запятой[128]
  • в соответствии сnationиgoodsIdНайдите матрицу характеристик продукта, а затем просуммируйтеAУмножьте, чтобы получить соответствующий балл продукта.

Эффект первого издания: При нормальном количестве запросов в секунду средняя задержка составляет менее 10 мс.

Предыстория объяснена. Кошмар вот-вот начнется~

Возникла проблема с GC

После выхода в интернет все было идеально. Однако после работы в течение определенного периода времени восходящая служба начинает прерываться по тайм-ауту или даже нерегулярно сливается, и продолжительность каждого времени очень мала. После некоторого расследования мы заметили, что при возникновении проблемы индикатор службы TP99 резко возрастает, как показано на следующем рисунке:

Время отклика иногда достигает 1 секунды! В случае отсутствия аномалий в журнале, мы можем только подозревать, что GC работает, поэтому я нашел журнал GC, чтобы выяснить это.

Следите за журналом GC

Ниже приведен журнал gc, когда параметры JVM принимают -Xmx4g -Xmx4g, версия Java: OpenJDK 1.8.0_212.

{Heap before GC invocations=393 (full 5):
 PSYoungGen      total 1191936K, used 191168K [0x000000076ab00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 986112K, 0% used [0x000000076ab00000,0x000000076ab00000,0x00000007a6e00000)
  from space 205824K, 92% used [0x00000007b3700000,0x00000007bf1b0000,0x00000007c0000000)
  to   space 205824K, 0% used [0x00000007a6e00000,0x00000007a6e00000,0x00000007b3700000)
 ParOldGen       total 2796544K, used 2791929K [0x00000006c0000000, 0x000000076ab00000, 0x000000076ab00000)
  object space 2796544K, 99% used [0x00000006c0000000,0x000000076a67e750,0x000000076ab00000)
 Metaspace       used 70873K, capacity 73514K, committed 73600K, reserved 1114112K
  class space    used 8549K, capacity 9083K, committed 9088K, reserved 1048576K
4542.168: [Full GC (Ergonomics) [PSYoungGen: 191168K->167781K(1191936K)] [ParOldGen: 2791929K->2796093K(2796544K)] 2983097K->2963875K(3988480K), [Metaspace: 70873K->70638K(1114112K)], 2.9853595 secs] [Times: user=11.28 sys=0.00, real=2.99 secs]
Heap after GC invocations=393 (full 5):
 PSYoungGen      total 1191936K, used 167781K [0x000000076ab00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 986112K, 0% used [0x000000076ab00000,0x000000076ab00000,0x00000007a6e00000)
  from space 205824K, 81% used [0x00000007b3700000,0x00000007bdad95e8,0x00000007c0000000)
  to   space 205824K, 0% used [0x00000007a6e00000,0x00000007a6e00000,0x00000007b3700000)
 ParOldGen       total 2796544K, used 2796093K [0x00000006c0000000, 0x000000076ab00000, 0x000000076ab00000)
  object space 2796544K, 99% used [0x00000006c0000000,0x000000076aa8f6d8,0x000000076ab00000)
 Metaspace       used 70638K, capacity 73140K, committed 73600K, reserved 1114112K
  class space    used 8514K, capacity 9016K, committed 9088K, reserved 1048576K
}

Некоторая информация, которую можно получить из журнала:

  • Если в JDK 8 не указан метод gc, по умолчанию используется комбинация Parallel Scavenge + Parallel Old. Это даже не CMS.
  • Причина этого Full GC в том, что старость заполнена, а STW приостанавливается на 3 секунды...

Скорректировать стратегию gc

Поскольку есть проблема с GC, его необходимо настроить. Вот некоторые из моих попыток:

  1. Сборщик мусора заменен на CMS
  2. Так как в старости места мало, дайте ей больше места! Увеличьте размер всей кучи и увеличьте размер памяти старого поколения.

Ниже приведены экспериментальные результаты и выводы:

  1. Переходить на CMS бесполезно, но даже хуже. Думаю, причина в том, что CMS больше зависит от количества ядер ЦП, а мы ограничиваем количество ядер до очень низкого уровня в среде докеров, что приводит к незначительному улучшению параллельной обработки CMS. Даже иногда из-за плотной памяти старости тоже появитсяConcurrent Mode Failure, введите итоговую линейную полную сборку мусора и займите больше времени.
  2. После увеличения памяти кучи количество обычных сборщиков мусора и полных сборщиков мусора уменьшается, но одиночный сборщик мусора работает медленнее. Не удалось решить проблему.

Прилагается место аварии CMS:

[GC (CMS Initial Mark) [1 CMS-initial-mark: 4793583K(5472256K)] 4886953K(6209536K), 0.0075637 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[CMS-concurrent-mark-start]
03:05:50.594 INFO [XNIO-2 task-8] c.shein.srchvecsort.filter.LogFilter ---- GET /prometheus?null took 3ms and returned 200
{Heap before GC invocations=240 (full 7):
par new generation total 737280K, used 737280K [0x0000000640000000, 0x0000000672000000, 0x0000000672000000)
eden space 655360K, 100% used [0x0000000640000000, 0x0000000668000000, 0x0000000668000000)
from space 81920K, 100% used [0x0000000668000000, 0x000000066d000000, 0x000000066d000000)
to space 81920K, 0% used [0x000000066d000000, 0x000000066d000000, 0x0000000672000000)
concurrent mark-sweep generation total 5472256K, used 4793583K [0x0000000672000000, 0x00000007c0000000, 0x00000007c0000000)
Metaspace used 66901K, capacity 69393K, committed 69556K, reserved 1110016K
class space used 8346K, capacity 8805K, committed 8884K, reserved 1048576K
[GC (Allocation Failure) [ParNew: 737280K->737280K(737280K), 0.0000229 secs][CMS[CMS-concurrent-mark: 1.044/1.045 secs] [Times: user=1.36 sys=0.05, real=1.05 secs]
(concurrent mode failure): 4793583K->3662044K(5472256K), 3.8206326 secs] 5530863K->3662044K(6209536K), [Metaspace: 66901K->66901K(1110016K)], 3.8207144 secs] [Times: user=3.82 sys=0.00, real=3.82 secs]
Heap after GC invocations=241 (full 8):
par new generation total 737280K, used 0K [0x0000000640000000, 0x0000000672000000, 0x0000000672000000)
eden space 655360K, 0% used [0x0000000640000000, 0x0000000640000000, 0x0000000668000000)
from space 81920K, 0% used [0x0000000668000000, 0x0000000668000000, 0x000000066d000000)
to space 81920K, 0% used [0x000000066d000000, 0x000000066d000000, 0x0000000672000000)
concurrent mark-sweep generation total 5472256K, used 3662044K [0x0000000672000000, 0x00000007c0000000, 0x00000007c0000000)
Metaspace used 66901K, capacity 69393K, committed 69556K, reserved 1110016K
class space used 8346K, capacity 8805K, committed 8884K, reserved 1048576K
}

Кстати, вот некоторые подводные камни, которые встречаются в процессе, в основном ограничения среды docker/k8s, некоторые из которых до сих пор не устранены. Давайте поговорим об этом позже.

  • jstat Невозможно указать процесс, так как процесс Java равен 0.
  • Похоже, что аварийный дамп OOM собрать не так-то просто.
  • VisualVM плохо подключается.

Как избежать проблем с GC

Подумайте: в чем проблема?

На самом деле причина этой проблемы GC очевидна: в силу специфики бизнеса мы держим в памяти несколько больших объектов карты, и можно не сомневаться, что они войдут в старость. Однако эти объекты не бессмертны! Время от времени, поскольку данные необходимо обновлять, будут создаваться некоторые новые объекты карты, а старые объекты карты теряют свои ссылки и должны быть восстановлены GC. Из-за массового роста памяти в пожилом возрасте приходится выполнять Major GC, и за один раз приходится высвобождать большой объем памяти, поэтому сложно сократить это время до особо низкого уровня.

Поскольку проблема заключается в большом объекте карты, решение естественно: избегать использования больших объектов карты, а точнее - не помещать такие большие данные в память кучи.

Если данные не помещаются в память кучи, они либо помещаются из кучи (прямая память в процессе), либо из процесса.

Внепроцессные решения — это, очевидно, базы данных, а как насчет внутрипроцессных решений? Существует два типа внутрипроцессных баз данных (таких как Berkeley DB) и кэши вне кучи. И база данных для меня слишком тяжелая, на самом деле все, что я хочу, это функция карты. Поэтому было принято решение дополнительно исследовать кеши вне кучи.

Кроме того: я не буду повторять здесь детали схемы кэширования Java, а обращусь к разделу кэширования в «Изучаем архитектуру с Kaitao»:

Тип кеша Java

  • Кэш кучи: используйте память кучи Java для хранения объектов. Преимущество заключается в том, что она не требует сериализации/десериализации и работает быстро. Недостатком является то, что на нее влияет GC. Может быть реализован с использованием Guava Cache, Ehcache 3.x, MapDB.
  • Кэш вне кучи: кешированные данные хранятся вне кучи, разрывая оковы JVM.При чтении данных требуется сериализация/десериализация, которая намного медленнее, чем кеш в куче. Его можно реализовать с помощью Ehcache 3.x, MapDB.
  • Кэш диска: данные все еще существуют при перезапуске JVM, но данные кэша кучи/кеша вне кучи будут потеряны и должны быть перезагружены. Его можно реализовать с помощью Ehcache 3.x, MapDB.
  • Распределенный кэш: нечего сказать, Redis…

кэш вне кучи

Просто опубликуйте две статьи:

Кратко резюмируя:

  1. Кэш в куче может вызвать проблемы с производительностью GC, когда объем данных огромен. Кэш вне кучи разрешим.
  2. Принцип реализации кэша вне кучи таков:UnsafeКласс напрямую манипулирует памятью процесса, поэтому ему необходимо контролировать переработку памяти и сериализацию/десериализацию с объектами Java, поскольку он знает только байты вне кучи, но не объекты Java.
  3. Такую полезную, но не простую в реализации функцию конечно лучше всего обратиться к фреймворку. Поддерживаемые фреймворки: mapdb, ohc, ehcache3 и т. д. Ehcache3 заряжает, ohc самый быстрый.

Таким образом, решение использовать ohc. Адрес официального сайтаздесь.

дизайн кода

Идеи:

  1. Учитывая гибкость, выберите режим стратегии. см. вышеКэши вне кучи, о которых вы не зналиконец текста.
  2. Поскольку он по-прежнему используется в качестве карты, пусть инкапсулированный класс инструмента наследует интерфейс Map. один из ohcOHCacheОбъект представляет собой кэш вне кучи, и я инкапсулирую его как карту для хранения данных страны. Естественно их будет несколькоOHCache.
  3. Кроме того, обратите внимание, чтоOHCacheСам класс наследуетсяCloseableинтерфейс, то есть вызов егоClose()Метод может освободить свои ресурсы, то есть восстановить память. Следовательно, класс инкапсулированного инструмента также должен наследоватьCloseable, а при обновлении данных о стране вызвать замену исходного объекта картыClose()метод освобождения памяти. Проверено и возможно.
  4. Поскольку он используется в Spring Boot и является фреймворком кэширования, естественно захотеть адаптировать его к системе кэширования Spring. Еще не реализовано.

Эффект

На картинке ниже показано улучшениеДо, при использовании карты в куче ситуация с сборкой мусора, вызванная обновлением карты при обновлении данных:

Видно, что Young GC уже давно, а есть и Major GC. Фактическое время GC намного больше, чем на графике (индикатор актуатора), Major GC больше 1 секунды.

После использования улучшения памяти вне кучи я уменьшил память кучи JVM, чтобы оставить достаточно памяти для вне кучи, эффект:

  • Эффект тот же. . . По-прежнему будут серьезные проблемы с сборкой мусора! фото не буду выкладывать.
  • Средняя задержка изменилась с исходных 10 мс до 40 мс...

Это антинаучно! Что-то должно быть не так~

Сделайте еще один шаг вперед

Вот, не буду продавать (се) (бу) закрыть (ся) саб (цю), прямо скажем причину проблемы.

Яма 1: Чтение больших файлов

Это обновление также внесло изменения: источник данных, используемый для обновления карты, был изменен с базы данных на файлы на s3, и эти файлы будут весить сотни МБ. и мы использовалиCommonsIOизreadLines()метод. Что ж, он загрузит все содержимое файла в кучу, и никакой сборщик мусора не виноват!

После переключения на перечислитель строк проблема с сборщиком мусора окончательно исчезла. Нет больше Major GC.

Замена карты происходит на изображении ниже:

Время основного GC равно нулю!

Яма 2: Сериализация

ohc требует, чтобы вы предоставили метод сериализации ключа и значения, передайтеByteBuffer. Из-за моей молодости и невежества я использовал его сноваApacheИнструмент сериализации преобразует объект в массив байтов в куче в соответствии с методом сериализации JDK, а затем копирует его вByteBufferсередина.

Решение состоит в том, чтобы работать напрямуюByteBuffer, пользовательский метод сериализации. После модификации также решена проблема с задержкой.

окончательный эффект

TP99 стабилен в течение 13 мс! Пока, пончики, о нет, пока, глюки~

Затем прикрепите изменение общей памяти процесса при замене карты:

Спасибо за просмотр!

постскриптум

Пользуюсь ohc online уже несколько месяцев без проблем. Однако в последнее время было много онлайн-сигналов тревоги.После некоторого расследования выяснилось, что я использовал фиксированную емкость, а затем значение loadFactor по умолчанию было 0,75. По мере того, как сохраняется все больше и больше товарных индексов (общее количество товаров на полках увеличивается), он фактически превышает мою заданную емкость, и тогда... некоторые индексы перезаписываются. Эта яма заслуживает внимания и настоящим записана. По сути, использование компонента в производстве — это понимание каждого его аспекта.