Java Internals & Concurrency/Metaspace, stack và native memory — đọc chữ ký OOM từng vùng
57/75
Bài 57 / 75~14 phútJVM InternalsMiễn phí lượt xem

Metaspace, stack và native memory — đọc chữ ký OOM từng vùng

Ngoài heap, JVM còn metaspace, thread stack và native memory. Mỗi vùng cạn theo một chữ ký OOM riêng — nhìn dòng lỗi là biết vùng nào hỏng và fix ở đâu.

TL;DR: Heap không phải toàn bộ bộ nhớ JVM. Class metadata sống trong metaspace (native memory, thay PermGen từ Java 8); frame của mỗi lời gọi method sống trong thread stack (mỗi thread một stack riêng, mặc định ~1 MB); buffer I/O và JIT code sống trong native memory ngoài heap. Điều đáng giá nhất khi debug production: mỗi vùng cạn theo một chữ ký lỗi riêng. OutOfMemoryError: Java heap space là heap; : Metaspace là class metadata phình; dòng chứa direct buffer memory là direct buffer; unable to create native thread là hết chỗ cho thread stack; còn StackOverflowError (không phải OOM) là một stack đơn lẻ tràn vì đệ quy sâu. Nhìn đúng dòng chữ ký là biết ngay vùng nào hỏng và fix ở đâu.

Bài 05 đã mổ heap: young/old, bump-the-pointer. Nhưng khi production ném OutOfMemoryError, tăng -Xmx chưa chắc cứu được — vì có thể vùng cạn không phải heap. Một app hot-reload tới lần thứ ~50 chết vì metaspace vượt 512 MB; một app Netty rò direct buffer chết dù heap còn trống; một app tạo ~4000 thread chết vì native cạn. Cả ba đều là "OutOfMemoryError" nhưng ba nguyên nhân, ba cách fix khác nhau.

Bài này dạy bạn đọc chữ ký OOM: mỗi vùng ngoài heap là gì, hỏng vì sao, và dòng lỗi nào tố cáo nó.

1. Analogy — toà nhà văn phòng nhiều loại không gian

Heap là nhà kho (bài 05). Nhưng một công ty còn nhiều loại không gian khác, mỗi loại đầy theo cách riêng:

Không gian văn phòngVùng JVM"Đầy" nghĩa là
Thư viện bản thiết kếMetaspace (class metadata)Quá nhiều bản thiết kế (class)
Bàn làm việc mỗi nhân viênThread stack (frame method)Một người xếp giấy chồng quá cao (đệ quy sâu)
Sân ngoài, kho thuê ngoàiNative / direct memoryHết đất trống ngoài kho chính

Nhân viên (thread) ai cũng có bàn riêng; đồ đang làm (frame) xếp trên bàn. Bản thiết kế (class metadata) để trong thư viện dùng chung. Hàng quá khổ hoặc thuê ngoài để ngoài sân (native). Khi một loại không gian đầy, biển báo lỗi khác nhau — và đó chính là chữ ký OOM.

flowchart TB
    subgraph JVM[Bo nho JVM process]
        H["Heap (-Xmx)<br/>object: young + old"]
        M["Metaspace (native)<br/>class metadata"]
        S["Thread stacks<br/>1 stack / thread, ~1 MB"]
        N["Native / direct<br/>DirectByteBuffer, code cache, JNI"]
    end
    H -.->|"OOM: Java heap space"| H
    M -.->|"OOM: Metaspace"| M
    S -.->|"StackOverflowError"| S
    N -.->|"OOM: Direct buffer / native thread"| N
💡 Cách nhớ

Ngoài heap (object) còn ba vùng: metaspace (class metadata), stack (frame method, mỗi thread một cái), native (buffer I/O, JIT code). Mỗi vùng có một dòng lỗi riêng — nhớ dòng lỗi là chẩn được vùng.

2. Metaspace — vì sao Java 8 thay PermGen?

