Cấp phát động trong C++ với new và delete: stack, heap và rò rỉ bộ nhớ

Cấp phát động trong C++ với new và delete: stack, heap và rò rỉ bộ nhớ - Khuê Nguyễn Creator

Trong bài này chúng ta sẽ học cấp phát động trong C++ với new và delete: bộ nhớ stack và heap khác nhau thế nào, cách cấp phát biến, mảng và đối tượng trên heap, và 4 lỗi kinh điển là rò rỉ bộ nhớ, con trỏ treo, delete hai lần, dùng nhầm delete với delete[]. Đây là phần nền để hiểu vì sao C++ hiện đại khuyên dùng smart pointer thay cho new/delete trực tiếp.

Bài 12 trong Serie Lập trình C++ từ A tới Z

Bộ nhớ stack và heap

Chương trình C++ dùng 2 vùng nhớ chính để lưu biến:

Sơ đồ so sánh bộ nhớ stack và heap trong C++

  • Stack chứa biến cục bộ và tham số hàm. Vào hàm thì biến được tạo, ra khỏi hàm thì tự hủy. Nhanh, tự động, nhưng nhỏ và kích thước phải biết lúc biên dịch.
  • Heap (còn gọi là free store) là vùng nhớ lớn mà chương trình xin cấp lúc chạy bằng new. Vùng nhớ tồn tại cho tới khi chương trình tự trả lại bằng delete.

Ví dụ đời thường: stack giống bàn làm việc, xong việc thì dọn sạch. Heap giống tủ đồ thuê ngoài: muốn dùng bao lâu cũng được, nhưng phải tự nhớ đi trả, quên trả thì tủ bị chiếm mãi.

Mọi biến trong các bài trước đều nằm trên stack. Vậy khi nào cần heap? Khi biến phải sống lâu hơn hàm tạo ra nó, khi kích thước chỉ biết lúc chạy, hoặc khi dữ liệu quá lớn so với stack.

Trả về địa chỉ biến cục bộ

Đây là lý do rõ nhất để cần heap:

#include <iostream>

int* taoSoLuongSai()
{
    int soLuong = 5;          // bien cuc bo nam tren stack
    return &soLuong;          // sai: bien bi huy khi ham ket thuc
}

int* taoSoLuongDung()
{
    int* p = new int(5);      // cap phat tren heap, ton tai toi khi delete
    return p;
}

int main()
{
    int* q = taoSoLuongDung();
    std::cout << "Gia tri tren heap: " << *q << '\n';
    delete q;
    return 0;
}

g++ cảnh báo address of local variable returned khi trả về địa chỉ biến cục bộ

taoSoLuongSai trả về địa chỉ của soLuong, nhưng soLuong nằm trên stack và bị hủy ngay khi hàm kết thúc. Người gọi nhận về một địa chỉ trỏ vào vùng nhớ đã bị thu hồi. g++ cảnh báo address of local variable returned. Còn taoSoLuongDung cấp phát trên heap bằng new, vùng nhớ vẫn còn sau khi hàm kết thúc.

Cấp phát động trong C++ với new và delete

  • new KieuDuLieu(giaTri) cấp phát một biến trên heap, trả về con trỏ tới nó.
  • delete conTro trả vùng nhớ đó lại cho hệ thống.

Mỗi new phải có đúng một delete tương ứng, không thiếu, không thừa. Chương trình ở trên làm đúng điều này: new int(5) trong hàm, delete q trong main.

Nếu đã học cấp phát động với malloc trong C, new/delete tương ứng với malloc/free. Khác biệt quan trọng: new gọi hàm khởi tạo và delete gọi hàm hủy của đối tượng, còn malloc/free chỉ cấp phát byte thô. Vì vậy trong C++ không dùng malloc cho đối tượng.

Cấp phát mảng động với new[] và delete[]

Mảng thô ở bài mảng và con trỏ trong C++ phải biết kích thước lúc biên dịch. Mảng trên heap thì kích thước quyết định lúc chạy:

#include <iostream>

