Bộ nhớ/Heap và cấp phát động — dữ liệu sống lâu hơn hàm
4/13
Bài 4 / 13~18 phútMô hình bộ nhớ chương trìnhMiễn phí lượt xem

Heap và cấp phát động — dữ liệu sống lâu hơn hàm

Heap cấp phát bộ nhớ kích thước tuỳ ý lúc chạy, sống tới khi được giải phóng. Hiểu allocator, fragmentation và vì sao heap chậm hơn stack giúp bạn dùng bộ nhớ đúng.

TL;DR: Khi bạn không biết trước kích thước dữ liệu, hoặc cần dữ liệu sống lâu hơn hàm tạo ra nó, bạn cấp phát trên heap — một vùng nhớ lớn, linh hoạt, quản lý bởi một allocator (như malloc trong C, new trong Java). Khác stack (push/pop theo LIFO, tự dọn), heap cho cấp phát và giải phóng theo bất kỳ thứ tự nào, nên allocator phải theo dõi vùng nào trống, vùng nào đang dùng. Sự tự do đó có giá: cấp phát heap chậm hơn stack hàng chục tới hàng trăm lần, và sau nhiều lần cấp/giải phóng kích thước khác nhau, heap bị fragmentation — bộ nhớ trống nhưng vỡ vụn, không gom được thành khối lớn. Hiểu heap giải thích vì sao new tốn kém, vì sao cấp phát nhiều object nhỏ gây áp lực bộ nhớ, và vì sao tái dùng object đôi khi đáng giá.

Bạn đọc một file kích thước chưa biết vào một ArrayList, nó tự lớn dần. Bạn tạo một object trong hàm rồi return nó, và nó vẫn sống sau khi hàm kết thúc. Cả hai chỉ có thể nhờ heap — vùng nhớ không bị ràng buộc vào vòng đời của một lời gọi hàm. Nhưng heap không miễn phí: nó cần một bộ máy quản lý phức tạp mà stack không cần.

Bài này giải thích vì sao heap tồn tại, allocator cấp phát và giải phóng thế nào, fragmentation phát sinh ra sao, và những quyết định code rút ra từ chi phí của heap.

1. Analogy — bãi đỗ xe so với chồng đĩa

Stack giống chồng đĩa (bài 02): chỉ thêm/lấy ở đỉnh, thứ tự cứng nhắc LIFO. Heap giống một bãi đỗ xe lớn: xe (object) đến và đi bất kỳ lúc nào, bất kỳ thứ tự nào. Người quản lý bãi (allocator) phải ghi sổ chỗ nào trống, chỗ nào có xe, và tìm chỗ đủ rộng khi một xe tải lớn (object lớn) tới.

Vấn đề nảy sinh: sau một ngày xe ra vào lộn xộn, bãi có thể còn 20 chỗ trống nhưng rải rác — không có 5 chỗ liền nhau cho một xe buýt. Bãi "còn chỗ" nhưng "không nhận được xe buýt". Đó là fragmentation.

Bãi đỗ xeHeap
Một chỗ đỗMột khối bộ nhớ
Xe đỗ vàoCấp phát (malloc, new)
Xe rời điGiải phóng (free, GC dọn)
Người quản lý ghi sổAllocator + cấu trúc free list
Tìm chỗ đủ rộng cho xe tảiTìm khối trống đủ lớn
Chỗ trống rải rác, không nhận xe buýtFragmentation
💡 Cách nhớ

Stack là chồng đĩa — trật tự, tự dọn, nhanh, nhưng cứng nhắc. Heap là bãi đỗ xe — linh hoạt về thứ tự và kích thước, nhưng cần người quản lý và dễ bị phân mảnh. Bạn trả phí linh hoạt bằng tốc độ và độ phức tạp.

2. Cơ chế — allocator làm gì khi bạn gọi new

Khi chương trình khởi động, OS cấp cho heap một vùng địa chỉ liên tục lớn. Allocator (một thư viện như malloc của glibc, hoặc bộ cấp phát của JVM) quản lý vùng này. Nó giữ một free list — danh sách các khối trống và kích thước.

Khi bạn gọi malloc(size) hoặc new:

  1. Allocator duyệt free list tìm một khối trống đủ lớn (≥ size).
  2. Cắt khối đó: phần size byte giao cho bạn, phần dư (nếu có) trả lại free list.
  3. Đánh dấu khối đã giao là "đang dùng" và trả về địa chỉ đầu khối.

