Время от времени мы сталкиваемся с проблемой памяти, которая связана не с кучей Java, а с собственной памятью . Давайте представим ситуацию, когда у нас есть работающий контейнер, который перезапускается один раз в день. Мы смотрим на Прометей / Графана, чтобы выяснить причину этой проблемы. Однако мы не обнаруживаем никаких проблем с размером кучи Java (экспортируется через JMX) и начинаем обвинять наш планировщик контейнеров в выполнении каких-либо неудобных операций с нашими контейнерами :). Настоящая проблема может быть скрыта чуть глубже — в Native Memory.
Используете ли вы какой-либо встроенный распределитель памяти в вашем коде, или вы используете какую-либо библиотеку, которая хранит свое внутреннее состояние вне кучи Java? Это может быть любой сторонний код, который пытается минимизировать GC, например, библиотеки ввода-вывода или базы данных / кэши, хранящие данные в памяти внутри вашего процесса.
В этой статье я расскажу о некоторых основах: как распределена встроенная память в этих библиотеках, и, в основном, как заметить, что что-то в вашем коде начало выделять встроенную память, что может привести к уничтожению вашего контейнера.
Как выделить родную память
Долгое время использовался единственный эффективный способ выделения памяти из Java sun.misc.Unsafe
. С самого начала архитекторы Java предупреждают, что эта библиотека предназначена только для внутренних сценариев использования, и нет намерения поддерживать ее обратно совместимой в качестве официального API. Однако разработчики хотели получить лучшее для своих библиотек и продолжали использовать Unsafe в своем коде. Большой взрыв пришел с Java 9 и введением в систему модулей Java. Небезопасный был автоматически помещен в jdk.unsupported
модуль, и было объявлено, что модуль станет видимым для внешнего кода в зависимости от него в течение некоторого времени. Но намерение состоит в том, чтобы предоставить официальный API, и библиотеки будут вынуждены перейти на него.
Другой способ выделить собственную память — ByteBuffer . Есть две реализации: HeapByteBuffer
и DirectByteBuffer
. Хотя HeapByteBuffer
данные хранятся в байтовом массиве, выделенном в куче, они DirectByteBuffer
поддерживаются собственной памятью и в основном используются для передачи данных между JVM и ядром. Тем не менее, некоторые библиотеки делают ввода / вывода реализовали свои собственные библиотеки для работы с родной памяти, такие как Нетти ( ByteBuf — Maven ) или Aeron (подпроект Agrona ), особенно из — за двух причин: обычай сбора неиспользованной памяти (Нетти использует реализация ручного сборщика ссылок (подсчет ссылок) и более удобный API.
Вы можете себе представить, что у владельцев библиотеки не было причин мигрировать из небезопасных . Поэтому проект «Панама» , касающийся взаимосвязи JVM и нативного кода , вышел с чем-то новым и блестящим. Первым выходом этого проекта был JEP 370: API доступа к внешней памяти , который предоставил альтернативный способ доступа к собственной памяти, поддерживаемый JIT-компилятором, чтобы максимально оптимизировать его, чтобы быть ближе к эффективности Unsafe., В дополнение к этому, он также принес новый API для разработки собственной структуры памяти, чтобы избежать «подсчета» адресов и смещений вручную и обеспечить явный способ освобождения памяти в исходном коде (например, с помощью try-with-resources). Он доступен в качестве инкубатора на Java 14. Не стесняйтесь попробовать и дать отзыв!
Идем прямо к примеру
После короткого вступления давайте воспользуемся самым простым способом выделить собственную память и посмотрим, что она на самом деле делает.
Джава
xxxxxxxxxx
1
import sun.misc.Unsafe;
2
import java.lang.reflect.Field;
4
public class UnsafeTest {
6
public static void main(String[] args) throws Exception {
8
/*
9
* STAGE 1
10
* Gets an access to an instance of Unsafe
11
*/
12
Field f = Unsafe.class.getDeclaredField("theUnsafe");
13
f.setAccessible(true);
14
Unsafe unsafe = (Unsafe) f.get(null);
15
System.out.println("PAGE SIZE: " + unsafe.pageSize());
17
System.in.read();
18
/*
19
* STAGE 2
20
* Allocates 50MB of native memory
21
*/
22
int memoryBlock = 50 * 1024 * 1024;
23
long address = unsafe.allocateMemory(memoryBlock);
24
System.out.println("ALLOCATED");
26
System.in.read();
27
/*
28
* STAGE 3
29
* Touches the allocated pages:
30
* writes one byte to every page to ensure that
31
* the page will be physically backed/mapped in memory
32
*/
33
long currentAddress = address;
34
while (currentAddress < (address + memoryBlock)) {
35
unsafe.putByte(currentAddress, (byte) 0);
36
currentAddress += unsafe.pageSize();
37
}
38
System.out.println("MEMORY TOUCHED");
40
System.in.read();
41
/*
42
* STAGE 4
43
* Frees the allocated memory.
44
*/
45
unsafe.freeMemory(address);
46
System.out.println("DE-ALLOCATED");
48
System.in.read();
49
}
50
}
Давайте обсудим один этап за другим, чтобы увидеть, что там происходит.
Этап 1. Доступ к небезопасным
Во-первых, это просто взлом, чтобы получить доступ, Unsafe
не бросая, SecurityException
когда вы пытаетесь получить к нему доступ через Unsafe.getUnsafe()
. Во-вторых, мы напечатали Размер страницы. Страница является фиксированной длиной адресуемого блоком виртуальной памяти , которые могут быть отображены в физическую память ( В любом случае, RAM или выгружена память на диск).
Этап 2: Распределение памяти
При вызове Unsafe.allocateMemory
мы выделяем блок памяти размером 50 МБ и получаем длинное значение, представляющее адрес в виртуальной памяти. Давайте скомпилируем код и попробуем его.
Оболочка
xxxxxxxxxx
1
$ javac UnsafeTest.java
2
< a lots of warnings because of using Unsafe 🙂 >
3
$ java UnsafeTest
5
PAGE SIZE: 4096
Мы можем видеть , что наша текущая PAGE_SIZE значение 4кБ. В Linux мы можем проверить значение, используя:
Оболочка
xxxxxxxxxx
1
$ getconf PAGE_SIZE
2
4096
Это означает, что мы можем выделить память, используя блоки по 4 КБ, но если вы хотите изменить это значение в соответствии со своими потребностями, не стесняйтесь искать термин, Hugepages , который может помочь вам выделить большие куски памяти и уменьшить накладные расходы. из незначительных сбоев страницы , которые генерируются, если страница виртуальной памяти не поддерживается физической памятью. MMU (блок управления памятью) должен найти подходящее место в физической памяти для нашего выделенного блока памяти. С другой стороны, в случае неправильного использования Hugepages, это может привести к значительному ложному обмену и потере памяти.
Используйте ваш любимый инструмент для проверки запущенных процессов (я могу порекомендовать htop ) и посмотрите значение RES (Resident Set Size), которое сообщает о текущем размере занятой физической памяти процессом (анонимные страницы, отображенные в памяти файлы с использованием mmap, общие библиотеки). ). Однако он не содержит памяти, которая была выгружена на диск.
Вы можете заметить, что с точки зрения резидентной памяти не было никаких изменений по сравнению с предыдущим шагом. По сути, распределение памяти не влияет на физическую память, это просто виртуальная память, которая не поддерживается физической памятью.
Этап 3: касание памяти
Что случилось, когда мы снова нажали «ввод» и перешли на стадию 3 ?
Мы коснулись всех выделенных виртуальных страниц со Стадии 2 . Мы перебрали полученный адрес памяти, используя текущий размер страницы, и поместили по одному байту на каждую страницу. Это означает, что каждая запись вызывала незначительную ошибку страницы , и MMU должен был находить свободные слоты в физической памяти и отображать слот 4 КБ из виртуальной памяти в слот 4 КБ из физической памяти.
Мы можем заметить два существенных изменения в нашем отчете памяти. RES, принадлежащий нашему процессу, наконец-то вырос на ~ 50 МБ, и мы также увеличили значение MINFLT, что означает незначительные ошибки страниц. MAJFLT остался абсолютно таким же и равным нулю. Это означает, что наш процесс не обращался к какому-либо адресу памяти, который мог бы привести к значительному отказу страницы (блок памяти, который недоступен в физической памяти, поскольку уже был выгружен на диск и теперь должен быть переставлен обратно в ОЗУ, чтобы снова доступно для нашего процесса)
Этап 4: Распределение памяти
Наш круг закрыт. Мы позвонили Unsafe.freeMemory
с оригинальным адресом памяти и освободили его от физического отображения. Мы можем заметить, что значение RES восстановилось на ~ 50 МБ.
Это было быстрое введение в то, как работает небезопасное распределение и перераспределение и что он делает с нашими инструментами мониторинга (top / htop в моем случае). Стоит отметить еще один трюк: отслеживание родной памяти .
Помощь от отслеживания родной памяти
Представьте, что мы наблюдаем за приложением как за черным ящиком, и мы не знаем, что приложение на самом деле делает подробно (без исходного кода). Предыдущий пример помог нам определить, что какая-то память растет внутри нашего процесса, но мы не знаем, что это за память. Это утечка памяти в нашей куче Java? Это какой-то больший блок внутренней памяти для нашего внутреннего кэша или файла с отображенной памятью?
Хотя анализ памяти Java Heap довольно прост, нам просто необходим инструмент, способный использовать значения JMX, экспортированные из нашего процесса, поскольку копание в исходную память немного сложнее. Давайте сделаем пример того, что мы можем извлечь из JVM. Запустите наше приложение с дополнительным флагом, который запустит Native Memory Tracking .
Оболочка
xxxxxxxxxx
1
java -XX:NativeMemoryTracking=summary UnsafeTest
Теперь выполните очень простую команду JCMD:
Оболочка
xxxxxxxxxx
1
jcmd <pid> VM.native_memory summary
2
Native Memory Tracking:
4
Total: reserved=6649506KB, committed=415302KB
6
- Java Heap (reserved=5064704KB, committed=317440KB)
7
(mmap: reserved=5064704KB, committed=317440KB)
8
9
- Class (reserved=1056927KB, committed=5919KB)
10
(classes #944)
11
( instance classes #843, array classes #101)
12
(malloc=159KB #1291)
13
(mmap: reserved=1056768KB, committed=5760KB)
14
( Metadata: )
15
( reserved=8192KB, committed=5120KB)
16
( used=1311KB)
17
( free=3809KB)
18
( waste=0KB =0,00%)
19
( Class space:)
20
( reserved=1048576KB, committed=640KB)
21
( used=161KB)
22
( free=480KB)
23
( waste=0KB =0,00%)
24
25
- Thread (reserved=20601KB, committed=977KB)
26
(thread #17)
27
(stack: reserved=20520KB, committed=896KB)
28
(malloc=59KB #104)
29
(arena=22KB #32)
30
31
- Code (reserved=247754KB, committed=7614KB)
32
(malloc=66KB #691)
33
(mmap: reserved=247688KB, committed=7548KB)
34
35
- GC (reserved=240137KB, committed=63969KB)
36
(malloc=18145KB #2579)
37
(mmap: reserved=221992KB, committed=45824KB)
38
39
- Compiler (reserved=100KB, committed=100KB)
40
(malloc=4KB #52)
41
(arena=96KB #4)
42
43
- JVMCI (reserved=5KB, committed=5KB)
44
(malloc=5KB #29)
45
46
- Internal (reserved=579KB, committed=579KB)
47
(malloc=547KB #1014)
48
(mmap: reserved=32KB, committed=32KB)
49
50
- Symbol (reserved=1343KB, committed=1343KB)
51
(malloc=983KB #6031)
52
(arena=360KB #1)
53
54
- Native Memory Tracking (reserved=226KB, committed=226KB)
55
(malloc=6KB #81)
56
(tracking overhead=220KB)
57
58
- Shared class space (reserved=16840KB, committed=16840KB)
59
(mmap: reserved=16840KB, committed=16840KB)
60
61
- Arena Chunk (reserved=165KB, committed=165KB)
62
(malloc=165KB)
63
64
- Logging (reserved=4KB, committed=4KB)
65
(malloc=4KB #185)
66
67
- Arguments (reserved=18KB, committed=18KB)
68
(malloc=18KB #477)
69
70
- Module (reserved=72KB, committed=72KB)
71
(malloc=72KB #1279)
72
73
- Synchronizer (reserved=24KB, committed=24KB)
74
(malloc=24KB #201)
75
76
- Safepoint (reserved=8KB, committed=8KB)
77
(mmap: reserved=8KB, committed=8KB)
Мы получили всю информацию о текущем выделении памяти из всех областей JVM. Это выходит за рамки этой статьи, чтобы пройти через все это. Но если вы хотите узнать больше, я могу порекомендовать эту статью .
Давайте сделаем базовый уровень на этапе 1 :
Оболочка
xxxxxxxxxx
1
jcmd <pid> VM.native_memory baseline
Затем нажмите «enter», перейдите к этапу 2 (распределение памяти) и создайте различие между этими двумя этапами:
Оболочка
xxxxxxxxxx
1
jcmd <pid> VM.native_memory summary.diff
2
- Other (reserved=51200KB +51200KB, committed=51200KB +51200KB)
3
(malloc=51200KB +51200KB #1 +1)
Мы можем увидеть раздел Other, содержащий выделенную собственную память от Unsafe и дополнительную информацию:
- Текущее значение выделенных байтов,
- Изменение между текущим значением и базовой линией
- № 1 количество выделений.
Чего нам ожидать от контейнера?
Теперь мы в нашем последнем примере, и это касается контейнера Docker . Как ведет себя внутренняя память внутри нашего контейнера? Как это повлияет, если мы попытаемся выделить больше, чем мы настроили во время запуска контейнера?
Давайте немного изменим наш код и сделаем из него агрессивного убийцу. Приложение начинает выделять 50 МБ каждые 500 миллионов и автоматически касается всех выделенных страниц, чтобы отразить это в памяти RSS.
Джава
xxxxxxxxxx
1
public static void main(String[] args) throws Exception {
2
Field f = Unsafe.class.getDeclaredField("theUnsafe");
3
f.setAccessible(true);
4
Unsafe unsafe = (Unsafe) f.get(null);
5
int i = 1;
7
while (true) {
8
int memoryBlock = 50 * 1024 * 1024;
9
long address = unsafe.allocateMemory(memoryBlock);
10
11
long currentAddress = address;
12
while (currentAddress < (address + memoryBlock)) {
13
unsafe.putByte(currentAddress, (byte) 0);
14
currentAddress += unsafe.pageSize();
15
}
16
System.out.println("ALLOCATED AND TOUCHED: " + i++);
17
Thread.sleep(500);
18
}
19
}
Выполните код внутри контейнера с помощью этой простой команды.
Оболочка
xxxxxxxxxx
1
docker run -it -v ${PWD}:/tmp adoptopenjdk/openjdk11 java /tmp/UnsafeTest.java
Вы можете заметить, что мы не ограничивали контейнер какими-либо параметрами, и это приводит к неконтролируемому росту памяти хоста и замене физической памяти на диск, чтобы выдержать скорость выделения.
Что если мы ограничим размер нашего контейнера? Каким будет его поведение после?
Оболочка
xxxxxxxxxx
1
docker run -it --memory 300m -v ${PWD}:/tmp adoptopenjdk/openjdk11 java /tmp/UnsafeTest.java
Мы ограничили наш контейнер до 300 МБ и снова провели тот же тест.
Мы видим, что память хранится на RSS 300 МБ. Однако, согласно заявлению журнала « ALLOCATED AND TOUCHED: 19», мы сделали 19 итераций, что эквивалентно ~ 950 МБ выделенной и затронутой памяти. Во фрагменте очень ясно, что ОС скрывает всю память свыше 300 МБ в памяти подкачки.
Давайте отключим подкачку и посмотрим, как быстро мы потерпим неудачу с нашей программой выделения памяти.
Оболочка
xxxxxxxxxx
1
docker run -it --memory 300m --memory-swappiness 0 -v ${PWD}:/tmp adoptopenjdk/openjdk11 java /tmp/UnsafeTest.java
Это то, что мы ожидали. Мы сделали 5 итераций, достигли 300 МБ и потерпели неудачу. Помните об этом поведении на производстве и спроектируйте свой Java-процесс (нативный + куча памяти) с учетом этого аспекта. Всегда проверяйте конфигурацию, переданную вашему контейнеру. Отключение подкачки приводит к лучшему разделению процессов. Поведение приложения более прозрачно, и нам нужно работать с четко определенным пространством.
В вашем планировщике контейнеров (Kubernetes, Mesos, Nomad, ..) очень вероятно отключен обмен.
Вы также можете проверить инструмент статистики D ocker , который отслеживает запущенные в данный момент контейнеры на хосте. Вы также можете заметить некоторые различия между размером резидентного набора и объемом памяти, сообщаемым статистикой докера . Причина в том, что RSS содержит всю физическую память, относящуюся к этому процессу, даже разделяемые библиотеки, которые могут быть подсчитаны несколько раз (для нескольких процессов). Инструмент статистики Docker имеет именно это описание (согласно официальной конфигурации):
«В Linux интерфейс командной строки Docker сообщает об использовании памяти, вычитая использование кеша страниц из общего объема используемой памяти. API не выполняет такие вычисления, а предоставляет общее использование памяти и объем из кеша страниц, чтобы клиенты могли использовать данные по мере необходимости."
Резюме
Поиграйте, попробуйте другие варианты использования, дайте нам знать о ваших выводах и инструментах, которые вы обычно используете. Спасибо за чтение моей статьи и, пожалуйста, оставьте комментарии ниже. Если вы хотите получать уведомления о новых сообщениях, тогда начните подписываться на меня в Twitter !