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 | 128m–160m | metaspace + потоки + буфери легко зʼїдають решту |
| 384MB | 192m–256m | вже комфортніше, але «впритул» усе ще ризик |
| 512MB | 256m–320m | зʼявляється запас під native, особливо на старті Boot |
| 1024MB | 512m–768m | зазвичай вистачає, але все залежить від навантаження і потоків |
Цю таблицю зручно тримати як стартову матрицю, а далі звіряти з логами й 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-памʼять. Якщо застосунок створює багато потоків або це роблять бібліотеки, то за малого контейнера ви можете впертися в ліміт, навіть не роблячи нічого, повʼязаного з памʼяттю, у бізнес-логіці. Це не заклик терміново оптимізувати все підряд, а прохання памʼятати: потік — теж витрата памʼяті.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