Khi bạn free(ptr) (C) hoặc GC xác định object chết (Java):

  1. Khối được đánh dấu trống, đưa lại free list.
  2. Nếu khối kề một khối trống khác, allocator gộp (coalesce) chúng thành khối lớn hơn để chống fragmentation.
flowchart TD
  A["new MyObject()"] --> B{"Free list co khoi<br/>du lon khong?"}
  B -->|Co| C["Cat khoi, danh dau dang dung"]
  B -->|Khong| D["Xin them tu OS (sbrk/mmap)<br/>hoac chay GC"]
  C --> E["Tra ve dia chi object"]
  D --> C

Khác biệt cốt lõi với stack: stack cấp phát bằng một phép dịch stack pointer (1 lệnh); heap cấp phát bằng duyệt và cập nhật cấu trúc dữ liệu (hàng chục–trăm lệnh, có thể phải khoá để an toàn đa luồng). Đây là lý do cấp phát heap chậm hơn stack đáng kể.

Heap sau vai lan cap phat/giai phong:

[#### A ####][ trong ][## B ##][ trong ][###### C ######][  trong  ]
             ^4KB              ^2KB                       ^6KB trong
Tong trong = 12KB, nhung khoi lien tuc lon nhat = 6KB
-> mot object 8KB KHONG cap phat duoc du "con 12KB" -> fragmentation

3. Fragmentation — vì sao "còn bộ nhớ" mà vẫn hết

Có hai loại fragmentation:

  • External fragmentation: bộ nhớ trống bị chia thành nhiều khối nhỏ rải rác (như bãi đỗ xe ở trên). Tổng trống đủ, nhưng không khối liền nào đủ lớn cho yêu cầu mới. Đây là loại gây OutOfMemoryError dù "còn chỗ".
  • Internal fragmentation: allocator làm tròn kích thước lên (ví dụ luôn cấp bội số của 16 byte để căn chỉnh). Bạn xin 20 byte, nhận 32 byte — 12 byte dư bên trong khối bị lãng phí.

Allocator hiện đại chống fragmentation bằng nhiều kỹ thuật: phân loại khối theo kích thước (size classes), gộp khối kề nhau, hoặc — trong các runtime có GC như JVM — nén (compaction): di chuyển các object còn sống về sát nhau, dồn vùng trống thành một khối lớn liền mạch.

flowchart LR
  subgraph before["Truoc compaction"]
    direction LR
    X1["A"] --- G1[" "] --- X2["B"] --- G2[" "] --- X3["C"]
  end
  subgraph after["Sau compaction"]
    direction LR
    Y1["A"] --- Y2["B"] --- Y3["C"] --- G3["vung trong lien tuc"]
  end
  before --> after
Vì sao GC compaction là lợi thế lớn của Java/Go

Trong C, một khi đã malloc, allocator không thể di chuyển object (vì con trỏ tới nó nằm rải rác khắp chương trình, không tìm hết được). Nên C dễ tích luỹ external fragmentation theo thời gian — một vấn đề thật của dịch vụ chạy lâu. JVM và Go biết mọi tham chiếu tới mỗi object, nên GC có thể di chuyển object và cập nhật tham chiếu → nén heap, gần như xoá sạch external fragmentation. Đây là một trade-off cốt lõi: GC tốn CPU nhưng cho heap "trẻ mãi". Bạn sẽ đào sâu ở Module 4.

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

Hiểu chi phí heap dẫn tới vài quyết định thực tế về tốc độ và áp lực bộ nhớ.

Tránh cấp phát trong vòng lặp nóng. Mỗi new là một lần gọi allocator. Cấp phát object tạm trong vòng lặp chạy triệu lần tạo áp lực lớn lên allocator và GC:

// SAI: tao mot StringBuilder moi moi vong lap
for (int i = 0; i < 1_000_000; i++) {
    StringBuilder sb = new StringBuilder(); // 1 trieu lan cap phat
    sb.append(prefix).append(i);
    results.add(sb.toString());
}

// TOT HON: tai dung, clear thay vi cap phat lai
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1_000_000; i++) {
    sb.setLength(0);              // reset, khong cap phat moi
    sb.append(prefix).append(i);
    results.add(sb.toString());
}

Cấp phát sẵn dung lượng (pre-size) cho collection. ArrayList lớn dần bằng cách cấp một mảng mới lớn hơn rồi copy — mỗi lần grow là một lần cấp phát + copy. Nếu biết trước kích thước, cấp sẵn:

// SAI: list grow nhieu lan, moi lan cap phat + copy mang
List<Item> items = new ArrayList<>();
for (int i = 0; i < 100_000; i++) items.add(load(i));

// TOT: cap san dung luong -> 1 lan cap phat
List<Item> items = new ArrayList<>(100_000);
for (int i = 0; i < 100_000; i++) items.add(load(i));

Object pool cho object đắt. Khi object tốn kém để tạo (kết nối DB, buffer lớn), tái dùng qua pool thay vì cấp phát/giải phóng liên tục — đây là lý do connection pool tồn tại. Nhưng đừng lạm dụng: với object rẻ, pool phức tạp hoá code mà GC hiện đại (cấp phát object non rất nhanh) thường xử lý tốt hơn.

Đo trước khi tối ưu cấp phát

GC thế hệ mới (G1, ZGC) cấp phát object non gần như nhanh bằng stack (bump pointer trong eden) và dọn rác non rất rẻ. Đừng vội object-pool mọi thứ — nó thường làm chậm và rối code. Chỉ tối ưu cấp phát khi profiler (allocation profiler, GC log) chỉ ra cấp phát/GC thật sự là bottleneck.

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

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

Heap lớn từ OS thế nào: allocator xin bộ nhớ từ kernel qua syscall brk/sbrk (mở rộng vùng data segment) hoặc mmap (ánh xạ một vùng mới). Object lớn thường được mmap riêng để khi free trả thẳng về OS. Bạn sẽ học mmap kỹ ở Module 3 (bộ nhớ ảo).

Bump pointer allocation: trong vùng eden của JVM (nơi object mới ra đời), cấp phát thực ra nhanh như stack: chỉ dịch một con trỏ "đỉnh vùng còn trống" đi size byte. Đây gọi là bump pointer / TLAB (Thread-Local Allocation Buffer). Chi phí thật của object non không nằm ở cấp phát mà ở việc GC phải quét và (có thể) copy chúng. Đây là lý do "đừng object-pool object non" — cấp phát chúng gần như miễn phí.

Allocator thực tế: glibc dùng ptmalloc (dựa trên dlmalloc); các allocator hiệu năng cao như jemalloc (FreeBSD, Facebook) và tcmalloc (Google) tối ưu cho đa luồng bằng cách cho mỗi luồng một arena/cache riêng, giảm tranh khoá. Lựa chọn allocator ảnh hưởng đáng kể tới throughput và fragmentation của dịch vụ chạy lâu.

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

7. Tóm tắt

  • Heap là vùng nhớ lớn, linh hoạt, cho cấp phát kích thước tuỳ ý lúc chạy và sống lâu hơn hàm tạo ra nó.
  • Cấp phát heap do allocator quản lý: duyệt free list, cắt khối, đánh dấu đang dùng — tốn nhiều lệnh hơn stack hàng chục tới hàng trăm lần.
  • Fragmentation phát sinh khi cấp/giải phóng kích thước khác nhau: external (trống rải rác, không khối lớn liền) và internal (làm tròn kích thước, dư bên trong).
  • Allocator chống fragmentation bằng size class, gộp khối kề; runtime có GC còn nén (compaction) — di chuyển object còn sống về sát nhau, điều C không làm được.
  • Quyết định thực tế: tránh cấp phát trong vòng lặp nóng, pre-size collection, pool object đắt — nhưng luôn đo trước, vì GC hiện đại cấp phát object non gần như miễn phí.

8. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao cần heap khi đã có stack? Nêu hai tình huống bắt buộc dùng heap.
Stack bị ràng buộc hai mặt: dữ liệu chết khi frame pop và kích thước phải biết tại thời điểm biên dịch (frame có kích thước cố định). Heap gỡ cả hai ràng buộc. Tình huống 1: dữ liệu sống lâu hơn hàm — một hàm tạo object rồi return nó để nơi khác dùng; nếu để trên stack, nó chết khi hàm kết thúc. Tình huống 2: kích thước chưa biết lúc biên dịch — đọc một file độ dài tuỳ ý vào một list lớn dần, hay cấp một mảng có size do người dùng nhập. Cả hai cần vùng nhớ không gắn vòng đời với frame và cấp được kích thước tuỳ ý lúc chạy — đó là heap.
Q2
Vì sao cấp phát trên heap chậm hơn nhiều so với cấp phát biến cục bộ trên stack?
Cấp phát stack chỉ là dịch stack pointer đi đúng kích thước frame — một lệnh CPU, không cần tìm kiếm. Cấp phát heap phải chạy allocator: duyệt free list tìm khối trống đủ lớn, cắt khối, cập nhật cấu trúc quản lý, và (trong môi trường đa luồng) có thể phải khoá để tránh tranh chấp — tốn hàng chục tới hàng trăm lệnh. Thêm nữa, heap cần cơ chế giải phóng (free thủ công hoặc GC quét) mà stack không cần. Tự do về thứ tự và kích thước của heap phải trả giá bằng bộ máy quản lý phức tạp đó. (Ngoại lệ: bump pointer/TLAB trong JVM cho cấp phát object non nhanh gần bằng stack.)
Q3
Phân biệt external và internal fragmentation. Loại nào gây 'còn tổng bộ nhớ mà vẫn OutOfMemory'?
External fragmentation: bộ nhớ trống bị chia thành nhiều khối nhỏ rải rác giữa các object đang dùng. Tổng trống có thể lớn, nhưng không khối liền nào đủ lớn cho một yêu cầu mới. Internal fragmentation: allocator làm tròn kích thước lên (ví dụ bội số 16 byte để căn chỉnh), nên xin 20 byte nhận 32 byte — 12 byte dư bên trong khối bị lãng phí. Loại gây "còn tổng bộ nhớ mà vẫn hết" là external: ví dụ còn tổng 12 KB trống nhưng vỡ thành các mảnh 4 KB, 2 KB, 6 KB, nên một object 8 KB không cấp phát được dù "còn 12 KB".
Q4
Vì sao JVM và Go có thể nén (compact) heap để chống fragmentation, còn C thì không?
Nén heap đòi hỏi di chuyển object tới vị trí mới và cập nhật mọi con trỏ đang trỏ tới nó. JVM/Go là môi trường có GC: runtime biết chính xác mọi tham chiếu tới từng object (qua thông tin kiểu và GC roots), nên sau khi dời object, nó tìm và sửa hết các tham chiếu. C thì không: con trỏ là số nguyên thô có thể nằm bất cứ đâu (biến, mảng, field, thậm chí ghi ra file), allocator không thể tìm hết để cập nhật. Nên một khi malloc, địa chỉ object cố định vĩnh viễn cho tới khi free — C dễ tích luỹ external fragmentation theo thời gian, còn JVM/Go giữ heap gọn nhờ compaction (đổi lại tốn CPU cho GC).
Q5
Một vòng lặp chạy một triệu lần tạo một StringBuilder mới mỗi vòng. Vì sao điều này tốn kém, và sửa thế nào?
Mỗi new StringBuilder() là một lần gọi allocator (cấp phát object + mảng char bên trong) và sinh một object rác sau mỗi vòng. Một triệu vòng tạo một triệu object tạm → áp lực lớn lên allocator và GC (GC phải quét và dọn chúng), dù mỗi object cuối cùng đều chết nhanh. Cách sửa: tái dùng một StringBuilder duy nhất ngoài vòng lặp, gọi sb.setLength(0) để reset nội dung mỗi vòng thay vì cấp phát mới. Như vậy chỉ một lần cấp phát thay vì một triệu. Nguyên tắc chung: tránh cấp phát object tạm trong vòng lặp nóng — nhưng đo bằng allocation profiler trước, vì không phải mọi cấp phát đều đáng tối ưu.
Q6
Tạo một ArrayList rỗng rồi add 100.000 phần tử khác gì với new ArrayList<>(100_000) về mặt cấp phát?
Một ArrayList bên trong là một mảng. Khi đầy, nó grow bằng cách cấp một mảng mới lớn hơn (thường ~1.5 lần) rồi copy toàn bộ phần tử sang. Bắt đầu từ rỗng và add 100.000 phần tử kích hoạt nhiều lần grow (mỗi lần là một cấp phát mảng mới + copy O(n)), tạo nhiều mảng rác trung gian. new ArrayList<>(100_000) cấp sẵn mảng đủ lớn ngay từ đầu — một lần cấp phát duy nhất, không grow, không copy lại, không rác trung gian. Khi biết trước (hoặc ước lượng được) kích thước, pre-size giúp giảm cả thời gian lẫn áp lực bộ nhớ.

Bài tiếp theo: Stack vs heap vs static

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