Перейти к основному содержимому

Нативное отслеживание памяти в JVM

· 8 мин. чтения

1. Обзор

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

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

2. Нативные распределения

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

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

Чтобы поддерживать некоторые метаданные о загруженных классах, JVM использует выделенную область без кучи, называемую Metaspace . До 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. Например, чтобы найти все флаги, связанные с Metaspace:

$ 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 <pid> 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. Кэш кода

Давайте посмотрим, что NMT говорит о сгенерированных и закешированных 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, скажем, Serial GC:

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

Serial 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 с течением времени

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

$ jcmd <pid> VM.native_memory baseline
Baseline succeeded

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

$ jcmd <pid> 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 может предоставить очень подробную информацию о карте всего пространства памяти. Чтобы включить этот подробный отчет, мы должны использовать флаг настройки -XX:NativeMemoryTracking=detail .

4. Вывод

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