JavaRush /Курси /Docker for Spring /Памʼять JVM: heap, metaspace і native

Памʼять JVM: heap, metaspace і native

Docker for Spring
Рівень 23, Лекція 1
Відкрита

1. Памʼять JVM: не лише heap

Ми вже розвели зовнішній бюджет контейнера й внутрішню стелю heap. Тепер важливо зрозуміти, чому одного -Xmx усе одно недостатньо. Коли heap здається всією памʼяттю JVM, рука сама тягнеться лікувати будь-яку проблему з памʼяттю одним і тим самим прапорцем.

Коли ви чули фразу «Java жере памʼять», зазвичай малася на увазі heap, тобто «купа» обʼєктів. І це правда, але лише частково. У контейнері така думка швидко розбивається: можна поставити охайний -Xmx, побачити в логах, що heap далекий від стелі, а контейнер усе одно починає задихатися. Причина майже завжди одна: heap — не вся памʼять процесу.

Давайте відразу домовимося про терміни простими словами. Heap — це область, де живуть обʼєкти, які ви створюєте в коді: рядки, списки, DTO, сутності й усе таке. Але в JVM є ще metaspace (памʼять під «опис класів»), і є native memory (усе, що JVM і бібліотеки виділяють повз heap: стеки потоків, буфери, внутрішні структури, JIT тощо). Контейнеру байдуже, як це називається — він бачить один процес і рахує сумарно.

Щоб не перетворитися на «людину, яка лікує будь-який біль пігулкою -Xmx++», нам потрібно навчитися тримати в голові щонайменше три частини: heap, metaspace і native memory.

2. Матрьошка памʼяті: контейнер і JVM

Дуже зручно уявляти memory limit контейнера як «коробку фіксованого розміру», а Java-процес усередині неї — як набір відсіків, кожен із яких щось споживає. Heap — найпомітніший відсік, але не єдиний. Якщо один із решти відсіків розростеться, коробка все одно переповниться, навіть якщо heap виглядає пристойно.

Нижче — схема без зайвої системної магії, лише те, що потрібно для діагностики. Контейнер накладає загальний ліміт, а JVM розкладає памʼять по внутрішніх зонах, які разом мають у цей ліміт уміститися.