Metaspace chứa class metadata: cấu trúc Klass (method table, runtime constant pool, field layout), bytecode của method đã load. Mỗi class load tốn vài tới chục kilobyte; app điển hình 5000 class → khoảng 25–50 MB metaspace.

Trước Java 8, vùng này là PermGen (Permanent Generation) — nằm trong heap, kích thước cố định qua -XX:MaxPermSize. Ba vấn đề khiến nó bị thay:

  • Kích thước cố định khó tune: app load class động (Spring CGLIB proxy, Hibernate entity proxy, Mockito mock) khó dự đoán cần bao nhiêu. Đặt thấp → OOM; đặt cao → phí RAM.
  • Nằm trong heap: GC class metadata trộn chung chu kỳ với object, class-loader unload kém.
  • OutOfMemoryError: PermGen space thành lỗi kinh điển khi container hot redeploy.

Java 8 (JEP 122) chuyển sang Metaspace trong native memory, mặc định không giới hạn — tự grow theo nhu cầu (chặn bởi RAM máy). Class-loader unload tốt hơn: loader unreachable thì JVM free toàn bộ metadata của loader đó.

java -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m MyApp
  • -XX:MetaspaceSize: mốc high-water ban đầu (mặc định ~21 MB) — vượt thì trigger một lần GC dọn class.
  • -XX:MaxMetaspaceSize: trần cứng. Vượt → OutOfMemoryError: Metaspace.
Default không giới hạn là con dao hai lưỡi

Metaspace mặc định unbounded nghĩa là class-loader leak sẽ ăn hết RAM máy rồi bị kernel OOM kill (im lặng, không stack trace Java). Production nên đặt -XX:MaxMetaspaceSize để fail nhanh bằng OutOfMemoryError: Metaspace (có stack trace, debug được) thay vì để kernel giết âm thầm.

3. Thread stack — frame method và StackOverflowError

Mỗi threadmột stack riêng, mặc định khoảng 1 MB (Linux x64; tuỳ nền tảng, chỉnh bằng -Xss). Stack chứa một frame cho mỗi lời gọi method đang chạy — frame giữ local variable, operand stack, return address. Method return thì frame pop; toàn bộ tự dọn, không cần GC.

Đệ quy quá sâu chồng frame vượt dung lượng stack:

void recurse() {
    recurse();   // khong co diem dung
}
💡 Thử đoán: vùng nào, lỗi gì?

Method recurse() trên gọi chính nó vô hạn. Nó sẽ ném lỗi gì? Đó có phải một OutOfMemoryError không, và vì sao? Viết ra câu trả lời trước khi đọc tiếp.

Nó ném StackOverflowError — một stack đơn lẻ tràn, không phải toàn bộ bộ nhớ JVM cạn (nên không phải OOM). Với stack 1 MB và mỗi frame cỡ trăm byte, giới hạn rơi vào khoảng chục nghìn mức đệ quy; method nhiều local + sâu thì ít mức hơn.

Cách fix theo thứ tự đúng:

  1. Sửa thuật toán: chuyển đệ quy sâu thành vòng lặp với explicit stack (ví dụ DFS trên cây đường kính lớn). Đây là fix gốc.
  2. Tăng -Xss chỉ khi đệ quy hợp lệ mà cần sâu hơn — trade-off: 1000 thread × 2 MB stack = 2 GB native memory chỉ cho stack.

Tăng -Xss để "chữa" đệ quy vô hạn là vô nghĩa — chỉ đẩy điểm tràn xa hơn chút rồi vẫn tràn.

4. Native / direct memory — ngoài heap, ngoài -Xmx

Heap và metaspace chưa phải hết. Native memory (off-heap) chứa nhiều thứ không tính vào -Xmx:

  • Direct ByteBuffer: ByteBuffer.allocateDirect(n) cấp phát qua malloc ngoài heap, cho I/O hiệu năng cao (NIO FileChannel, Netty) — tránh một lần copy staging qua heap (GC không di chuyển buffer native), kernel đọc/ghi thẳng vào buffer. Bộ nhớ native của buffer được Cleaner (cơ chế phantom-reference) tự giải phóng khi buffer bị GC, nhưng chạy trễ nên buffer tồn dư một lúc. Trần: -XX:MaxDirectMemorySize (mặc định xấp xỉ -Xmx). Vượt → OutOfMemoryError: Cannot reserve N bytes of direct buffer memory (...) — dấu nhận là cụm direct buffer memory (Java 8 in gọn Direct buffer memory; từ JDK 11 là câu đầy đủ này).
  • JIT code cache: native code do JIT biên dịch. Trần -XX:ReservedCodeCacheSize (mặc định 240 MB). Đầy → JIT ngừng compile, app rớt về interpreter (chậm hẳn) — theo dõi bằng jcmd <pid> VM.codecache_info.
  • Thread stacks: 1000 thread × 1 MB = 1 GB native. Hết chỗ tạo thread mới → OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached (thường do vượt OS limit ulimit -u hoặc native memory cạn).
  • JNI (Java Native Interface) / thư viện native: code C/C++ tự malloc, JVM không track — leak ở đây không hiện trong heap dump.
💡 Thử đoán: đọc chữ ký này ra vùng nào?

Một app Netty xử lý nhiều kết nối chết với dòng: java.lang.OutOfMemoryError: Cannot reserve 16777216 bytes of direct buffer memory (allocated: ..., limit: ...). Vùng nào cạn? Tăng -Xmx có cứu được không, và vì sao? Tự trả lời trước khi đọc mục 5.

5. Bảng chữ ký OOM — nhìn dòng lỗi là biết vùng

Đây là phần cốt lõi. Trước khi xem bảng đầy đủ, với mỗi dòng lỗi dưới tự điền hai thứ: (1) vùng nào cạn, (2) tăng -Xmx có cứu được không — rồi so với đáp án:

  • ...: Java heap space → vùng nào?
  • ...: Metaspace → vùng nào?
  • ...: Direct buffer memory → vùng nào?
  • ...: unable to create native thread → vùng nào?
  • StackOverflowError → vùng nào?

Đáp án và nguyên nhân gốc:

Chữ ký (dòng lỗi)Vùng cạnNguyên nhân gốcFix hướng tới
OutOfMemoryError: Java heap spaceHeapLeak (static map không bound), heap quá nhỏ, hoặc object quá lớnHeap dump → MAT tìm leak; hoặc tăng -Xmx
OutOfMemoryError: MetaspaceMetaspace (native)Class-loader leak (hot reload, redeploy), sinh class động vô hạnjcmd VM.classloader_stats; restart thay hot reload; đặt MaxMetaspaceSize
OOM: Cannot reserve N ... direct buffer memory (...)Native (direct)Nhiều DirectByteBuffer không release (Netty, gRPC), Cleaner chậmTăng MaxDirectMemorySize; audit release buffer
OOM: unable to create native thread: ...Native (thread stack)Vượt OS limit ulimit -u, hoặc native cạn, thread leakulimit -u cao hơn; audit pool/executor không shutdown
StackOverflowErrorMột thread stackĐệ quy quá sâu (thường không có điểm dừng)Chuyển sang vòng lặp; -Xss chỉ khi đệ quy hợp lệ
Hai chữ ký họ hàng dễ nhầm

GC overhead limit exceeded vẫn là heap: GC tốn vượt 98% thời gian mà reclaim chưa tới 2% heap → JVM bỏ cuộc sớm (họ hàng "Java heap space"). Kernel OOM Kill (Linux) KHÔNG phải Java exception — không stack trace; dmesg hiện "killed process (java)". Xảy ra khi tổng heap + metaspace + native + stack vượt cgroup limit (Linux control group — trần RAM cấp cho container) của container.

