Bộ nhớ/Stack và stack frame — gọi hàm sống và chết thế nào
3/13
Bài 3 / 13~18 phútMô hình bộ nhớ chương trìnhMiễn phí lượt xem

Stack và stack frame — gọi hàm sống và chết thế nào

Mỗi lời gọi hàm đẩy một stack frame chứa biến cục bộ và địa chỉ trả về. Hiểu stack giải thích biến cục bộ tự dọn, đệ quy, và vì sao StackOverflowError xảy ra.

TL;DR: Khi một hàm được gọi, CPU dành một khối bộ nhớ liên tục trên stack gọi là stack frame — chứa tham số, biến cục bộ, và địa chỉ trả về (để biết quay lại đâu sau khi hàm xong). Frame được đẩy vào (push) khi gọi hàm và bật ra (pop) khi hàm trả về, theo đúng thứ tự LIFO (last in, first out). Vì biến cục bộ sống trong frame, chúng tự động biến mất khi hàm kết thúc — không cần dọn thủ công. Stack có kích thước cố định và nhỏ (thường 512 KB–8 MB); đệ quy quá sâu hoặc mảng cục bộ khổng lồ làm tràn stack, sinh StackOverflowError. Hiểu stack giải thích vì sao biến cục bộ nhanh, vì sao đệ quy tốn bộ nhớ, và vì sao không được trả về con trỏ tới biến cục bộ.

Bạn viết một hàm đệ quy tính giai thừa, quên điều kiện dừng, và chương trình chết với StackOverflowError sau vài nghìn lần gọi. Bạn tự hỏi: tại sao một hàm đơn giản với một biến int lại làm "tràn" thứ gì đó? Và "stack" đang tràn là cái gì?

Bài này giải thích stack là gì, một stack frame chứa gì, cách frame được tạo và huỷ qua mỗi lời gọi hàm, và hệ quả thực tế lên cách bạn viết hàm đệ quy và quản lý biến cục bộ.

1. Analogy — chồng đĩa trong nhà bếp

Hình dung một chồng đĩa: bạn chỉ thêm đĩa lên đỉnh và lấy đĩa cũng từ đỉnh. Đĩa bỏ vào sau cùng là đĩa lấy ra đầu tiên — đó là LIFO.

Mỗi lần bạn gọi một hàm, hệ thống đặt một "đĩa" mới lên đỉnh stack — đĩa đó là stack frame của hàm, chứa mọi thứ hàm cần: tham số, biến cục bộ, và một mẩu giấy ghi "gọi xong thì quay lại dòng nào". Khi hàm trả về, đĩa trên đỉnh bị lấy đi (pop), lộ ra đĩa của hàm đã gọi nó — và chương trình tiếp tục từ chỗ mẩu giấy chỉ.

Chồng đĩaStack chương trình
Một cái đĩaMột stack frame (một lời gọi hàm)
Đặt đĩa lên đỉnhPush frame khi gọi hàm
Lấy đĩa từ đỉnhPop frame khi hàm trả về
Chỉ thao tác ở đỉnhChỉ hàm đang chạy mới active
Chồng quá cao, đổStack overflow
💡 Cách nhớ

Stack là LIFO: hàm gọi sau cùng là hàm xong trước. Đây chính là lý do call stack trong debugger đọc từ trên xuống là "hàm hiện tại → hàm gọi nó → hàm gọi cái đó..." — mỗi dòng là một đĩa trong chồng.

2. Cơ chế — một stack frame chứa gì

Khi hàm B được gọi từ A, một frame mới cho B được push lên stack. Frame điển hình chứa:

  • Tham số truyền vào B.
  • Biến cục bộ khai báo trong B.
  • Địa chỉ trả về — vị trí trong A để tiếp tục sau khi B xong.
  • Saved registers — vài thanh ghi A đang dùng, lưu lại để khôi phục.