flowchart TB
  subgraph C["Ліміт памʼяті Docker (наприклад, 512MB)"]
    subgraph P["Java-процес (сервіс Spring Boot)"]
      H["Heap (обʼєкти Java)
-Xmx"] M["Metaspace (метадані класів)
не heap"] N["Native memory
стеки потоків, direct buffers, code cache, внутрішні структури JVM"] end end

Головна практична думка така: якщо ви обмежили контейнер, JVM живе всередині бюджету. І якщо ви впритул зайняли бюджет heap-ом, то не лишили місця на все інше. Це приблизно як купити валізу на 10 кг і покласти в неї 9.9 кг ноутбуків — місця під зарядку не залишиться, а потім ви ще дивуєтеся, чому валіза не закривається.

3. Heap: обʼєкти і три числа

Heap найчастіше стає першим підозрюваним з однієї простої причини: це та памʼять, з якою ми працюємо напряму. Ви пишете new ArrayList<>(), створюєте DTO, серіалізуєте JSON — і все це так чи інакше живе в heap. Тому, коли щось «по памʼяті», рука тягнеться до -Xmx. Але щоб правильно розуміти heap, потрібно розрізняти три числа: максимум (max), «виділено зараз» (total/committed) і «вільно всередині виділеного» (free).

Найпростіший спосіб подивитися на heap очима Java — використати Runtime.getRuntime(). Це не профайлер, але для швидкої діагностики та логів — чудовий старт.

import java.lang.Runtime;

// Беремо Runtime поточного Java-процесу
Runtime rt = Runtime.getRuntime();

// Переводимо байти в мегабайти, щоб цифри було зручно читати в логах
long maxHeapMb = rt.maxMemory() / 1024 / 1024;                 // стеля heap (приблизно -Xmx)
long committedHeapMb = rt.totalMemory() / 1024 / 1024;         // скільки heap уже закомічено в ОС
long freeInsideCommittedMb = rt.freeMemory() / 1024 / 1024;    // скільки вільно всередині закоміченого

System.out.println("maxHeapMb=" + maxHeapMb);                 // maxHeapMb=256
System.out.println("committedHeapMb=" + committedHeapMb);     // committedHeapMb=64
System.out.println("freeInsideCommittedMb=" + freeInsideCommittedMb); // freeInsideCommittedMb=40

Тут важливо не переплутати зміст. maxMemory() — це стеля heap (приблизно ваш -Xmx або значення, яке JVM обрала за замовчуванням). totalMemory() — це скільки heap вже закомічила JVM в операційній системі. JVM не зобовʼязана одразу брати весь -Xmx; вона може збільшувати heap поступово. А freeMemory() — це скільки вільно всередині вже закоміченого heap.

І ось тут зʼявляється класична пастка: freeMemory() іноді виглядає дуже заспокійливо, але контейнеру від цього ні тепло, ні холодно. Контейнеру важлива сумарна памʼять процесу, а freeMemory() говорить лише про один відсік цієї матрьошки.

Щоб відчути, що таке heap як саме обʼєкти, можна подумки привʼязати це до простого коду. Наприклад, ось такий масив майже гарантовано опиниться в heap:

// Це саме heap: створюємо великий масив байтів як звичайний Java-обʼєкт
byte[] block = new byte[10 * 1024 * 1024]; // 10 MB в heap

// Просто перевіряємо, щоб візуально побачити розмір (і не забути, що це байти)
System.out.println("blockSize=" + block.length); // blockSize=10485760

Створили масив — heap зріс, GC вирішуватиме, коли його можна прибрати, і якщо таких масивів буде багато, ви впертеся в -Xmx. Але… і це критично для контейнерів… heap може бути «в порядку», а памʼять процесу — ні. Чому? Бо в бюджеті бере участь не лише heap.

4. Metaspace: класи і проксі

Metaspace — це той шматок памʼяті, про який найчастіше не думають, доки він не нагадає про себе помилкою OutOfMemoryError: Metaspace. Якщо heap — це «дані», то metaspace — це «інструкції про те, які в нас є класи і як вони влаштовані». Кожен завантажений клас залишає слід у metaspace, а сучасні застосунки (особливо Spring Boot) завантажують дуже багато класів.

До того ж Spring (і не лише Spring) любить динаміку: проксі, рефлексію, генерацію допоміжних класів. Вам не потрібно зараз глибоко розуміти, як саме AOP робить проксі (це окремі курси), але корисно памʼятати наслідок: metaspace — це не рідкісна екзотика, а звичайна стаття витрат памʼяті у типовому Boot-сервісі.

Подивитися metaspace наживо можна через MemoryPoolMXBean.getMemoryPoolMXBeans(). Так, це вже трохи «доросліший» Java API, але він усе ще читається нормально, якщо не намагатися охопити неосяжне.

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;

// Шукаємо пул памʼяті, який відповідає Metaspace (у HotSpot зазвичай так і називається)
long metaspaceUsedMb = ManagementFactory.getMemoryPoolMXBeans().stream()
    .filter(p -> p.getName().contains("Metaspace")) // фільтр за назвою пулу
    .findFirst()
    .map(p -> p.getUsage().getUsed() / 1024 / 1024) // used -> у мегабайти для читабельного логу
    .orElse(0L); // якщо пулу немає (екзотична JVM/збірка) — нехай буде 0, щоб не падати

System.out.println("metaspaceUsedMb=" + metaspaceUsedMb); // metaspaceUsedMb=48

Тут є важливе застереження: назва пулу може відрізнятися в різних JVM або збірках, але в HotSpot зазвичай справді є пул з імʼям Metaspace. У контексті курсу нам важливо не ідеально покрити всі варіанти JVM, а навчитися бачити, що metaspace існує і його можна виміряти.

Практично це допомагає в двох ситуаціях. Перша — коли застосунок стартує і відразу зʼїдає помітний шматок памʼяті, хоча «ми ж ще нічого не робили». Це часто metaspace + інший non-heap. Друга — коли ви намагаєтеся стиснути контейнер за памʼяттю, а сервіс раптом падає не з Java heap space, а з помилками про metaspace. Тоді -Xmx не лікує, бо це зовсім інший відсік.

5. Native memory у контейнерному ліміті

Native memory звучить як щось страшне й системне, але насправді це лише чесна назва: це памʼять, яка виділяється не в heap. Її використовують внутрішні механізми JVM і бібліотеки, а інколи й ваш код напряму. Контейнер при цьому бачить лише факт: процес споживає памʼять. Йому байдуже, heap це чи не heap — ліміт один.

Якщо зробити дуже просту карту, то native memory у реальному Spring Boot-сервісі зазвичай складається з кількох великих категорій. Таблиця нижче не для того, щоб ви вивчили її напамʼять, а щоб у вас зʼявився словник симптомів.

Шматок памʼяті (спрощено) Що це за змістом Типовий симптом, якщо місця мало Як хоча б приблизно побачити
Thread stacks стек кожного потоку (так, у кожного!) багато потоків → памʼять іде «в нікуди», heap тут ні до чого непрямо: кількість потоків + знання -Xss
JIT / Code cache скомпільований машинний код, який генерує JVM рідко падає «в лоб», радше впливає на стабільність і продуктивність на junior-рівні зазвичай не чіпаємо
Direct buffers off-heap буфери для I/O (NIO) OutOfMemoryError: Direct buffer memory або OOM контейнера без heap-OOM BufferPoolMXBean, Actuator jvm.buffer.*
JVM internals службові структури, GC, таблиці тощо «просто не вистачає памʼяті», особливо за малого ліміту частіше за все бачимо лише сумарно
JNI / native libs нативні бібліотеки та їхні алокації непередбачувані симптоми, залежить від libs за ситуацією

Тут особливо важливі два пункти для контейнерної діагностики: стеки потоків і direct buffers. Вони легко зʼїдають десятки й сотні мегабайт, а початківець продовжує дивитися лише на heap і дивуватися, чому «все ж було нормально».

Про стеки потоків можна зрозуміти навіть без інструментів — просто арифметикою. Припустімо, у вас 200 потоків, а стек кожного — умовно 1MB (це груба оцінка, але для розуміння достатньо). Це вже приблизно 200MB native памʼяті. Жодних обʼєктів ви при цьому могли майже не створювати. Тому «багато потоків» і «малий контейнер» іноді — токсичне поєднання.

6. Direct buffers: памʼять поза heap

Direct buffer — це такий «чит-код» у хорошому сенсі для I/O: памʼять виділяється поза heap, і її можна використовувати ефективніше для деяких операцій читання та запису. Проблема в тому, що ця памʼять усе одно належить процесу, а отже входить у контейнерний ліміт. Ба більше, якщо direct memory закінчується, ви можете отримати цілком конкретну помилку виду OutOfMemoryError: Direct buffer memory, і це буде не heap.

Найзрозуміліший спосіб показати різницю — виділити direct buffer вручну. Це не те, що ви робитимете щодня в бізнес-коді нашого каталогу, але це допомагає на дотик відчути модель памʼяті.

import java.nio.ByteBuffer;

// Виділяємо 10MB direct-памʼяті (off-heap). Це НЕ heap, але це памʼять процесу.
ByteBuffer direct = ByteBuffer.allocateDirect(10 * 1024 * 1024);

// Ознака, що буфер справді direct (а не звичайний heap ByteBuffer)
System.out.println("isDirect=" + direct.isDirect());     // isDirect=true
System.out.println("capacity=" + direct.capacity());     // capacity=10485760

Це виділення памʼяті поза heap. Обʼєкт direct (як Java-обʼєкт) живе в heap, але його внутрішності — поза heap. І ось тут початківці часто плутаються: «я ж створив обʼєкт, значить heap». Не завжди. У Java є конструкції, які тримають «ручку» на зовнішню памʼять.

Подивитися, скільки direct memory зараз зайнято, можна через BufferPoolMXBean.getPlatformMXBeans(). Це вже трохи ближче до інструментів, але код усе ще короткий і зрозумілий.

import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;

// BufferPoolMXBean дає статистику по буферах (heap/direct) на рівні JVM
long directUsedMb = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class).stream()
    .filter(b -> b.getName().equals("direct")) // нам потрібен саме direct-пул
    .findFirst()
    .map(b -> b.getMemoryUsed() / 1024 / 1024) // у мегабайти, щоб співставляти з лімітом контейнера
    .orElse(0L);

System.out.println("directUsedMb=" + directUsedMb); // directUsedMb=12

Чому це важливо саме для Docker-курсу? Бо в контейнері вам потрібно тримати звʼязок: «памʼять процесу» ≈ heap + metaspace + native (включно з direct). Якщо ви бачите ріст памʼяті контейнера, а heap не росте, direct buffers — один із перших кандидатів на перевірку.

7. Логи памʼяті на старті сервісу

Зараз ми не хочемо перетворювати курс на JVM internals, тому мета проста: сервіс має сам підказувати в логах базову картину, особливо коли ми запускаємо його під лімітами. maxHeapMb у нас уже є у стартовому runtime snapshot. Щоб поруч побачити non-heap, не потрібен окремий startup-class — достатньо розширити той самий RuntimeSnapshotConfig.

// Усередині вже наявного RuntimeSnapshotConfig
@Bean
ApplicationRunner logRuntimeSnapshot() {
    return args -> {
        MemoryMXBean bean = ManagementFactory.getMemoryMXBean();

        long maxHeapMb = Runtime.getRuntime().maxMemory() / 1024 / 1024;
        long usedNonHeapMb = bean.getNonHeapMemoryUsage().getUsed() / 1024 / 1024;

        log.info("Стан Runtime: maxHeapMb={}, usedNonHeapMb={}",
                maxHeapMb, usedNonHeapMb);
    };
}

