Bộ nhớ
5/26
Bài 5 / 26~16 phútCó videoMô 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:

Plain Text
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.

Bản đồ không gian địa chỉ một tiến trình theo trục dọc từ địa chỉ cao xuống thấp: stack chứa biến cục bộ và frame, lớn dần xuống dưới và riêng mỗi luồng; vùng trống dùng chung ở giữa vì stack và heap mọc về phía nhau; heap chứa object động lớn dần lên trên và dùng chung; rồi tới data với BSS chứa biến toàn cục và hằng; dưới cùng là text chứa lệnh máy chỉ đọc

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).

Thử ngẫmhai luồng trong cùng tiến trình cùng gọi một hàm add(int x). Tham số x của luồng này có bị luồng kia nhìn thấy hay ghi đè không, và vì sao?

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:

Java
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).

Bốn cái tên trong cùng một hàm rơi vào ba vùng nhớ: hằng static final MAX nằm ở vùng static và sống tới khi chương trình thoát; tham số n và biến local nằm trên stack, chết khi hàm return; biến result giữ tham chiếu cũng nằm trên stack; còn mảng new int mà nó trỏ tới nằm trên heap và sống tới khi không còn ai tham chiếu

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.

Thử ngẫmmột hàm Go tạo local := &Point{1,2} rồi return con trỏ đó. Escape analysis buộc local phải chuyển lên đâu, và ai chịu trách nhiệm dọn nó sau này?

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:

Java
// 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
0/7 câu đã trả lời
  1. 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 đó.
  2. 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ì?
  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?
  4. Q4
    Khi nào nên đặt dữ liệu vào static, và vì sao static mutable lại nguy hiểm?
  5. 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 đó?
  6. 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?
  7. 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ó?

Bài tiếp theo: Tổng kết Module 1

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

Module 1 — Tổng kết & cheat sheet