«1. Обзор

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

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

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

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

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

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

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

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

2.2. Потоки

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

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

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

2.3. Кэш кода

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

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

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

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

2.5. Символы

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

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

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

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

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

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

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

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

$ java -XX:+PrintFlagsFinal -version | grep <concept>

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

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

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

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

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

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

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

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

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

$ jcmd <pid> VM.native_memory

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

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

3.2. Total Allocations

$ jcmd 7858 VM.native_memory

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

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

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

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

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

3.3. Куча

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

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

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

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

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

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

3.5. Поток

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

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

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

3.6. Кэш кода

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

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

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

3.7. GC

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

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

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

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

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

Последовательный сборщик мусора едва использует 1 МБ:

«

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

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

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

3.8. Символ

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

Под символы выделено почти 10 МБ.

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

3.9. NMT с течением времени

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

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

$ jcmd <pid> VM.native_memory baseline
Baseline succeeded

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

$ jcmd <pid> VM.native_memory summary.diff

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

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.10. Подробный NMT

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

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

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

«