Рубрики
Без рубрики

Отслеживание собственной памяти в JVM

Узнайте о выделении собственной памяти в JVM и о том, как ее отслеживать.

Автор оригинала: Ali Dehghani.

1. Обзор

Вы когда-нибудь задумывались, почему Java-приложения потребляют гораздо больше памяти, чем заданный объем, с помощью известных флагов настройки – Xms и – Xmx ? По целому ряду причин и возможных оптимизаций JVM может выделять дополнительную собственную память. Эти дополнительные выделения могут в конечном итоге увеличить потребляемую память за пределы ограничения -Xmx .

В этом уроке мы перечислим несколько распространенных источников выделения собственной памяти в JVM, а также их флаги настройки размера, а затем узнаем, как использовать Отслеживание собственной памяти для их мониторинга.

2. Собственные Распределения

Куча обычно является крупнейшим потребителем памяти в приложениях Java, но есть и другие. Помимо кучи, JVM выделяет довольно большой кусок из собственной памяти для хранения метаданных своего класса, кода приложения, кода, сгенерированного JIT, внутренних структур данных и т. Д. В следующих разделах мы рассмотрим некоторые из этих распределений.

2.1. Метапространство

Чтобы сохранить некоторые метаданные о загруженных классах, JVM использует выделенную область без кучи, называемую Metaspace . До Java 8 эквивалент назывался PermGen или Постоянное поколение . Metaspace или PermGen содержат метаданные о загруженных классах, а не их экземпляры, которые хранятся в куче.

Здесь важно то, что конфигурации размера кучи не повлияют на размер метапространства , поскольку метапространство является областью данных вне кучи. Чтобы ограничить размер метапространства, мы используем другие флаги настройки:

  • -XX:MetaspaceSize и -XX:MaxMetaspaceSize для установки минимального и максимального размера метапространства
  • Перед Java 8, -XX:PermSize и -XX:MaxPermSize для установки минимального и максимального размера PermGen

2.2. Потоки

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

Размер стека потоков по умолчанию зависит от платформы, но в большинстве современных 64-разрядных операционных систем он составляет около 1 МБ. Этот размер настраивается с помощью флага настройки -Xss .

В отличие от других областей данных, общая память, выделяемая стекам, практически неограничена, когда нет ограничений на количество потоков. Также стоит упомянуть, что сама JVM нуждается в нескольких потоках для выполнения своих внутренних операций, таких как GC или компиляции just-in-time.

2.3. Кэш кода

Чтобы запустить байт-код JVM на разных платформах, его необходимо преобразовать в машинные инструкции. JIT-компилятор отвечает за эту компиляцию по мере выполнения программы.

Когда JVM компилирует байт-код в инструкции по сборке, он сохраняет эти инструкции в специальной области данных без кучи, называемой Code Cache. Кэшем кода можно управлять так же, как и другими областями данных в JVM. Флаги настройки -XX:InitialCodeCacheSize и -XX:ReservedCodeCacheSize определяют начальный и максимально возможный размер кэша кода.

2.4. Сбор мусора

JVM поставляется с несколькими алгоритмами GC, каждый из которых подходит для разных случаев использования. Все эти алгоритмы GC имеют одну общую черту: им необходимо использовать некоторые структуры данных вне кучи для выполнения своих задач. Эти внутренние структуры данных потребляют больше собственной памяти.

2.5. Символы

Давайте начнем с Strings, одного из наиболее часто используемых типов данных в коде приложений и библиотек. Из – за своей вездесущности они обычно занимают большую часть Кучи. Если большое количество этих строк содержит одно и то же содержимое, то значительная часть кучи будет потрачена впустую.

Чтобы сэкономить место в куче, мы можем сохранить одну версию каждой строки | и заставить другие ссылаться на сохраненную версию. Этот процесс называется Интернированием строк. Поскольку JVM может только интернировать Строковые константы времени компиляции, мы можем вручную вызвать метод intern() для строк, которые мы собираемся интернировать.

JVM хранит интернированные строки в специальной собственной хэш-таблице фиксированного размера, называемой таблицей строк , также известной как Пул строк . Мы можем настроить размер таблицы (т. Е. количество сегментов) с помощью флага -XX:StringTableSize tuning.

В дополнение к таблице строк существует еще одна собственная область данных, называемая пулом констант времени выполнения . JVM использует этот пул для хранения констант, таких как числовые литералы времени компиляции или ссылки на методы и поля, которые должны быть разрешены во время выполнения.

2.6. Собственные байтовые буферы

JVM обычно подозревается в значительном количестве собственных выделений, но иногда разработчики также могут напрямую выделять собственную память. Наиболее распространенными подходами являются malloc вызов буферов байтов NI и Nio direct .

2.7. Дополнительные флаги настройки

В этом разделе мы использовали несколько флагов настройки JVM для различных сценариев оптимизации. Используя следующий совет, мы можем найти почти все флаги настройки, связанные с конкретной концепцией:

$ java -XX:+PrintFlagsFinal -version | grep 

PrintFlagsFinal печатает все параметры – XX в JVM. Например, чтобы найти все флаги, связанные с метапространством:

$ java -XX:+PrintFlagsFinal -version | grep Metaspace
      // truncated
      uintx MaxMetaspaceSize                          = 18446744073709547520                    {product}
      uintx MetaspaceSize                             = 21807104                                {pd product}
      // truncated

3. Отслеживание собственной памяти (NMT)

Теперь, когда мы знаем общие источники выделения собственной памяти в JVM, пришло время выяснить, как их отслеживать. Во-первых, мы должны включить отслеживание собственной памяти, используя еще один флаг настройки JVM: – XX:NativeMemoryTracking=off|sumary|detail. По умолчанию NMT выключен, но мы можем включить его для просмотра сводного или подробного представления его наблюдений.