CPU theo dõi đỉnh stack bằng một thanh ghi đặc biệt: stack pointer (SP). Push một frame chỉ là dịch stack pointer đi một khoảng bằng kích thước frame — cực nhanh, một lệnh duy nhất. Đây là lý do cấp phát biến cục bộ gần như miễn phí so với heap.

Xét ví dụ:

int main() {
    int x = 5;
    int r = square(x);   // goi square, day frame
    return r;
}

int square(int n) {
    int result = n * n;  // n va result song trong frame cua square
    return result;       // pop frame, tra ve main
}

Stack tại thời điểm square đang chạy. Lưu ý quy ước phổ biến: stack "lớn xuống dưới", nghĩa là frame mới được push về phía địa chỉ thấp hơn — ngược hướng so với analogy chồng đĩa ở mục 1, nhưng vẫn là LIFO. Sơ đồ dưới vẽ theo địa chỉ: cao ở trên, thấp ở dưới, nên frame mới nhất (square) nằm dưới cùng:

Dia chi cao
   +---------------------------+
   | Frame main                |
   |   x = 5                   |
   |   r = ?                   |
   +---------------------------+
   | Frame square   <- dinh    |   <- stack pointer
   |   n = 5                   |
   |   result = 25             |
   |   return addr -> main     |
   +---------------------------+
Dia chi thap

Khi square chạy return result, frame của nó bị pop: stack pointer dịch ngược lên, nresult "biến mất" (vùng nhớ được tái dùng cho lời gọi sau). Giá trị 25 được chuyển về main qua thanh ghi, gán vào r. Không ai phải "giải phóng" n hay result — chúng chết tự động khi frame pop.

sequenceDiagram
  participant M as main
  participant S as square
  M->>S: goi square(5) — push frame
  Note over S: n=5, result=25
  S-->>M: return 25 — pop frame
  Note over M: r = 25
Vì sao biến cục bộ 'tự dọn'

Bạn không bao giờ phải free biến cục bộ vì vòng đời của chúng trùng khít với frame. Frame pop khi hàm return → toàn bộ biến trong frame hết hạn cùng lúc. Đây là điểm mạnh lớn nhất của stack: cấp phát và giải phóng đều là một phép dịch stack pointer, không cần allocator, không có rò bộ nhớ. Đổi lại: dữ liệu không thể sống lâu hơn hàm tạo ra nó (xem mục 4).

3. Đệ quy và stack overflow

Mỗi lời gọi hàm tốn một frame. Đệ quy nghĩa là hàm tự gọi chính nó — mỗi cấp đệ quy đẩy thêm một frame, tất cả cùng tồn tại trên stack cho tới khi cấp sâu nhất trả về.

long factorial(int n) {
    if (n <= 1) return 1;        // base case: dung de quy
    return n * factorial(n - 1); // moi lan goi day them mot frame
}

Gọi factorial(4) tạo một chuỗi frame chồng nhau:

factorial(4)  -> can factorial(3)
  factorial(3)  -> can factorial(2)
    factorial(2)  -> can factorial(1)
      factorial(1)  -> tra ve 1   (base case, bat dau pop nguoc)

Bốn frame cùng sống ở đỉnh điểm. Stack có giới hạn cứng — nếu đệ quy không có base case (hoặc base case không bao giờ đạt), frame chồng mãi cho tới khi vượt giới hạn stack và OS ném StackOverflowError:

int boom(int n) {
    return boom(n + 1);  // khong co base case -> StackOverflowError
}

Kích thước stack mặc định: thường 512 KB tới vài MB mỗi luồng. Một frame nhỏ (vài chục byte) cho phép vài chục nghìn cấp đệ quy; một frame có mảng cục bộ lớn (int[10000]) làm tràn nhanh hơn nhiều.

Đệ quy sâu là rủi ro production