Цього вже достатньо, щоб під час старту бачити дві опорні цифри: скільки heap JVM готова зайняти і скільки памʼяті вже пішло в non-heap. Нам не потрібен профайлер на кожен запуск; потрібен чесний ранній сигнал, який легко зіставити з docker stats і лімітом контейнера.

Зверніть увагу: ми свідомо не друкуємо двадцять метрик. Нам потрібна мінімальна картина — heap і non-heap. Коли сигналів небагато, легше зрозуміти, чи впираємося ми в memory budget, а не потонути в шумі.

8. Memory budget без «магії відсотків»

Тепер, коли heap перестав бути єдиною картинкою, можна дати одну робочу стартову матрицю. Хочеться запитати: «Гаразд, а скільки тоді ставити -Xmx, якщо контейнеру дали 512MB?» І це нормальне запитання. Але важливо розуміти: немає однієї магічної цифри, бо різні застосунки по-різному витрачають native памʼять. Зате тепер у нас хоча б є причина для headroom, а не просто забобон.

Для нашого навчального Spring Boot сервісу можна тримати просту чорнову модель: якщо контейнеру дали M мегабайт, то heap зазвичай займає певну частку, а решта — запас під non-heap/native. На старті курсу можна мислити так: -Xmx десь у районі половини–двох третин ліміту контейнера часто виявляється безпечнішою відправною точкою, ніж «майже весь ліміт».

Нижче — не догма, а ілюстрація, щоб мозок перестав прирівнювати --memory і -Xmx:

Ліміт памʼяті контейнера Приклад «обережного» -Xmx Чому не більше
256MB 128m160m metaspace + потоки + буфери легко зʼїдають решту
384MB 192m256m вже комфортніше, але «впритул» усе ще ризик
512MB 256m320m зʼявляється запас під native, особливо на старті Boot
1024MB 512m768m зазвичай вистачає, але все залежить від навантаження і потоків

Цю таблицю зручно тримати як стартову матрицю, а далі звіряти з логами й docker stats.

Ще раз: мета — не назвати «правильні» числа, а навчити вас мислити бюджетом. У реальному житті ви перевіряєте це спостереженням: логи, метрики, docker stats, і вже потім підбираєте значення. Але якщо ваша початкова гіпотеза — heap = 99% ліміту контейнера, ви майже гарантовано стартуєте з конфігурації, яка падає від першого ж чхання.

9. Типові помилки: heap, metaspace, native

Помилка №1: вважати, що вся памʼять Java-процесу — це heap.
Це найчастіша ментальна пастка. Вона призводить до того, що під час будь-якої проблеми з памʼяттю ви починаєте крутити -Xmx, навіть якщо реальний споживач — metaspace, стеки потоків або direct buffers. Лікується просто: тримайте в голові «три шматки» і спершу шукайте, який із них росте за симптомами та метриками.

Помилка №2: трактувати Runtime.freeMemory() як «вільну памʼять контейнера».
freeMemory() показує лише вільне місце всередині вже виділеного heap. Контейнеру байдуже, що heap усередині себе вільний, якщо metaspace і native зʼїли решту бюджету. Це особливо підступно, бо цифра freeMemory() виглядає заспокійливо. Правильна звичка: freeMemory() — лише heap-сигнал, не загальний.

Помилка №3: ігнорувати metaspace, доки не прилетить OutOfMemoryError: Metaspace.
Metaspace не завжди помітний у щоденній розробці, але Spring Boot-сервіс на старті завантажує гори класів, і це реальна стаття витрат памʼяті. Якщо ви стискаєте контейнер впритул, metaspace може стати першою стіною. Хороший мінімум — уміти вимірювати metaspace через MemoryPoolMXBean або хоча б бачити non-heap через MemoryMXBean.

Помилка №4: забувати, що direct buffers — це off-heap памʼять, але в межах ліміту контейнера.
Direct buffers легко сприймати як якусь внутрішню частину Java, доки ви не зіткнетеся з помилкою Direct buffer memory або контейнерним OOM без heap-OOM. У Docker-контексті важливо памʼятати: усе, що виділяє процес, потрапляє в загальний лічильник, навіть якщо це не heap. Отже, «heap нормальний» не означає «памʼять нормальна».

Помилка №5: намагатися «перемогти памʼять» лише налаштуванням JVM, ігноруючи кількість потоків.
У потоків є стеки, а стеки — це реальна native-памʼять. Якщо застосунок створює багато потоків або це роблять бібліотеки, то за малого контейнера ви можете впертися в ліміт, навіть не роблячи нічого, повʼязаного з памʼяттю, у бізнес-логіці. Це не заклик терміново оптимізувати все підряд, а прохання памʼятати: потік — теж витрата памʼяті.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