6. Ngân sách bộ nhớ trong container

Vì native memory không tính vào -Xmx, đặt -Xmx bằng đúng RAM container là công thức bị kernel OOM kill:

Container 4 GB:
  Heap (-Xmx)              2.5 GB
  Metaspace (max)          256 MB
  Direct memory (max)      256 MB
  Code cache               240 MB
  Thread stacks (200)      200 MB
  Native/JNI/internal      ~300 MB
  --------------------------------
  Tong                     ~3.7 GB   -> chua 300 MB dem cho OS

Quy tắc: -Xmx chỉ nên chiếm khoảng 50–70% RAM container, chừa phần còn lại cho non-heap. Đặt -Xmx4g cho container 4 GB thì heap phình đẩy native vượt limit → kernel giết JVM im lặng.

7. Pitfall tổng hợp

Nhầm 1: Gặp bất kỳ OutOfMemoryError nào cũng tăng -Xmx. ✅ Đọc chữ ký trước: Metaspace/Direct buffer memory/unable to create native thread đều KHÔNG cứu được bằng -Xmx — có khi tăng -Xmx còn làm native tệ hơn (ép kernel OOM).

Nhầm 2: Hot reload liên tục (Spring DevTools, Tomcat redeploy) mà không restart. ✅ Mỗi lần reload để lại một ClassLoader cũ giữ class metadata → OutOfMemoryError: Metaspace. Restart đủ chu kỳ, hoặc đặt MaxMetaspaceSize để fail nhanh khi leak.

Nhầm 3: catch (OutOfMemoryError e) { retry(); }. ✅ OOM nghĩa vùng không reclaim được — retry trong cùng JVM cũng OOM. Fix root cause hoặc restart, đừng nuốt lỗi.

8. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: Khi nghi native memory (không phải heap) là thủ phạm, Native Memory Tracking là công cụ chuẩn — nó chia rõ heap / metaspace / thread / code cache / direct, thứ mà heap dump không thấy.

9. Liên hệ các bài khác

10. Tóm tắt

  • Ngoài heap (object), JVM còn metaspace (class metadata, native), thread stack (frame method, mỗi thread một cái), native/direct (buffer I/O, JIT code, JNI).
  • Metaspace thay PermGen từ Java 8 (JEP 122): native memory, mặc định không giới hạn; class-loader leak (hot reload) làm nó phình → OutOfMemoryError: Metaspace.
  • Thread stack ~1 MB/thread (Linux x64); đệ quy quá sâu tràn một stack → StackOverflowError (không phải OOM). Fix gốc là chuyển sang vòng lặp, không phải tăng -Xss.
  • Native/direct không tính vào -Xmx: DirectByteBuffer (trần MaxDirectMemorySize-Xmx), code cache (240 MB), thread stacks, JNI.
  • Chữ ký OOM chỉ đích vùng: Java heap space (heap), Metaspace, direct buffer memory (direct), unable to create native thread (native/stack). Đọc đúng dòng là biết fix ở đâu — tăng -Xmx không phải luôn đúng.
  • Container: -Xmx chỉ nên 50–70% RAM, chừa cho non-heap, tránh kernel OOM kill im lặng.

11. Tự kiểm tra

Tự kiểm tra
Q1
Metaspace khác PermGen thế nào, và vì sao Java 8 thay?

PermGen (Java 7 trở xuống) nằm trong heap, kích thước cố định (-XX:MaxPermSize). Ba vấn đề: khó tune cho app sinh class động (Spring/Hibernate/Mockito); GC class metadata trộn chung chu kỳ heap, unload kém; lỗi OutOfMemoryError: PermGen space phổ biến khi hot redeploy.

