Stack vs heap vs static — chọn vùng nhớ nào
Ba vùng nhớ chính của một chương trình: stack (tự dọn, nhanh), heap (linh hoạt, tốn kém), static (sống suốt đời). Bài tổng hợp quy tắc chọn vùng đúng cho từng dữ liệu.
TL;DR: Bộ nhớ của một tiến trình chia thành các vùng có vai trò khác nhau. Stack chứa biến cục bộ và frame lời gọi — nhanh, tự dọn theo frame, nhưng nhỏ và vòng đời ngắn. Heap chứa object cấp phát động — linh hoạt về kích thước và vòng đời, nhưng chậm hơn và cần quản lý (free thủ công hoặc GC). Static/global chứa biến toàn cục, hằng số, và code — cấp một lần lúc nạp chương trình, sống tới khi thoát. Chọn đúng vùng là một quyết định thiết kế: dữ liệu nhỏ, ngắn hạn → stack; dữ liệu lớn hoặc phải sống lâu hơn hàm → heap; hằng dùng chung toàn chương trình → static. Bài này đặt ba vùng cạnh nhau và rút ra quy tắc chọn.
Ba bài trước mổ xẻ stack và heap riêng lẻ. Bài này lắp chúng vào một bức tranh: toàn bộ không gian địa chỉ của một chương trình đang chạy, các vùng nằm đâu, và làm sao quyết định dữ liệu của bạn nên sống ở vùng nào.
1. Analogy — căn hộ ba khu
Hình dung một căn hộ với ba khu lưu đồ, mỗi khu một thói quen khác nhau:
- Phòng khách (static): bàn ghế, kệ tivi — đồ cố định, đặt một lần khi dọn vào, ai trong nhà cũng dùng chung, ở nguyên đó tới khi chuyển nhà.
- Ngăn kéo bàn (stack): bạn mở ngăn lấy bút dùng tạm, xong đẩy ngăn vào là gọn — đồ trong ngăn gắn với việc bạn đang làm, tự "biến mất" khỏi tầm mắt khi đóng ngăn. Nhanh, gọn, nhưng không để đồ lâu dài.
- Nhà kho (heap): chứa đồ lớn, đồ chưa biết khi nào cần — phải quản lý chìa khoá, tự sắp xếp, và nếu không dọn thì bừa bộn dần (đồ cũ chiếm chỗ).
Câu hỏi "để đồ ở đâu" trong nhà giống hệt câu hỏi "đặt dữ liệu vào vùng nào" trong chương trình: đồ dùng tạm theo việc → ngăn kéo (stack); đồ lớn hoặc sống lâu → nhà kho (heap); đồ cố định dùng chung → phòng khách (static).
| Căn hộ | Vùng nhớ |
|---|---|
| Bàn ghế phòng khách (cố định, dùng chung) | Static / global |
| Ngăn kéo (mở/đóng theo việc, tự gọn) | Stack |
| Nhà kho (đồ lớn, phải quản lý, dễ bừa) | Heap |
Stack = "tạm bợ và nhanh" (sống theo hàm). Heap = "linh hoạt và tốn kém" (sống theo nhu cầu). Static = "vĩnh viễn và dùng chung" (sống theo chương trình). Câu hỏi chọn vùng luôn là: dữ liệu này cần sống bao lâu, và to bao nhiêu?
2. Bản đồ bộ nhớ một tiến trình
Khi OS nạp chương trình, nó dựng một không gian địa chỉ với các vùng cố định:
Dia chi cao
+-----------------------------+
| Stack | <- bien cuc bo, frame; lon xuong duoi
| | |
| v |
| |
| ^ |
| | |
| Heap | <- object dong; lon len tren
+-----------------------------+
| BSS / Data (static) | <- bien toan cuc, static
+-----------------------------+
| Text (code) | <- lenh may, chi doc
+-----------------------------+
Dia chi thap
- Text (code): lệnh máy của chương trình, chỉ đọc (OS đánh dấu vùng này không cho ghi, để một bug hay mã độc không thể ghi đè lệnh của chính chương trình). Một bản dùng chung cho mọi luồng.
- Data + BSS (static): biến toàn cục và
static.Datachứa biến có giá trị khởi tạo;BSSchứa biến khởi tạo 0 (không tốn chỗ trong file thực thi). - Heap: lớn dần lên trên khi cấp phát động.
- Stack: lớn dần xuống dưới khi gọi hàm. Stack và heap mọc về phía nhau — thiết kế này cho mỗi vùng dùng tối đa không gian còn lại.
flowchart TB S["Stack — bien cuc bo, frame<br/>(lon xuong duoi, rieng moi luong)"] GAP["vung trong dung chung<br/>stack va heap moc ve phia nhau"] H["Heap — object dong<br/>(lon len tren, dung chung)"] D["Static / Data — toan cuc, hang"] T["Text — code, chi doc"] S --> GAP --> H --> D --> T
Mỗi luồng có stack riêng; heap, static và code dùng chung giữa các luồng — đây là gốc của nhiều vấn đề đồng thời bạn sẽ gặp ở Course 3 (hai luồng cùng sửa một object trên heap).
3. Ba vùng cạnh nhau
| Tiêu chí | Stack | Heap | Static / Global |
|---|---|---|---|
| Chứa gì | Biến cục bộ, tham số, frame | Object cấp phát động | Biến toàn cục, hằng, static |
| Cấp phát khi nào | Tự động khi gọi hàm | Tường minh (new, malloc) | Một lần khi nạp chương trình |
| Giải phóng khi nào | Tự động khi hàm return | Thủ công (free) hoặc GC | Khi chương trình thoát |
| Tốc độ cấp phát | Rất nhanh (dịch pointer) | Chậm hơn (allocator) | Không áp dụng (một lần) |
| Kích thước | Nhỏ, cố định (vài MB/luồng) | Lớn (tới hàng GB) | Cố định, biết lúc biên dịch |
| Vòng đời | Theo frame (ngắn) | Tuỳ ý (tới khi free/GC) | Suốt đời chương trình |
| Chia sẻ giữa luồng | Không (mỗi luồng riêng) | Có | Có |
| Rủi ro | Stack overflow | Rò bộ nhớ, fragmentation | Trạng thái dùng chung khó kiểm soát |
4. Một biến đi đâu — trace một hàm
Xét một đoạn Java và xem mỗi thứ sống ở vùng nào:
class Config {
static final int MAX = 100; // MAX: vung static (hang, dung chung)
}
int[] build(int n) { // n: tham so -> stack frame cua build
int local = n * 2; // local: bien cuc bo -> stack
int[] result = new int[local]; // bien 'result': tham chieu tren stack
// mang int that su: tren HEAP
return result; // tra ve tham chieu; mang song tiep tren heap
}
Config.MAXnằm ở vùng static — cấp một lần, mọi nơi dùng chung, sống tới khi chương trình thoát.nvàlocalnằm trên stack (trong frame củabuild) — sinh khi gọibuild, chết khibuildreturn.- Biến
result(một tham chiếu) cũng nằm trên stack, nhưng mảngintmà nó trỏ tới nằm trên heap. - Khi
buildreturn, frame pop:n,local, và biếnresultbiến mất — nhưng mảng trên heap vẫn sống vì nơi gọi nhận được tham chiếu và GC giữ nó chừng nào còn ai trỏ tới. Đây chính là lý do dữ liệu thoát khỏi hàm phải nằm trên heap (bài 02).
5. Ngôn ngữ quản lý các vùng này thế nào
Cùng một mô hình ba vùng, nhưng mỗi ngôn ngữ lộ ra khác nhau:
- C/C++: bạn kiểm soát trực tiếp. Biến cục bộ → stack;
malloc/new→ heap (bạn phảifree/delete); biếnstatic/global → static. Quyền lực tối đa, trách nhiệm tối đa (rò bộ nhớ nếu quên free). - Java: primitive cục bộ và tham chiếu nằm trên stack; mọi object (
new) nằm trên heap, GC dọn;staticfield nằm trong vùng metadata của class (gần static). Bạn không chọn stack hay heap cho object — JVM quyết (xem escape analysis bên dưới). - Python: gần như mọi thứ là object trên heap, kể cả số nguyên; biến chỉ là tên trỏ tới object. Quản lý bằng ref-counting + GC. Stack chỉ giữ frame và tham chiếu.
- Go: bạn viết như thể mọi thứ trên stack, nhưng compiler chạy escape analysis — biến nào "thoát" khỏi hàm (bị trả về hoặc lưu ra ngoài) tự động chuyển lên heap; còn lại ở stack. Bạn không gọi
free; GC dọn heap.
Java HotSpot và Go đều có escape analysis: nếu compiler chứng minh một object không thoát khỏi hàm (không bị return, không lưu vào field ngoài, không truyền cho luồng khác), nó có thể cấp phát object đó trên stack thay vì heap — nhanh hơn và không tạo rác cho GC. Đây là lý do "Java luôn đặt object trên heap" chỉ đúng ở mức ngôn ngữ; ở mức máy, JIT có thể dời object cục bộ về stack. Bạn không điều khiển trực tiếp được, nhưng viết hàm nhỏ, object cục bộ không rò ra ngoài giúp escape analysis làm việc.
6. Áp dụng vào code của bạn
Quy tắc quyết định vùng nhớ, theo thứ tự ưu tiên:
1. Dữ liệu nhỏ, chỉ dùng trong hàm → để cục bộ (stack). Biến tạm, bộ đếm, kết quả trung gian. Nhanh, tự dọn, không áp lực GC. Đây là mặc định tốt nhất khi đủ.
2. Dữ liệu phải sống lâu hơn hàm, hoặc kích thước không biết trước → heap. Object trả về từ hàm, collection lớn dần, cấu trúc dữ liệu chia sẻ. Trong Java/Go bạn không gọi gì đặc biệt — new/make là đủ, runtime lo phần còn lại.
3. Hằng số bất biến dùng toàn chương trình → static/constant. Bảng tra cứu, cấu hình chỉ-đọc, hằng. Cấp một lần, không lặp lại chi phí.
Cảnh giác với static mutable (trạng thái toàn cục). Một static field thay đổi được là trạng thái dùng chung giữa mọi luồng — gốc của race condition và bug khó tái hiện:
// NGUY HIEM: static mutable -> moi luong cung sua, race condition
public class Counter {
static int count = 0; // dung chung toan chuong trinh
void inc() { count++; } // khong an toan da luong
}
// AN TOAN HON: dong goi trang thai, hoac dung kieu atomic
public class Counter {
private final AtomicInteger count = new AtomicInteger();
void inc() { count.incrementAndGet(); }
}
Quy tắc: static nên là final (hằng) trong hầu hết trường hợp. static mutable chỉ dùng khi thật sự cần trạng thái toàn cục và phải bảo vệ đồng bộ cẩn thận.
Đừng ép object lên stack chỉ vì "nhanh hơn". Trong Java/Go bạn không chọn được, và escape analysis đã tự tối ưu các object cục bộ an toàn. Tập trung viết code rõ ràng; chỉ can thiệp khi profiler chỉ ra cấp phát/GC là bottleneck thật.
7. Đào sâu (tuỳ chọn)
Escape analysis trong HotSpot C2 đi xa hơn cấp-phát-trên-stack: khi JIT chứng minh object không thoát, nó có thể làm scalar replacement — xoá hẳn object và thay bằng các biến vô hướng nằm thẳng trong thanh ghi (không cấp phát ở đâu cả), rồi allocation elimination và lock elision (bỏ khoá synchronized trên object không thoát). Vì thế một object "tạm" trong hot loop có thể biến mất hoàn toàn ở mã máy. Điều kiện: hàm đủ nhỏ để được inline, object không bị truyền ra ngoài. Đây là lý do micro-benchmark đo "chi phí cấp phát object" thường ra số thấp bất ngờ — JIT đã xoá phần lớn cấp phát.
Bốn triết lý quản lý heap, bốn trade-off:
| Ngôn ngữ | Cơ chế | Được | Mất |
|---|---|---|---|
| C/C++ | Thủ công (malloc/free) | Kiểm soát tuyệt đối, không pause | Rò bộ nhớ, dangling, use-after-free |
| Rust | Ownership + borrow checker | An toàn bộ nhớ, không GC, không pause | Học khó, fight borrow checker |
| Java/C# | Tracing GC | An toàn, lập trình viên không lo free | GC pause, tốn CPU/RAM phụ trội |
| Go | Escape analysis + GC nhẹ | Cú pháp đơn giản, pause thấp | Vẫn có GC overhead |
Điểm đáng chú ý: Rust đạt an toàn bộ nhớ mà không cần GC — borrow checker kiểm tra vòng đời ở compile time nên không có chi phí runtime. Đây là một điểm thiết kế khác hẳn GC, và là lý do Rust hấp dẫn cho hệ thống cần vừa an toàn vừa không chấp nhận GC pause. Bạn sẽ đào sâu GC pause và tuning ở Module 4 và java-internals.
8. Liên hệ các bài khác
- Bài 02 — Stack và stack frame: chi tiết cơ chế vùng stack — frame, vòng đời, stack overflow.
- Bài 03 — Heap và cấp phát động: chi tiết cơ chế vùng heap — allocator, fragmentation, chi phí.
- Module 4 — Quản lý bộ nhớ ngôn ngữ bậc cao: ai giải phóng heap (bạn hay GC) và rò bộ nhớ phát sinh thế nào.
- Course 3 — Tiến trình & luồng: stack riêng mỗi luồng, heap/static dùng chung — gốc của race condition.
9. Tóm tắt
- Không gian địa chỉ một tiến trình gồm: text (code, chỉ đọc), static/data (toàn cục, hằng), heap (động, lớn lên), stack (frame, lớn xuống).
- Stack: nhanh, tự dọn theo frame, nhỏ, vòng đời ngắn, riêng mỗi luồng. Mặc định tốt cho dữ liệu cục bộ nhỏ.
- Heap: linh hoạt về kích thước và vòng đời, chậm hơn, cần free/GC, dùng chung giữa luồng. Cho dữ liệu sống lâu hơn hàm hoặc kích thước chưa biết.
- Static: cấp một lần, sống suốt đời chương trình, dùng chung. Tốt cho hằng;
staticmutable là trạng thái toàn cục nguy hiểm (race condition). - Mỗi ngôn ngữ lộ ba vùng khác nhau; escape analysis (Java, Go) có thể dời object cục bộ không thoát từ heap về stack — thậm chí xoá hẳn bằng scalar replacement.
- Quy tắc chọn: nhỏ và ngắn hạn → stack; lớn hoặc sống lâu → heap; hằng dùng chung → static. Ưu tiên code rõ ràng, tối ưu vùng nhớ chỉ khi profiler yêu cầu.
10. Tự kiểm tra
Q1Ba vùng stack, heap, static khác nhau cơ bản ở hai chiều: vòng đời và ai quản lý cấp phát/giải phóng. Tóm tắt mỗi vùng theo hai chiều đó.▸
new/malloc) và giải phóng thủ công (free) hoặc tự động qua GC. Static: vòng đời bằng cả chương trình (sinh khi nạp, chết khi thoát); cấp phát một lần lúc nạp, không lặp lại. Câu hỏi chọn vùng quy về: dữ liệu cần sống bao lâu, và ai chịu trách nhiệm dọn nó.Q2Vì sao stack riêng cho mỗi luồng, còn heap và static dùng chung? Hệ quả với lập trình đa luồng là gì?▸
Q3Có người nói 'trong Java mọi object luôn nằm trên heap'. Câu này đúng ở mức nào và sai ở mức nào?▸
new như nằm trên heap và do GC quản lý; bạn không khai báo object trên stack được. Nhưng sai ở mức máy/runtime: JIT của HotSpot chạy escape analysis — nếu chứng minh một object không thoát khỏi hàm (không return, không lưu ra field ngoài, không chia sẻ với luồng khác), nó có thể cấp phát object đó trên stack (hoặc thậm chí "scalar replacement" — tách thành các biến thanh ghi), tránh hẳn heap và GC. Vậy "luôn trên heap" là mô hình tư duy đúng để lập luận về ngữ nghĩa, nhưng máy thật có thể tối ưu object cục bộ về stack mà bạn không thấy.Q4Khi nào nên đặt dữ liệu vào static, và vì sao static mutable lại nguy hiểm?▸
static nên là final trong hầu hết trường hợp; static mutable chỉ khi thật cần trạng thái toàn cục, và phải bảo vệ đồng bộ (atomic, lock).Q5Một hàm cần tạo một mảng kết quả rồi trả về cho nơi gọi. Mảng này sống ở vùng nào, và vì sao không thể để trên stack của hàm đó?▸
new/make và return — runtime giữ nó sống chừng nào nơi gọi còn tham chiếu; trong C bạn phải malloc và nơi gọi chịu trách nhiệm free.Q6Một dịch vụ Java chạy 500 luồng, mỗi luồng stack mặc định 1 MB. Riêng stack tốn bao nhiêu RAM, và điều này ảnh hưởng gì tới quyết định chọn thread pool size?▸
Q7Vì sao vùng text (code) được OS đánh dấu chỉ-đọc? Điều gì xảy ra nếu một chương trình có thể ghi đè lên code của chính nó?▸
Bài tiếp theo: Tổng kết Module 1
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
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