int main()
{
    int n;
    std::cout << "So dau sach: ";
    std::cin >> n;

    int* soLuong = new int[n];          // mang co kich thuoc biet luc chay
    for (int i = 0; i < n; i++)
        soLuong[i] = (i + 1) * 10;

    std::cout << "So luong:";
    for (int i = 0; i < n; i++)
        std::cout << ' ' << soLuong[i];
    std::cout << '\n';

    delete[] soLuong;                    // mang thi phai delete[]
    soLuong = nullptr;
    return 0;
}

Cấp phát động trong C++: mảng có kích thước nhập lúc chạy cấp bằng new[] rồi giải phóng bằng delete[]

Cấp phát bằng new[] thì phải trả bằng delete[], có cặp ngoặc vuông. Sau khi delete, gán con trỏ về nullptr để lỡ dùng lại thì chương trình crash ngay chứ không âm thầm đọc rác.

Trên thực tế, mảng có kích thước lúc chạy thì dùng std::vector như bài string và vector, nó làm đúng việc new[]/delete[] ở trên nhưng tự động. Ví dụ này chỉ để các bạn hiểu bên dưới vector làm gì.

Cấp phát đối tượng

new với class sẽ gọi hàm khởi tạo, delete sẽ gọi hàm hủy:

#include <iostream>
#include <string>

class Sach
{
public:
    Sach(const std::string& ten) : ten_(ten) { std::cout << "  Tao sach '" << ten_ << "'\n"; }
    ~Sach() { std::cout << "  Huy sach '" << ten_ << "'\n"; }
    const std::string& ten() const { return ten_; }

private:
    std::string ten_;
};

int main()
{
    std::cout << "new:\n";
    Sach* s = new Sach("Lap trinh C++");
    std::cout << "Dang dung: " << s->ten() << '\n';
    std::cout << "delete:\n";
    delete s;
    std::cout << "Ket thuc main\n";
    return 0;
}

new gọi hàm khởi tạo, delete gọi hàm hủy của đối tượng Sach

Đối tượng tạo bằng new dùng qua con trỏ với toán tử ->. Hàm hủy chạy đúng lúc gọi delete, không phải lúc kết thúc khối lệnh như đối tượng trên stack. Đây cũng là cách các đối tượng đa hình ở bài tính đa hình trong C++ được tạo ra.

4 lỗi kinh điển khi dùng new và delete

1. Rò rỉ bộ nhớ (memory leak)

Quên delete thì vùng nhớ bị chiếm cho tới khi chương trình tắt:

#include <iostream>

void xuLyPhieuMuon()
{
    int* boDem = new int[1000];      // 4000 byte
    boDem[0] = 1;
    // quen delete[] boDem;
}

int main()
{
    for (int i = 0; i < 3; i++)
        xuLyPhieuMuon();
    std::cout << "Xu ly xong 3 phieu muon\n";
    return 0;
}

LeakSanitizer phát hiện rò rỉ 12000 byte khi quên delete[] trong hàm

Chạy bình thường thì không có dấu hiệu gì. Biên dịch với -fsanitize=address thì khi chương trình kết thúc, LeakSanitizer báo rò rỉ 12000 byte trong 3 lần cấp phát, chỉ đúng dòng 5 là chỗ new. Mỗi phiếu mượn mất 4000 byte, một server chạy liên tục xử lý triệu phiếu thì sẽ hết RAM.

2. Con trỏ treo (dangling pointer)

Dùng con trỏ sau khi đã delete:

#include <iostream>

int main()
{
    int* soLuong = new int(5);
    delete soLuong;
    std::cout << "Sau delete: " << *soLuong << '\n';   // dung vung nho da tra
    return 0;
}

Con trỏ treo đọc ra số rác, AddressSanitizer báo heap-use-after-free

Lần chạy thường in ra một số rác, không báo lỗi gì. AddressSanitizer bắt được và gọi tên đúng lỗi: heap-use-after-free, đọc vùng nhớ heap sau khi đã giải phóng.

3. delete hai lần

#include <iostream>