Metaspace (Java 8+, JEP 122) nằm trong native memory, mặc định không giới hạn, tự grow, class-loader unload tốt hơn. Caveat: unbounded nghĩa là leak có thể ăn hết RAM rồi bị kernel OOM kill im lặng — production nên đặt -XX:MaxMetaspaceSize để fail nhanh bằng OutOfMemoryError: Metaspace có stack trace.

Q2
Đọc từng dòng lỗi sau, gọi tên vùng cạn và nêu một nguyên nhân gốc: (a) Metaspace (b) unable to create native thread (c) dòng chứa direct buffer memory.
  • (a) Metaspace (native) — class metadata phình. Gốc: class-loader leak (hot reload, redeploy giữ ClassLoader cũ), hoặc sinh class động vô hạn. Fix: jcmd VM.classloader_stats, restart thay hot reload, đặt MaxMetaspaceSize.
  • (b) Native memory / thread stack — hết chỗ tạo thread mới. Gốc: vượt OS limit ulimit -u, native cạn (mỗi thread ~1 MB stack), hoặc thread leak (pool/executor không shutdown). Fix: nâng ulimit -u, audit thread leak.
  • (c) Native (direct buffer) — DirectByteBuffer vượt trần. Gốc: nhiều buffer không release (Netty, gRPC), Cleaner chậm. Fix: tăng MaxDirectMemorySize, audit release() buffer.

Điểm chung: cả ba đều không cứu được bằng tăng -Xmx — chúng nằm ngoài heap.

Q3
Vì sao StackOverflowError không phải một OutOfMemoryError, và vì sao tăng -Xss hiếm khi là fix đúng?

StackOverflowErrormột thread stack đơn lẻ tràn (frame chồng vượt ~1 MB), không phải toàn bộ bộ nhớ JVM cạn — nên nó thuộc họ Error khác, không phải OOM. Nguyên nhân gần như luôn là đệ quy quá sâu (thường thiếu điểm dừng).

Tăng -Xss chỉ dời điểm tràn xa hơn chút: đệ quy vô hạn vẫn tràn, còn đệ quy hợp lệ nhưng rất sâu thì nên chuyển thành vòng lặp với explicit stack (fix gốc). Ngoài ra -Xss lớn nhân với số thread tốn native memory nhanh (1000 thread × 2 MB = 2 GB).

Q4
Vì sao đặt -Xmx bằng đúng RAM container (ví dụ -Xmx4g cho container 4 GB) lại gây kernel OOM kill?

-Xmx chỉ giới hạn heap, không tính các vùng non-heap: metaspace, thread stacks, direct buffer, JIT code cache, JNI. Tổng bộ nhớ JVM = heap + tất cả những vùng đó.

Nếu heap được phép chiếm trọn 4 GB, thì metaspace/native/stack đẩy tổng vượt cgroup limit của container → kernel giết process (SIGKILL) im lặng, không Java exception, không stack trace (chỉ dmesg ghi lại). Vì vậy quy tắc là -Xmx ≈ 50–70% RAM container, chừa phần còn lại cho non-heap.

Q5
Direct buffer (off-heap) khác byte array trên heap ở điểm nào, và khi nào nên dùng?

Heap ByteBufferbyte[] trên heap, JVM quản qua GC. Direct ByteBuffer cấp phát qua malloc ngoài heap, JVM giữ reference + Cleaner để free. Direct nhanh hơn cho I/O vì tránh một lần copy staging qua heap (kernel đọc/ghi thẳng vào địa chỉ buffer) và địa chỉ ổn định (GC không di chuyển như object trên heap).

Nên dùng direct cho I/O lớn và buffer sống lâu, tái dùng nhiều lần (Netty pool buffer); tránh cho buffer nhỏ ngắn hạn vì malloc/free chậm hơn bump-pointer heap và free phụ thuộc Cleaner. Luôn đặt -XX:MaxDirectMemorySize để tránh leak âm thầm.

Bài tiếp theo: Garbage Collection — reachability và ba thuật toán nền

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Garbage Collection — reachability và ba thuật toán nền tảng