Bộ nhớ/Stack vs heap vs static — chọn vùng nhớ nào
5/13
Bài 5 / 13~16 phútMô hình bộ nhớ chương trìnhMiễn phí lượt xem

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
💡 Cách nhớ ba vùng

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. Data chứa biến có giá trị khởi tạo; BSS chứ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íStackHeapStatic / Global
Chứa gìBiến cục bộ, tham số, frameObject cấp phát độngBiến toàn cục, hằng, static
Cấp phát khi nàoTự động khi gọi hàmTường minh (new, malloc)Một lần khi nạp chương trình
Giải phóng khi nàoTự động khi hàm returnThủ công (free) hoặc GCKhi chương trình thoát
Tốc độ cấp phátRất nhanh (dịch pointer)Chậm hơn (allocator)Không áp dụng (một lần)
Kích thướcNhỏ, 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 đờiTheo frame (ngắn)Tuỳ ý (tới khi free/GC)Suốt đời chương trình
Chia sẻ giữa luồngKhông (mỗi luồng riêng)
Rủi roStack overflowRò bộ nhớ, fragmentationTrạ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.MAX nằ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.
  • nlocal nằm trên stack (trong frame của build) — sinh khi gọi build, chết khi build return.
  • Biến result (một tham chiếu) cũng nằm trên stack, nhưng mảng int mà nó trỏ tới nằm trên heap.
  • Khi build return, frame pop: n, local, và biến result biế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ải free/delete); biến static/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; static field 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.
Escape analysis — khi 'object trên heap' thực ra ở stack

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)

📚 Đà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 eliminationlock 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ếĐượcMất
C/C++Thủ công (malloc/free)Kiểm soát tuyệt đối, không pauseRò bộ nhớ, dangling, use-after-free
RustOwnership + borrow checkerAn toàn bộ nhớ, không GC, không pauseHọc khó, fight borrow checker
Java/C#Tracing GCAn toàn, lập trình viên không lo freeGC pause, tốn CPU/RAM phụ trội
GoEscape analysis + GC nhẹCú pháp đơn giản, pause thấpVẫ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

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; static mutable 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

Tự kiểm tra
Q1
Ba 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 đó.
Stack: vòng đời theo frame (sinh khi gọi hàm, chết khi return); cấp phát/giải phóng tự động bằng dịch stack pointer, không ai phải can thiệp. Heap: vòng đời tuỳ ý (tới khi được giải phóng); cấp phát tường minh (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ó.
Q2
Vì 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ì?
Mỗi luồng có luồng thực thi riêng — chuỗi lời gọi hàm độc lập — nên cần call stack riêng để lưu frame và biến cục bộ của nó; nếu dùng chung stack, các luồng sẽ ghi đè frame của nhau. Ngược lại, heap và static là dữ liệu của chương trình, không gắn với luồng nào, nên dùng chung để các luồng hợp tác trên cùng dữ liệu. Hệ quả: biến cục bộ (trên stack) vốn an toàn giữa các luồng vì mỗi luồng có bản riêng; nhưng object trên heap và biến static dùng chung — hai luồng cùng đọc/ghi một object là gốc của race condition, cần đồng bộ (lock, atomic). Đây là chủ đề trung tâm của Course 3.
Q3
Có 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?
Đúng ở mức ngôn ngữ/đặc tả: ngữ nghĩa Java coi mọi object tạo bằng 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.
Q4
Khi nào nên đặt dữ liệu vào static, và vì sao static mutable lại nguy hiểm?
Đặt vào static khi dữ liệu là hằng bất biến dùng chung toàn chương trình: bảng tra cứu chỉ-đọc, cấu hình hằng, hằng số. Lợi: cấp một lần, không lặp chi phí, truy cập từ mọi nơi. Static mutable (biến static thay đổi được) nguy hiểm vì nó là trạng thái toàn cục dùng chung giữa mọi luồng: bất kỳ luồng nào cũng đọc/ghi được, gây race condition khó tái hiện; nó cũng tạo coupling ẩn (hàm phụ thuộc trạng thái ngoài tham số) khiến code khó test và suy luận. Nguyên tắc: 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).
Q5
Mộ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 đó?
Mảng phải sống trên heap. Lý do: nó cần sống lâu hơn hàm tạo ra nó — nơi gọi sẽ dùng mảng sau khi hàm đã return. Nếu mảng nằm trên stack frame của hàm, frame đó bị pop khi hàm return, vùng nhớ của mảng được tái dùng cho lời gọi sau, và tham chiếu trả về sẽ trỏ tới dữ liệu đã chết (dangling — đúng bug "return địa chỉ biến cục bộ" ở bài 02). Vì thế dữ liệu thoát khỏi hàm bắt buộc lên heap. Trong Java/Go bạn chỉ cần 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.
Q6
Mộ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?
500 luồng × 1 MB = ~500 MB RAM chỉ riêng cho stack, trước cả khi tính heap, vì mỗi luồng có stack riêng được cấp khi tạo luồng (mục 2). Đây là chi phí cố định theo số luồng, độc lập với việc luồng có đang làm việc hay ngủ. Hệ quả thiết kế: không thể cứ tạo hàng chục nghìn luồng OS — vài nghìn luồng đã ngốn vài GB chỉ cho stack, chưa kể chi phí context switch. Vì thế thread pool size cho luồng OS thường giới hạn ở bội số nhỏ của số nhân CPU (với workload CPU-bound) hoặc cao hơn cho IO-bound, chứ không "một luồng mỗi request". Đây cũng là động lực ra đời virtual thread (Java 21) và goroutine (Go): chúng dùng stack nhỏ lớn dần (vài KB), nên tạo hàng triệu cái vẫn vừa RAM. Course 3 đào sâu chủ đề này.
Q7
Vì 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ó?
Vùng text chứa lệnh máy của chương trình. OS đánh dấu nó chỉ-đọc (và executable) vì hai lý do. (1) An toàn: nếu code ghi được, một bug (con trỏ chạy loạn) hoặc kẻ tấn công (qua buffer overflow) có thể ghi đè lệnh đang chạy — chèn mã độc vào chính luồng thực thi, một trong những lớp tấn công nguy hiểm nhất. Đánh dấu chỉ-đọc khiến mọi nỗ lực ghi vào vùng text bị phần cứng chặn (segmentation fault) thay vì âm thầm sửa code. (2) Chia sẻ: vì code không đổi, nhiều tiến trình chạy cùng một chương trình (hoặc cùng thư viện) có thể dùng chung một bản text trong RAM vật lý — tiết kiệm bộ nhớ. Nếu code ghi được, mỗi tiến trình phải có bản riêng và mất luôn lá chắn an toàn. Nguyên tắc "code không tự sửa mình" (W^X — write xor execute) là một trụ cột bảo mật hệ điều hành hiện đại.

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

Đặ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