Thuật toán đệ quy đẹp về mặt lý thuyết có thể tràn stack với dữ liệu thực. Duyệt cây cân bằng sâu 20 cấp thì an toàn (~một triệu node); nhưng duyệt một danh sách liên kết dài 100.000 phần tử bằng đệ quy sẽ tràn. Khi độ sâu phụ thuộc dữ liệu đầu vào không kiểm soát được, chuyển sang vòng lặp + stack tường minh (cấp trên heap, lớn hơn nhiều) hoặc thuật toán iterative.

4. Áp dụng vào code của bạn

Hiểu vòng đời frame giúp bạn tránh hai bug nguy hiểm và chọn đúng chiến lược đệ quy.

Bug 1 — trả về con trỏ tới biến cục bộ (dangling pointer). Trong C, biến cục bộ chết khi frame pop; trả về địa chỉ của nó là trỏ tới vùng nhớ đã chết:

int* badCounter() {
    int count = 0;       // count song trong frame badCounter
    return &count;       // SAI: tra ve dia chi cua count
}                        // frame pop -> count chet -> con tro lo lung

// Goi: int *p = badCounter(); *p = 5;  // undefined behavior!

Vùng nhớ của count sẽ bị lời gọi hàm tiếp theo ghi đè. Java/Python miễn nhiễm bug này vì chúng đặt object trên heap và GC giữ chúng sống chừng nào còn tham chiếu — nhưng đây là lý do cốt lõi để hiểu vì sao object "thoát khỏi" hàm phải nằm trên heap, không phải stack.

Bug 2 — mảng cục bộ khổng lồ làm tràn stack:

void process() {
    int[] buffer = new int[2_000_000]; // ~8 MB
    // ...
}

Trong Java, mảng object thực ra nằm trên heap (chỉ tham chiếu trên stack), nên ví dụ này an toàn. Nhưng trong C/C++ với mảng trên stack (int buffer[2000000];), 8 MB vượt stack mặc định → tràn. Quy tắc: dữ liệu lớn hoặc kích thước không biết trước → đặt trên heap.

Chuyển đệ quy sâu thành iterative. Khi độ sâu đệ quy phụ thuộc input:

// De quy: rui ro tran stack voi cay sau
void traverse(Node node) {
    if (node == null) return;
    visit(node);
    traverse(node.left);
    traverse(node.right);
}

// Iterative: dung stack tuong minh tren heap (lon hon nhieu)
void traverse(Node root) {
    Deque<Node> stack = new ArrayDeque<>();
    if (root != null) stack.push(root);
    while (!stack.isEmpty()) {
        Node node = stack.pop();
        visit(node);
        if (node.right != null) stack.push(node.right);
        if (node.left  != null) stack.push(node.left);
    }
}

Bản iterative dời "stack lời gọi" từ call stack (nhỏ, cố định) sang một ArrayDeque trên heap (lớn tới hàng GB), nên chịu được độ sâu lớn hơn nhiều bậc.

5. Đào sâu (tuỳ chọn)

📚 Đào sâu (tuỳ chọn)

Calling convention và ABI: thứ tự đẩy tham số, thanh ghi nào giữ giá trị trả về, ai dọn stack (caller hay callee) được quy định bởi calling convention — một phần của ABI (Application Binary Interface) của nền tảng. Ví dụ System V AMD64 (Linux/macOS) truyền 6 tham số nguyên đầu qua thanh ghi rdi, rsi, rdx, rcx, r8, r9 rồi mới tới stack; giá trị trả về ở rax. ABI là lý do code biên dịch từ ngôn ngữ khác nhau (C, Rust, Go) gọi được lẫn nhau — chúng đồng ý cùng một quy ước về frame.

Tail call optimization (TCO): nếu lời gọi đệ quy là thao tác cuối cùng của hàm (tail position), frame hiện tại không còn cần thiết và compiler có thể tái dùng nó thay vì push frame mới — biến đệ quy thành vòng lặp, tránh tràn stack. Scheme, Scala, và nhiều compiler C bật TCO. Đáng chú ý: JVM không đảm bảo TCO, nên đệ quy sâu trong Java/Kotlin vẫn tràn stack dù viết ở tail position.

