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.

Hai khung: trong -Xmx chỉ có heap với chữ ký OutOfMemoryError Java heap space; ngoài -Xmx là metaspace, thread stack và native direct, mỗi vùng kèm chữ ký lỗi riêng và tăng -Xmx không cứu được vùng nào ở đây

💡 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

  • JVM dùng thêm: metaspace (class metadata, native — thay PermGen từ Java 8, JEP 122), thread stack (~1 MB/thread, Linux x64), code cache (240 MB mặc định), native/direct (DirectByteBuffer). Tất cả không tính vào -Xmx.
  • Metaspace mặc định không giới hạn — production phải đặt -XX:MaxMetaspaceSize để fail fast thay vì để kernel OOM kill im lặng. Hot reload leak class-loader → metaspace phình.
  • StackOverflowError = đệ quy quá sâu; fix gốc là chuyển sang vòng lặp, không phải tăng -Xss — tăng stack che thuật toán lỗi thay vì sửa nó.
  • Chữ ký OOM: Java heap space / Metaspace / direct buffer memory / unable to create native thread. Container: -Xmx chỉ 50–70% RAM, chừa non-heap, tránh kernel OOM kill im lặng.

11. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Metaspace khác PermGen thế nào, và vì sao Java 8 thay?
  2. 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.
  3. Q3
    Vì sao StackOverflowError không phải một OutOfMemoryError, và vì sao tăng -Xss hiếm khi là fix đúng?
  4. Q4
    Vì sao đặt -Xmx bằng đúng RAM container (ví dụ -Xmx4g cho container 4 GB) lại gây kernel OOM kill?
  5. Q5
    Direct buffer (off-heap) khác byte array trên heap ở điểm nào, và khi nào nên dùng?

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