Stack vs Heap: dữ liệu sống ở đâu
Tiếp đi: Stack và Heap. Mình hay nghe "primitive nằm trên stack, object nằm trên heap" có đúng không?
Câu đó đúng một nửa. Chính xác hơn: biến local nằm trên stack (dù là primitive hay reference), còn object nằm trên heap. Một field int bên trong object thì vẫn ở heap. Ta xem trên order-service.
1. Bài toán: một request tạo Order
@PostMapping("/orders")
OrderResponse create(@RequestBody CreateOrderRequest req) {
int qty = req.quantity(); // (a)
Order order = new Order(req.productId(), qty); // (b)
long total = pricing.total(order); // (c)
return OrderResponse.from(order, total);
}
200 thread Tomcat chạy method này cùng lúc. Mỗi thread có biến qty, order, total riêng, nhưng tất cả object Order nằm chung một chỗ.
2. Vấn đề cần hiểu
- Vì sao biến local thread-safe tự nhiên, còn field của bean singleton thì không?
- Vì sao đệ quy sâu ra
StackOverflowErrorchứ không phảiOutOfMemoryError? -Xmxgiới hạn những gì, và RAM tiến trình Java gồm những gì?
3. Giải pháp: các vùng nhớ của JVM
| Vùng | Riêng/chung | Chứa | Lỗi khi đầy |
|---|---|---|---|
| JVM stack | Mỗi thread một stack | Stack frame: biến local, tham số, operand stack, return address | StackOverflowError |
| PC register | Mỗi thread | Lệnh bytecode đang chạy | - |
| Heap | Chung mọi thread | Mọi object và mảng (kể cả field primitive bên trong) | OOM: Java heap space |
| Metaspace | Chung (native memory) | Metadata của class, method | OOM: Metaspace |
| Code cache | Chung | Mã máy do JIT sinh (bài 2.1) | JIT ngừng compile |
| Native / direct | Chung | Thread stack, DirectByteBuffer (Netty), GC structures | OOM: Direct buffer memory |
4. Từng bước
Bước 1: vẽ bộ nhớ cho đoạn code trên
Thread http-nio-8080-exec-7 HEAP (dùng chung)
┌─────────────────────────────┐ ┌──────────────────────────┐
│ frame: pricing.total(order) │ │ Order@1a2b │
│ order ─────────────────────┼──┐ │ productId: Long@... ─┐│
├─────────────────────────────┤ ├──►│ qty: 3 (int, trong ││
│ frame: create(req) │ │ │ object) ││
│ req ──────────────────────┼─┐│ │ status: "NEW" ──────┐ ││
│ qty = 3 (int) │ ││ └───────────────────────┼─┼┘
│ order ─────────────────────┼─┼┘ CreateOrderRequest@... ◄┘ │
│ total = 1500 (long) │ └───► String pool "NEW" ◄──────┘
└─────────────────────────────┘ Long@... ◄────────────────
Thread exec-8: stack riêng, frame riêng, cùng nhìn vào heap
Reference (order) là một giá trị trên stack, trỏ tới object trên heap. Java truyền giá trị của reference (xem bài 1.10 pass-by-value).
Bước 2: gây StackOverflowError
// Bug thật: category cha-con bị vòng (A → B → A) trong DB
int depth(Category c) {
return c.getParent() == null ? 0 : 1 + depth(c.getParent());
}
java.lang.StackOverflowError
at com.shop.order.CategoryService.depth(CategoryService.java:12)
at com.shop.order.CategoryService.depth(CategoryService.java:12)
... (1024 dòng lặp lại)
Mỗi lần gọi đẩy một frame. Stack mặc định ~1 MB (-Xss1m trên Linux x64), hết chỗ là lỗi. Cũng hay gặp: toString()/equals() hoặc Jackson serialize entity hai chiều Order ↔ OrderItem.
Bước 3: sửa
int depth(Category c) {
Set<Long> seen = new HashSet<>();
int d = 0;
while (c.getParent() != null) {
if (!seen.add(c.getId())) throw new IllegalStateException("Cycle at " + c.getId());
c = c.getParent();
d++;
}
return d;
}
Đổi đệ quy thành vòng lặp + phát hiện vòng. Tăng -Xss chỉ là băng dán, và nhân lên với số thread.
Bước 4: xem các vùng nhớ đang dùng
$ jcmd $(pgrep -f order-service) GC.heap_info
garbage-first heap total 1048576K, used 412331K
region size 1024K, 120 young (122880K), 8 survivors (8192K)
Metaspace used 98211K, committed 99328K, reserved 1179648K
$ java -XX:NativeMemoryTracking=summary ... ; jcmd <pid> VM.native_memory summary
- Java Heap (reserved=1048576KB, committed=1048576KB)
- Class (committed=101234KB)
- Thread (committed=210432KB) ← 200 thread × ~1MB stack
- Code (committed=48211KB)
- GC (committed=61022KB)
5. Cách hoạt động: RAM tiến trình Java
RAM của process order-service (RSS)
├── Heap ← -Xmx / -XX:MaxRAMPercentage
├── Metaspace ← -XX:MaxMetaspaceSize (mặc định không giới hạn)
├── Thread stacks ← số thread × -Xss
├── Code cache ← -XX:ReservedCodeCacheSize
├── GC overhead
└── Direct buffers ← -XX:MaxDirectMemorySize
RSS = heap + tất cả phần còn lại ⇒ RSS > -Xmx là bình thường
Chính vì vậy container giới hạn 1 GiB mà đặt -Xmx1g sẽ bị OOMKilled. Cách đặt MaxRAMPercentage trong container đã có ở Docker bài 16.3 và Kubernetes bài 17.6.
6. So sánh Stack và Heap
| Stack | Heap | |
|---|---|---|
| Phạm vi | Mỗi thread | Toàn JVM |
| Chứa | Frame, biến local (primitive + reference) | Object, mảng |
| Cấp phát / thu hồi | Push/pop frame, cực nhanh, tự động | Bump pointer (TLAB), thu hồi bởi GC |
| Thread-safety | Biến local an toàn tự nhiên | Cần đồng bộ nếu nhiều thread sửa |
| Kích thước | -Xss (~1MB) | -Xms/-Xmx |
| Lỗi | StackOverflowError | OutOfMemoryError |
7. Khi nào cần để ý
- Đệ quy theo dữ liệu người dùng/DB (cây, graph): dùng vòng lặp hoặc giới hạn độ sâu.
- Rất nhiều platform thread: RAM stack tăng theo; cân nhắc virtual threads (bài 3.11), stack của chúng nằm trên heap và co giãn.
- State dùng chung giữa các request đặt trong field bean: cần thread-safe (bài 3.5).
NoClassDefFoundError?Mình đọc đâu đó là JVM có thể đặt object lên stack. Vậy câu "object nằm trên heap" sai à?
Đúng là có chuyện đó, nhưng theo kiểu khác. Đây là escape analysis của C2 (bài 2.1):
long total(Order o) {
Money m = new Money(o.price(), o.qty()); // m không "thoát" khỏi method
return m.amount();
}
- C2 thấy
mkhông được trả về, không gán vào field, không truyền sang thread khác → không escape. - Nó làm scalar replacement: tách object thành vài biến local (thanh ghi). Không có object nào được cấp phát cả.
Về mặt ngôn ngữ, object vẫn "ở heap"; đây chỉ là tối ưu, chỉ có sau khi JIT, và bạn không điều khiển được. Trả lời phỏng vấn: "Object cấp phát trên heap; JIT có thể loại bỏ cấp phát nhờ escape analysis."
Metaspace với PermGen ngày xưa khác nhau gì? Có cần đặt giới hạn cho Metaspace không?
Từ Java 8, PermGen bị bỏ, thay bằng Metaspace:
| PermGen (≤ Java 7) | Metaspace (Java 8+) | |
|---|---|---|
| Nằm ở | Trong heap, kích thước cố định | Native memory, tự lớn |
| Lỗi hay gặp | OOM: PermGen space | OOM: Metaspace (khi đặt giới hạn hoặc hết RAM) |
Nên đặt -XX:MaxMetaspaceSize (ví dụ 256m) cho order-service trong container. Không đặt thì một leak class (thường do tạo class động liên tục) sẽ ăn hết RAM và bị kernel kill, khó chẩn đoán hơn nhiều so với một OutOfMemoryError: Metaspace rõ ràng. Leak kiểu này ở bài 2.5.
Phỏng vấn hỏi Stack và Heap thì trả lời sao?
Q1. Stack và Heap trong Java khác nhau thế nào?
🇻🇳 Tiếng Việt:
Mỗi thread có stack riêng chứa các frame; mỗi frame có biến local và tham số, gồm cả primitive và reference. Heap dùng chung cho mọi thread, chứa mọi object và mảng, được GC dọn. Biến local vì vậy thread-safe tự nhiên, còn object trên heap mà nhiều thread cùng sửa thì phải đồng bộ. Stack đầy ra StackOverflowError, heap đầy ra OutOfMemoryError.
🇬🇧 English:
Each thread has its own stack of frames; a frame holds local variables and parameters, both primitives and references. The heap is shared by all threads, holds every object and array, and is managed by the GC. So local variables are naturally thread-safe, while heap objects mutated by several threads need synchronization. A full stack gives StackOverflowError, a full heap gives OutOfMemoryError.
Q2. Primitive có luôn nằm trên stack không?
🇻🇳 Tiếng Việt:
Không. Primitive là biến local thì nằm trong stack frame, nhưng primitive là field của object thì nằm trong object trên heap. Reference cũng vậy: reference local nằm trên stack, còn object nó trỏ tới nằm trên heap. JIT có thể bỏ cấp phát heap nhờ escape analysis, nhưng đó là tối ưu.
🇬🇧 English:
No. A primitive local variable lives in the stack frame, but a primitive field lives inside its object on the heap. Same for references: a local reference is on the stack while the object it points to is on the heap. The JIT may eliminate heap allocation through escape analysis, but that is an optimization.
Q3. Vì sao RAM của tiến trình Java lớn hơn -Xmx?
🇻🇳 Tiếng Việt:
-Xmx chỉ giới hạn heap. Tiến trình còn có metaspace, thread stack, code cache, cấu trúc của GC và direct buffer. Vì vậy trong container em để heap khoảng 70–75% memory limit và dùng Native Memory Tracking khi cần xem chi tiết.
🇬🇧 English:
-Xmx only limits the heap. The process also uses metaspace, thread stacks, the code cache, GC data structures and direct buffers. So in a container I size the heap at about 70 to 75 percent of the memory limit and use Native Memory Tracking when I need the breakdown.
Cách nhớ ngắn gọn
- Stack = mỗi thread, frame, biến local. Heap = chung, object.
- "Local trên stack, object trên heap", không phải "primitive trên stack".
- Đệ quy sâu / vòng tham chiếu → StackOverflowError.
- RSS = heap + metaspace + stacks + code cache + GC + direct.
- Metaspace thay PermGen, nằm ở native memory.
Tự kiểm tra
int qty là field của Order thì nằm ở đâu?
Trong object Order trên heap.
Vì sao biến local thread-safe?
Mỗi thread có stack riêng, thread khác không thấy frame của nó (nhưng object mà biến trỏ tới thì có thể dùng chung).
Jackson serialize Order ↔ OrderItem hai chiều gây lỗi gì?
Đệ quy vô hạn → StackOverflowError.
Escape analysis làm gì?
Phát hiện object không thoát khỏi method để thay bằng biến local (scalar replacement), bỏ cấp phát heap.
Container 1 GiB, đặt -Xmx1g có ổn không?
Không. Phần non-heap làm RSS vượt limit → OOMKilled.