Stack mỗi luồng: mỗi luồng (thread) có stack riêng, cấp khi tạo luồng. Đây là một lý do tạo hàng nghìn luồng OS tốn bộ nhớ (mỗi luồng ~1 MB stack → 1000 luồng = ~1 GB chỉ riêng stack). Virtual thread (Java 21) và goroutine (Go) né điều này bằng stack nhỏ, lớn dần — bạn sẽ gặp lại ở Course 3 khi học thread.

6. Liên hệ các bài khác

  • Bài 01 — Địa chỉ và con trỏ: biến cục bộ và địa chỉ trả về trong frame đều là địa chỉ; bug "trả về con trỏ tới biến cục bộ" chỉ hiểu được khi nắm con trỏ.
  • Bài 03 — Heap và cấp phát động: dữ liệu cần sống lâu hơn hàm phải đặt trên heap, không phải stack — bài này giải thích heap làm điều đó thế nào.
  • Bài 04 — Stack vs heap vs static: tổng hợp ba vùng nhớ và quy tắc chọn; stack là vùng nhanh nhất nhưng vòng đời bị ràng buộc theo frame.

7. Tóm tắt

  • Stack là vùng nhớ LIFO; mỗi lời gọi hàm push một stack frame chứa tham số, biến cục bộ và địa chỉ trả về.
  • Push/pop frame chỉ là dịch stack pointer — một lệnh, gần như miễn phí. Đây là lý do biến cục bộ rất nhanh.
  • Biến cục bộ tự dọn khi frame pop: vòng đời trùng khít với frame, không cần free, không rò bộ nhớ.
  • Stack có giới hạn cứng (512 KB–8 MB/luồng). Đệ quy quá sâu hoặc mảng cục bộ lớn làm tràn → StackOverflowError.
  • Không trả về con trỏ tới biến cục bộ (C) — frame pop làm nó thành dangling pointer. Dữ liệu cần sống lâu hơn hàm phải nằm trên heap.
  • Khi độ sâu đệ quy phụ thuộc input không kiểm soát được, chuyển sang iterative + stack tường minh trên heap.
  • JVM không đảm bảo tail call optimization — đệ quy sâu trong Java vẫn tràn dù viết ở tail position.

8. Tự kiểm tra

