Câu hỏi tưởng đơn giản: đặt -Xmx512m thì tiến trình Java chiếm bao nhiêu RAM?
Câu trả lời không phải 512MB, và cũng không phải "512MB cộng một chút". Bài này mổ xẻ từng vùng bằng Native Memory Tracking — công cụ có sẵn trong JDK mà ít người bật.
Chuyện này rất thực tế nếu bạn chạy Java trong container: đặt giới hạn bộ nhớ theo -Xmx là cách chắc chắn để bị hệ điều hành giết tiến trình.
Bật Native Memory Tracking
java -XX:NativeMemoryTracking=summary MyApp
jcmd <pid> VM.native_memory summary
Chi phí khoảng 5–10% hiệu năng, nên đừng bật thường trực trên máy chủ — nhưng khi đang đi tìm nguyên nhân rò rỉ bộ nhớ thì nó là công cụ duy nhất trả lời được câu "phần RAM đó nằm ở đâu".
JVM khởi động, chưa làm gì
-Xmx512m, container 2GB, chương trình chỉ ngủ:
Total: reserved=2033029KB, committed=136869KB
Java Heap (reserved=524288KB, committed= 34816KB)
Class (reserved=1048873KB, committed= 1705KB)
Thread (reserved= 53114KB, committed= 1198KB)
Code (reserved=248065KB, committed= 9285KB)
GC (reserved= 70309KB, committed= 60749KB)
Symbol (reserved= 3945KB, committed= 3945KB)
Shared class space (committed=12992KB)
RSS thật: 111 MB
Một trăm mười một megabyte cho một chương trình không làm gì.
Heap mới dùng 34MB trong 512MB được phép. Phần còn lại là những vùng mà -Xmx không quản:
Code — bộ nhớ đệm mã máy do JIT sinh ra. Ứng dụng chạy lâu sẽ tăng lên hàng chục megabyte.
GC — cấu trúc dữ liệu nội bộ của bộ thu gom rác. Chú ý nó chiếm 60MB, gần gấp đôi heap đang dùng. G1 cần bảng ghi nhớ tỷ lệ với kích thước heap tối đa, không phải kích thước đang dùng.
Symbol — bảng tên lớp, tên phương thức, chuỗi hằng.
Shared class space — dữ liệu lớp JDK chia sẻ được giữa các tiến trình (CDS).
Đọc "reserved" khác "committed"
Đây là chỗ dễ hiểu lầm nhất, và có một ví dụ rất rõ. Tôi tạo 500 luồng nền tảng:
Thread (reserved=1072759KB, committed=37507KB)
RSS thật: 140 MB
Đặt chỗ hơn một gigabyte, dùng thật 37 megabyte.
reserved là không gian địa chỉ ảo được giữ trước — hệ điều hành hứa "vùng này của anh" nhưng chưa cấp RAM thật. committed là phần đã thật sự chạm tới.
Mỗi luồng có ngăn xếp 1MB (mặc định -Xss), nên 500 luồng đặt chỗ khoảng 500MB cộng phần dôi. Nhưng ngăn xếp chỉ được cấp RAM theo trang khi luồng thật sự dùng tới độ sâu đó — mà luồng đang ngủ thì gần như không dùng gì.
Bài học vận hành: theo dõi committed và RSS, đừng hoảng vì reserved. Con số reserved trên máy 64-bit gần như vô nghĩa.
Heap: phần dễ hiểu nhất
Cấp 200MB mảng byte:
Java Heap (committed=214016KB)
RSS thật: 292 MB
Heap tăng đúng như mong đợi, RSS tăng theo. Đây là vùng duy nhất -Xmx kiểm soát, và cũng là vùng duy nhất bộ thu gom rác dọn.
Bộ nhớ ngoài heap: chỗ -Xmx mù hoàn toàn
Cấp 200MB bằng ByteBuffer.allocateDirect:
Java Heap (committed= 34816KB) <- KHÔNG đổi
Other (committed=195323KB) <- 195MB nằm ở đây
RSS thật: 303 MB
Heap vẫn 34MB. -Xmx512m vẫn còn nguyên chỗ trống. Nhưng tiến trình chiếm 303MB.
Đây là nguồn của loại sự cố khó chịu nhất trong container: ứng dụng bị hệ điều hành giết vì vượt giới hạn bộ nhớ, trong khi mọi biểu đồ heap đều bình thường và không có OutOfMemoryError nào trong log.
Ai cấp bộ nhớ ngoài heap? Netty và các thư viện I/O hiệu năng cao, FileChannel.map, JDBC driver, thư viện nén, và mọi thứ dùng JNI. Giới hạn bằng -XX:MaxDirectMemorySize; mặc định nó bằng -Xmx, tức là bạn có thể vô tình cho phép gấp đôi.
Metaspace: nơi lớp sống
20.000 lớp proxy -> metaspace tăng 91.153 KB (4.667 byte mỗi lớp)
metaspace hiện dùng: 101.927 KB
Khoảng 4,7KB cho mỗi lớp. Một ứng dụng Spring cỡ vừa nạp mười tới hai mươi nghìn lớp, nên 50–100MB metaspace là bình thường.
Điều quan trọng: metaspace mặc định không có trần. Nó nằm ngoài heap và lớn tới khi máy hết RAM. Java 7 trở về trước gọi nó là PermGen và có trần mặc định; đổi sang metaspace là để bỏ đúng cái lỗi "PermGen space" kinh điển, nhưng đổi lại rò rỉ classloader giờ ăn hết RAM máy thay vì báo lỗi sớm.
Đặt trần bằng -XX:MaxMetaspaceSize. Tôi khuyên luôn đặt trong container — thà hỏng có thông báo còn hơn bị hệ điều hành giết không dấu vết.
Năm kiểu hết bộ nhớ, năm thông báo
heap -> java.lang.OutOfMemoryError: Java heap space
ngoài -> java.lang.OutOfMemoryError: Cannot reserve 1000000 bytes of direct buffer memory
(allocated: 67008192, limit: 67108864)
meta -> java.lang.OutOfMemoryError: Metaspace
mảng -> java.lang.OutOfMemoryError: Requested array size exceeds VM limit
stack -> java.lang.StackOverflowError (sâu ~1855 khung với -Xss256k)
Năm nguyên nhân hoàn toàn khác nhau, và cách chữa cũng khác:
Java heap space → rò rỉ đối tượng, hoặc heap thật sự quá nhỏ. Chụp heap bằng -XX:+HeapDumpOnOutOfMemoryError rồi phân tích.
Direct buffer memory → thư viện nào đó không giải phóng bộ đệm. Chú ý bộ đệm trực tiếp chỉ được thu hồi khi đối tượng ByteBuffer bị GC — nên heap rộng rãi lại làm chậm việc giải phóng bộ nhớ ngoài heap.
Metaspace → rò rỉ classloader, hoặc sinh lớp động không kiểm soát (proxy, CGLib, ngôn ngữ script). Đây chính là bài 81.
Requested array size exceeds VM limit → mảng dài hơn giới hạn của JVM (khoảng Integer.MAX_VALUE), không liên quan tới dung lượng heap.
StackOverflowError → khác cả bốn cái trên: nó là Error của ngăn xếp, thường do đệ quy không có điểm dừng. Với -Xss256k tôi đo được khoảng 1855 khung; mặc định 1MB thì khoảng bốn lần con số đó — nhưng nó phụ thuộc số biến cục bộ nên đừng coi là hằng số.
Trong container: JVM nhìn thấy gì
container 512m -> MaxHeapSize = 128 MB
container 1g -> MaxHeapSize = 256 MB
container 2g -> MaxHeapSize = 512 MB
container 4g -> MaxHeapSize = 1024 MB
Từ Java 10, JVM đọc được cgroup limit và mặc định lấy 25% làm heap tối đa.
Hai mươi lăm phần trăm nghe rất ít, nhưng nó có lý: 75% còn lại dành cho tất cả những vùng ở trên. Với container nhỏ, phần dôi ra ấy chiếm tỷ lệ lớn.
Chỉnh bằng phần trăm chứ đừng chỉnh bằng con số tuyệt đối:
container 1g, MaxRAMPercentage=50 -> 512 MB
container 1g, MaxRAMPercentage=75 -> 768 MB
java -XX:MaxRAMPercentage=60 -jar app.jar
Dùng phần trăm thì cùng một image chạy đúng trên mọi kích thước container, không phải sửa lệnh mỗi lần đổi cấu hình.
MaxRAMPercentage lên 90. Phép đo đầu bài cho thấy vùng ngoài heap tốn ít nhất 70–100MB ngay cả khi ứng dụng không làm gì. Để heap ăn 90% container là gần như chắc chắn bị hệ điều hành giết — và cái chết đó không để lại OutOfMemoryError nào trong log.
Tôi kiểm chuyện đó bằng cách cho một tiến trình -Xmx64m cấp bộ nhớ ngoài heap tới khi vượt giới hạn container 300MB:
mã thoát: 137
số dòng chứa "OutOfMemory" trong output: 0
Mã thoát 137, log sạch trơn. 137 là 128 cộng 9, tức là tiến trình nhận SIGKILL — hệ điều hành giết nó, không phải JVM tự dừng, nên JVM không có cơ hội ghi bất cứ thứ gì. Thấy container Java khởi động lại với mã 137 thì đừng đi tìm ngoại lệ; hãy đi đo bộ nhớ.
Công thức ước lượng
Từ các con số đo được, tôi dùng công thức này khi đặt giới hạn container:
RAM container ≈ Xmx
+ metaspace (50–150MB)
+ code cache (30–100MB)
+ cấu trúc GC (~10% của Xmx)
+ số luồng × Xss thật sự dùng
+ bộ nhớ ngoài heap của thư viện
+ 50MB cho JVM
Với ứng dụng Spring Boot cỡ vừa, heap 512MB thì container nên khoảng 1GB. Tỷ lệ "container gấp đôi -Xmx" là điểm khởi đầu thô nhưng hiếm khi sai hẳn.
Và cách chắc chắn hơn mọi công thức: chạy thật rồi đo RSS, đúng cách tôi làm trong bài.
Thử ba mươi giây
Với một ứng dụng Java đang chạy trong container của bạn:
docker stats --no-stream <ten> # RAM thật đang dùng
jcmd <pid> GC.heap_info # heap đang dùng bao nhiêu
Lấy số thứ nhất trừ số thứ hai. Phần chênh lệch chính là toàn bộ những vùng trong bài này — và nếu nó lớn hơn bạn tưởng, bạn vừa hiểu vì sao container thi thoảng bị giết dù biểu đồ heap luôn phẳng lặng.
Ngày mai xuống một tầng nữa: đọc bytecode bằng javap — invokevirtual, invokeinterface, invokedynamic, và một lambda thực ra biên dịch thành cái gì.