Stack vs Heap: dữ liệu sống ở đâu

4 câu hỏi~20 phútTừ khóa: runtime data areas, stack frame, local variable, heap, reference, primitive, StackOverflowError, -Xss, -Xmx, escape analysis, metaspace, code cache

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

3. Giải pháp: các vùng nhớ của JVM

VùngRiêng/chungChứaLỗi khi đầy
JVM stackMỗi thread một stackStack frame: biến local, tham số, operand stack, return addressStackOverflowError
PC registerMỗi threadLệnh bytecode đang chạy-
HeapChung mọi threadMọi object và mảng (kể cả field primitive bên trong)OOM: Java heap space
MetaspaceChung (native memory)Metadata của class, methodOOM: Metaspace
Code cacheChungMã máy do JIT sinh (bài 2.1)JIT ngừng compile
Native / directChungThread stack, DirectByteBuffer (Netty), GC structuresOOM: 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

StackHeap
Phạm viMỗi threadToàn JVM
ChứaFrame, biến local (primitive + reference)Object, mảng
Cấp phát / thu hồiPush/pop frame, cực nhanh, tự độngBump pointer (TLAB), thu hồi bởi GC
Thread-safetyBiến local an toàn tự nhiênCần đồng bộ nếu nhiều thread sửa
Kích thước-Xss (~1MB)-Xms/-Xmx
LỗiStackOverflowErrorOutOfMemoryError

7. Khi nào cần để ý

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();
}

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ố địnhNative memory, tự lớn
Lỗi hay gặpOOM: PermGen spaceOOM: 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


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.