Tự kiểm tra
Q1
Một stack frame chứa những gì, và vì sao push/pop frame lại nhanh hơn nhiều so với cấp phát trên heap?
Một stack frame chứa tham số truyền vào hàm, biến cục bộ, địa chỉ trả về (để biết quay lại đâu), và đôi khi vài saved register. Push một frame chỉ là dịch stack pointer đi một khoảng bằng kích thước frame — một lệnh CPU duy nhất, không cần tìm chỗ trống. Pop cũng chỉ là dịch ngược stack pointer. Ngược lại, cấp phát heap phải chạy allocator: tìm một khối trống đủ lớn, cập nhật cấu trúc quản lý, xử lý fragmentation — tốn hàng chục tới hàng trăm lệnh. Vì thế stack nhanh hơn nhiều bậc, đổi lại vòng đời bị ràng buộc theo frame.
Q2
Vì sao biến cục bộ không cần được giải phóng thủ công, trong khi object trên heap thì cần (hoặc cần GC)?
Vòng đời của biến cục bộ trùng khít với stack frame chứa nó. Khi hàm trả về, frame bị pop — stack pointer dịch ngược, và toàn bộ biến trong frame hết hạn cùng lúc, vùng nhớ được tái dùng cho lời gọi sau. Không có quyết định nào về "khi nào dọn" vì câu trả lời luôn là "khi hàm return". Object trên heap thì khác: chúng có thể được tham chiếu từ nhiều nơi và sống lâu hơn hàm tạo ra chúng, nên không có thời điểm tự nhiên để dọn — phải có người (lập trình viên gọi free, hoặc GC) quyết định khi nào object không còn ai dùng.
Q3
Vì sao một hàm đệ quy không có base case lại sinh StackOverflowError thay vì chạy mãi mãi?
Mỗi lời gọi đệ quy push thêm một frame mà chưa pop frame nào (vì hàm chưa trả về — nó còn đang chờ kết quả lời gọi sâu hơn). Không có base case nghĩa là không bao giờ có lời gọi nào trả về, nên frame chồng mãi lên, stack pointer dịch ngày càng xa. Stack có giới hạn cứng (thường vài MB mỗi luồng). Khi tổng kích thước các frame vượt giới hạn đó, lần push tiếp theo chạm vùng cấm, OS phát hiện và ném StackOverflowError. Vậy nó không chạy mãi vì stack là tài nguyên hữu hạn — nó chết ngay khi cạn stack, thường sau vài nghìn tới vài chục nghìn cấp.
Q4
Trong C, vì sao trả về địa chỉ của một biến cục bộ (return &count) là bug? Vì sao Java không gặp vấn đề tương đương?
Biến cục bộ count sống trong frame của hàm. Khi hàm return, frame bị pop và vùng nhớ của count được đánh dấu tự do — lời gọi hàm tiếp theo sẽ ghi đè lên đó. Con trỏ &count trả về giờ trỏ tới vùng nhớ đã chết (dangling pointer); đọc/ghi qua nó là undefined behavior. Java không gặp vấn đề này vì biến đối tượng trên stack chỉ là tham chiếu, còn object thật nằm trên heap. Khi bạn return một object, bạn return tham chiếu của nó; GC giữ object sống chừng nào còn ai trỏ tới, nên nó không bị huỷ khi frame pop. (Primitive Java thì được copy giá trị về, cũng an toàn.)
Q5
Bạn có một thuật toán duyệt cây đệ quy chạy tốt trong test nhưng thỉnh thoảng StackOverflowError trên production. Nguyên nhân khả dĩ và cách sửa?
Nguyên nhân khả dĩ: trên production cây (hoặc cấu trúc được duyệt) sâu hơn nhiều so với dữ liệu test — ví dụ một cây suy biến thành chuỗi dài (mỗi node chỉ có một con), hoặc danh sách liên kết dài hàng trăm nghìn phần tử. Mỗi cấp đệ quy là một frame; độ sâu vượt giới hạn stack thì tràn. Cách sửa: chuyển đệ quy thành iterative với stack tường minh trên heap (ví dụ ArrayDeque) — heap lớn hơn call stack nhiều bậc nên chịu được độ sâu lớn hơn rất nhiều. Ngoài ra có thể tăng kích thước stack luồng (-Xss), nhưng đó chỉ là nới trần, không giải quyết gốc; với độ sâu không chặn trên, iterative mới an toàn.
Q6
Đệ quy ở tail position (lời gọi đệ quy là thao tác cuối) trên lý thuyết có thể tối ưu thành vòng lặp. Vì sao trong Java điều này không cứu bạn khỏi StackOverflowError?
Tail call optimization (TCO) cho phép compiler tái dùng frame hiện tại thay vì push frame mới khi lời gọi đệ quy là thao tác cuối cùng — biến đệ quy thành vòng lặp, dùng O(1) stack. Nhưng JVM không đảm bảo TCO: bytecode vẫn sinh lệnh invoke tạo frame mới cho mỗi lời gọi, bất kể nó ở tail position hay không (một phần vì JVM cần giữ stack trace đầy đủ cho security và debugging). Vì thế trong Java/Kotlin, đệ quy sâu vẫn chồng frame và vẫn tràn stack dù bạn viết đúng dạng tail-recursive. Muốn an toàn phải tự viết iterative; một số ngôn ngữ trên JVM (như Kotlin với tailrec) tự chuyển ở compile time.

Bài tiếp theo: Heap và cấp phát động

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