Предположим, мы хотим отслеживать собственные распределения для типичного приложения Spring Boot:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseG1GC -jar app.jar

Здесь мы включаем NMT, выделяя 300 МБ места в куче, с G1 в качестве нашего алгоритма GC.

3.1. Мгновенные снимки

Когда NMT включен, мы можем получить информацию о собственной памяти в любое время с помощью команды jcmd :

$ jcmd  VM.native_memory

Чтобы найти PID для приложения JVM, мы можем использовать команду jps :

$ jps -l                    
7858 app.jar // This is our app
7899 sun.tools.jps.Jps

Теперь , если мы используем jcmd с соответствующим pid , VM.native_memory заставляет JVM распечатывать информацию о собственных выделениях:

$ jcmd 7858 VM.native_memory

Давайте проанализируем выход NMT раздел за разделом.

3.2. Общие ассигнования

NMT сообщает об общей зарезервированной и зафиксированной памяти следующим образом:

Native Memory Tracking:
Total: reserved=1731124KB, committed=448152KB

Зарезервированная память представляет собой общий объем памяти, который потенциально может использовать наше приложение. И наоборот, выделенная память равна объему памяти, которую ваше приложение использует прямо сейчас.

Несмотря на выделение 300 МБ кучи, общая зарезервированная память для нашего приложения составляет почти 1,7 ГБ, что намного больше. Аналогично, выделенная память составляет около 440 МБ, что, опять же, намного больше, чем 300 МБ.

После раздела “Общее” NMT сообщает о выделении памяти для каждого источника выделения. Итак, давайте подробно рассмотрим каждый источник.

3.3. Куча

NMT сообщает о наших распределениях кучи, как мы и ожидали:

Java Heap (reserved=307200KB, committed=307200KB)
          (mmap: reserved=307200KB, committed=307200KB)

300 МБ как зарезервированной, так и выделенной памяти, что соответствует нашим настройкам размера кучи.

3.4. Метапространство

Вот что говорит NMT о метаданных класса для загруженных классов:

Class (reserved=1091407KB, committed=45815KB)
      (classes #6566)
      (malloc=10063KB #8519) 
      (mmap: reserved=1081344KB, committed=35752KB)

Почти 1 ГБ зарезервировано и 45 МБ выделено для загрузки 6566 классов.

3.5. Резьба

А вот отчет NMT о распределении потоков:

Thread (reserved=37018KB, committed=37018KB)
       (thread #37)
       (stack: reserved=36864KB, committed=36864KB)
       (malloc=112KB #190) 
       (arena=42KB #72)

В общей сложности 36 МБ памяти выделено на стеки для 37 потоков – почти 1 МБ на стек. JVM выделяет память потокам во время создания, поэтому зарезервированные и зафиксированные выделения равны.

3.6. Кэш кода

Давайте посмотрим, что NT говорит о сгенерированных и кэшированных инструкциях по сборке JIT:

Code (reserved=251549KB, committed=14169KB)
     (malloc=1949KB #3424) 
     (mmap: reserved=249600KB, committed=12220KB)

В настоящее время кэшируется почти 13 МБ кода, и этот объем потенциально может увеличиться примерно до 245 МБ.

3.7. ГК

Вот отчет NMT об использовании памяти G1 GC:

GC (reserved=61771KB, committed=61771KB)
   (malloc=17603KB #4501) 
   (mmap: reserved=44168KB, committed=44168KB)

Как мы видим, почти 60 МБ зарезервировано и предназначено для помощи G1.

Давайте посмотрим, как выглядит использование памяти для гораздо более простого GC, скажем, последовательного GC:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseSerialGC -jar app.jar

Последовательный GC едва использует 1 МБ:

GC (reserved=1034KB, committed=1034KB)
   (malloc=26KB #158) 
   (mmap: reserved=1008KB, committed=1008KB)

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

3.8. Символ

Вот отчет NMT о распределении символов, таких как таблица строк и пул констант:

Symbol (reserved=10148KB, committed=10148KB)
       (malloc=7295KB #66194) 
       (arena=2853KB #1)

На символы выделяется почти 10 МБ.

3.9. НМТ С течением времени

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

$ jcmd  VM.native_memory baseline
Baseline succeeded

Затем, через некоторое время, мы можем сравнить текущее использование памяти с этим базовым уровнем:

$ jcmd  VM.native_memory summary.diff

NMT, используя знаки + и–, расскажет нам, как изменилось использование памяти за этот период:

Total: reserved=1771487KB +3373KB, committed=491491KB +6873KB
-             Java Heap (reserved=307200KB, committed=307200KB)
                        (mmap: reserved=307200KB, committed=307200KB)
 
-             Class (reserved=1084300KB +2103KB, committed=39356KB +2871KB)
// Truncated

Общий объем зарезервированной и выделенной памяти увеличился на 3 МБ и 6 МБ соответственно. Другие колебания в распределении памяти можно обнаружить так же легко.

3.10. Подробный NMT

NMT может предоставить очень подробную информацию о карте всего пространства памяти. Чтобы включить этот подробный отчет, мы должны использовать флаг -XX:NativeMemoryTracking=detail tuning.

4. Заключение

В этой статье мы перечислили различные факторы, влияющие на распределение собственной памяти в JVM. Затем мы узнали, как проверять запущенное приложение, чтобы отслеживать его собственные распределения. С помощью этих знаний мы можем более эффективно настраивать наши приложения и определять размер наших сред выполнения.