int main()
{
    int* soLuong = new int(5);
    delete soLuong;
    delete soLuong;          // xoa lan hai
    std::cout << "Xong\n";
    return 0;
}

delete hai lần làm chương trình dừng với lỗi double free detected

Lần này chương trình bị hệ thống dừng ngay với thông báo double free detected. Trong chương trình lớn, lỗi này thường xảy ra khi 2 đối tượng cùng giữ một con trỏ và cả hai đều nghĩ mình phải delete.

4. new[] mà delete thường

int main()
{
    int* ds = new int[10];
    delete ds;               // sai: mang phai dung delete[]
    return 0;
}

Cấp phát bằng new[] mà giải phóng bằng delete, AddressSanitizer báo alloc-dealloc-mismatch

Biên dịch và chạy bình thường đều không có dấu hiệu gì, nhưng đây là undefined behavior. Với mảng đối tượng, chỉ phần tử đầu được gọi hàm hủy. AddressSanitizer báo alloc-dealloc-mismatch.

Lưu ý: Cả 4 lỗi trên đều biên dịch được và nhiều khi chạy vẫn “có vẻ đúng”. Khi viết code có new/delete, hãy luôn chạy thử một lần với -g -fsanitize=address. Cờ này bắt được tất cả các lỗi trong bài, chỉ đúng dòng code gây lỗi.

Vì sao C++ hiện đại hạn chế new và delete?

Nhìn lại 4 lỗi trên, chúng có chung một nguyên nhân: lập trình viên phải tự nhớ ai sở hữu vùng nhớ và khi nào trả. Chương trình càng lớn, có càng nhiều nhánh if, return giữa chừng hay ngoại lệ, thì càng dễ quên.

C++ giải quyết bằng kỹ thuật RAII: gắn vùng nhớ với một đối tượng trên stack, đối tượng đó tự delete trong hàm hủy. Các bạn đã dùng RAII mà không biết:

  • std::string và std::vector cấp phát trên heap bên trong, và tự giải phóng khi bị hủy.
  • std::unique_ptr và std::shared_ptr (smart pointer) là con trỏ tự delete đối tượng nó trỏ tới.

Quy tắc chung trong code C++ hiện đại: gần như không bao giờ viết new và delete trực tiếp. Cần mảng động thì dùng std::vector, cần một đối tượng trên heap thì dùng std::make_unique. Hiểu new/delete vẫn cần thiết để đọc code cũ, viết thư viện cấp thấp, và hiểu các công cụ kia làm gì bên dưới.

Bài tập

  1. Viết hàm int* taoMang(int n) cấp phát mảng n phần tử trên heap, gán giá trị từ 1 tới n rồi trả về. Trong main, in mảng và giải phóng đúng cách.
  2. Viết lại bài 1 bằng std::vector<int>. So sánh số dòng code và số chỗ có thể quên giải phóng.
  3. Cố tình quên delete[] trong bài 1, biên dịch với -fsanitize=address và đọc báo cáo.
  4. Cho class Sach có hàm hủy in ra màn hình. Cấp phát new Sach[3] rồi giải phóng bằng delete (không có ngoặc vuông). Hàm hủy được gọi mấy lần?

Lời giải sẽ có trong bài tập tổng hợp của chương nhé.

Kết

Cấp phát động trong C++ với new và delete cho phép tạo dữ liệu sống lâu hơn hàm và có kích thước quyết định lúc chạy, nhưng đi kèm trách nhiệm tự giải phóng. 4 lỗi cần nhớ: quên delete gây rò rỉ, dùng sau delete là con trỏ treo, delete hai lần, và new[] phải đi với delete[]. Công cụ AddressSanitizer bắt được cả 4.

Muốn tra cứu chi tiết về new và delete, các bạn xem thêm tài liệu chuẩn trên cppreference.

Bài tiếp theo chúng ta sẽ học smart pointer unique_ptr và shared_ptr, cách C++ hiện đại loại bỏ gần hết các lỗi trên. Nếu thấy bài viết hữu ích, hãy chia sẻ cho bạn bè nhé.

Rate this post

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *