1. Mở đầu: Vì sao cấp phát bộ nhớ động lại quan trọng?
Trong C++, cấp phát bộ nhớ động là một cơ chế trung tâm cho phép chương trình tạo và quản lý dữ liệu trong thời gian chạy, thay vì bị ràng buộc hoàn toàn tại thời điểm biên dịch. Khác với các biến cục bộ trên ngăn xếp – vốn có vòng đời ngắn và phạm vi rõ ràng – bộ nhớ động trên heap cho phép dữ liệu tồn tại linh hoạt hơn, vượt ra khỏi phạm vi của một hàm hay một khối lệnh. Tuy nhiên, chính sự linh hoạt này cũng khiến cấp phát bộ nhớ động trở thành một trong những nguồn lỗi phức tạp nhất trong C++, từ rò rỉ bộ nhớ, con trỏ treo (dangling pointer), cho đến các lỗi truy cập bộ nhớ nghiêm trọng. Vì vậy, hiểu rõ các cơ chế cấp phát và giải phóng bộ nhớ động là điều kiện bắt buộc để viết C++ một cách an toàn và chuyên nghiệp.
Cấp phát bộ nhớ động trên heap có nghĩa là chương trình yêu cầu hệ điều hành (hoặc bộ cấp phát bộ nhớ runtime) dành riêng một khối bộ nhớ liền kề trong không gian địa chỉ ảo, đánh dấu khối này là “đang được sử dụng”, và trả về một địa chỉ để chương trình có thể truy cập trong thời gian chạy. Điểm cốt lõi cần nhấn mạnh là: heap không có cơ chế tự động thu hồi như stack. Điều này trao cho lập trình viên quyền kiểm soát rất lớn, nhưng đồng thời đặt toàn bộ trách nhiệm quản lý vòng đời bộ nhớ lên vai họ. Trong C++, có hai họ cơ chế chính để thao tác với bộ nhớ heap:
- Các hàm cấp phát theo phong cách C: malloc, calloc, free.
- Các toán tử hướng đối tượng của C++: new, delete
2. Sử dụng malloc và free
Trong C và cả C++, các hàm malloc và calloc đại diện cho cơ chế cấp phát bộ nhớ động mức thấp. Chúng không hiểu khái niệm đối tượng, không gọi hàm khởi tạo hay hủy, mà chỉ đơn thuần cấp phát các byte thô trên heap. malloc (Memory Allocation) được sử dụng để cấp phát một khối bộ nhớ liên tục có kích thước xác định trước, tính bằng byte. calloc (Cleared Memory Allocation) cấp phát nhiều khối bộ nhớ có cùng kích thước và tự động khởi tạo toàn bộ vùng nhớ về 0. Đây là cú pháp của malloc và calloc:
pointer_name = (cast-type*) malloc(size);
pointer_name = (cast-type*) calloc(num_elems, size_elem);
Cả hai hàm đều:
- Trả về con trỏ kiểu void*.
- Trả về NULL nếu việc cấp phát thất bại.
- Chỉ cấp phát bộ nhớ thô, không gắn với bất kỳ kiểu dữ liệu hay khái niệm đối tượng nào của C++.

#include <stdio.h>
#include <stdlib.h>
int main()
{
int *p = (int *)malloc(sizeof(int)*2);
printf("address=%p, value=%d\n", p, *p);
return 0;
}
Bộ nhớ đã được khởi tạo với giá trị 0 trong trường hợp này. Tại thời điểm biên dịch, chỉ có không gian cho con trỏ được dành riêng (trên ngăn xếp). Khi con trỏ được khởi tạo, một khối bộ nhớ có kích thước sizeof(int) byte được cấp phát (trên heap) tại thời điểm chạy chương trình. Con trỏ trên ngăn xếp sau đó trỏ đến vị trí bộ nhớ này trên vùng nhớ heap.
Giải phóng bộ nhớ
Mọi bộ nhớ được cấp phát bằng malloc hoặc calloc đều phải được giải phóng bằng free khi không còn cần thiết. Nếu không, bộ nhớ sẽ tiếp tục bị chiếm dụng, dẫn đến hiện tượng rò rỉ bộ nhớ. Trong các chương trình chạy lâu dài, điều này có thể khiến hệ thống cạn kiệt tài nguyên hoặc suy giảm hiệu năng nghiêm trọng. Việc quản lý bộ nhớ động bằng free đi kèm với nhiều quy tắc nghiêm ngặt. Chỉ những vùng nhớ được cấp phát động mới được phép giải phóng. Một khối bộ nhớ chỉ được giải phóng một lần duy nhất. Vi phạm các nguyên tắc này sẽ dẫn đến hành vi không xác định, thường là lỗi runtime nghiêm trọng.
#include <stdio.h>
#include <stdlib.h>
int main()
{
void *p = malloc(100);
free(p);
void *p2 = p;
free(p2);
return 0;
}
*** Error in `./free_example': double free or corruption (fasttop): 0x00000000298d5010 ***
Trong ví dụ bên trên, con trỏ p được sao chép vào một biến mới p2, sau đó biến này được truyền cho hàm free sau khi con trỏ ban đầu đã được giải phóng.Con trỏ p2 trong ví dụ không hợp lệ ngay khi hàm free(p) được gọi. Nó vẫn giữ địa chỉ đến vị trí bộ nhớ đã được giải phóng, nhưng không thể truy cập được nữa. Con trỏ như vậy được gọi là "dangling pointer".
3. Sử dụng new và delete
Sự khác biệt căn bản giữa C và C++ nằm ở lập trình hướng đối tượng. Trong C++, việc cấp phát bộ nhớ không chỉ đơn thuần là xin byte từ heap, mà còn gắn liền với vòng đời của đối tượng. Khi một đối tượng được tạo, hàm khởi tạo phải được gọi; khi đối tượng bị hủy, hàm hủy phải có cơ hội dọn dẹp tài nguyên. Chính vì lý do này, C++ giới thiệu toán tử new và delete. Chúng không chỉ cấp phát và giải phóng bộ nhớ, mà còn điều phối vòng đời đối tượng một cách đầy đủ. new đảm bảo rằng bộ nhớ được cấp phát trước, sau đó hàm khởi tạo được gọi. Ngược lại, delete đảm bảo hàm hủy được gọi trước khi vùng nhớ bị giải phóng. Việc sử dụng malloc để tạo đối tượng C++ sẽ bỏ qua toàn bộ cơ chế này, dẫn đến trạng thái đối tượng không hợp lệ và lỗi truy cập bộ nhớ – một sai lầm điển hình của người mới học C++.
new – cấp phát và khởi tạo đối tượng
Khi sử dụng new, hai thao tác được thực hiện liên tiếp:
- Cấp phát bộ nhớ đủ lớn để chứa đối tượng.
- Gọi constructor của đối tượng tại vùng nhớ vừa cấp phát.
Điều này đảm bảo rằng đối tượng được tạo ra ở trạng thái hợp lệ ngay từ đầu.
delete – hủy đối tượng và giải phóng bộ nhớ
Ngược lại, delete cũng bao gồm hai bước:
- Gọi destructor của đối tượng để giải phóng tài nguyên do lập trình viên quản lý.
- Giải phóng vùng bộ nhớ mà đối tượng chiếm giữ.
#include <stdlib.h>
#include <iostream>
class MyClass
{
private:
int *_number;
public:
MyClass()
{
std::cout << "Allocate memory\n";
_number = (int *)malloc(sizeof(int));
}
~MyClass()
{
std::cout << "Delete memory\n";
free(_number);
}
void setNumber(int number)
{
*_number = number;
std::cout << "Number: " << * _number << "\n";
}
};
int main()
{
// allocate memory using malloc
MyClass *myClass = (MyClass *)malloc(sizeof(MyClass));
myClass->setNumber(42); // EXC_BAD_ACCESS
free(myClass);
// allocate memory using new
MyClass *myClass1 = new MyClass();
myClass1->setNumber(42); // works as expected
delete myClass1;
return 0;
}
Nếu ta tạo một đối tượng C++ bằng malloc, thì hàm tạo và hàm hủy của đối tượng đó sẽ không được gọi. Hàm tạo cấp phát bộ nhớ cho phần tử private là con trỏ _number và hàm hủy giải phóng bộ nhớ. Phương thức setter setNumber cuối cùng gán một giá trị cho _number với giả định rằng bộ nhớ đã được cấp phát trước đó. Trong hàm main, cấp phát bộ nhớ cho một đối tượng của lớp MyClass với malloc, chương trình bị lỗi khi gọi phương thức setNumber, vì không có bộ nhớ nào được cấp phát cho _number - do hàm tạo chưa được gọi. Do đó, lỗi EXC_BAD_ACCESS xảy ra khi cố gắng truy cập vào vị trí bộ nhớ mà _number đang trỏ tới. Với new, kết quả sẽ trông như sau:
Nạp chồng toán tử `new` và `delete`
Một ưu điểm quan trọng của new và delete là chúng là toán tử, không phải hàm thông thường. Điều này cho phép lập trình viên nạp chồng chúng để triển khai các chiến lược quản lý bộ nhớ tùy chỉnh. Thông qua nạp chồng, lập trình viên có thể theo dõi kích thước bộ nhớ được cấp phát, tích hợp bộ nhớ pool, ghi log, hoặc tối ưu cho các hệ thống nhúng và thời gian thực. Trong cơ chế này, bộ nhớ luôn được cấp phát trước khi constructor chạy, và được giải phóng sau khi destructor hoàn tất, đảm bảo tính nhất quán tuyệt đối của vòng đời đối tượng.
- Cú pháp để nạp chồng toán tử `new` như sau: `void* operator new(size_t size);`- Toán tử nhận một tham số `size` có kiểu `size_t`, chỉ định số byte bộ nhớ cần cấp phát. Kiểu trả về của `new` được nạp chồng là một con trỏ `void`, tham chiếu đến đầu khối bộ nhớ được cấp phát.
- Cú pháp để nạp chồng toán tử `delete` như sau: `void operator delete(void*);` - Toán tử nhận một con trỏ đến đối tượng cần xóa. Trái ngược với `new`, toán tử `delete` không có giá trị trả về.
#include <iostream>
#include <stdlib.h>
class MyClass
{
double _mymember;
public:
MyClass()
{
std::cout << "Constructor is called\n";
}
~MyClass()
{
std::cout << "Destructor is called\n";
}
void *operator new(size_t size)
{
std::cout << "new: Allocating " << size << " bytes of memory" << std::endl;
void *p = malloc(size);
return p;
}
void operator delete(void *p)
{
std::cout << "delete: Memory is freed again " << std::endl;
free(p);
}
};
int main()
{
MyClass *p = new MyClass();
delete p;
}
Trong đoạn mã bên trên, cả toán tử `new` và `delete` đều được nạp chồng. Trong `new`, kích thước của đối tượng lớp tính bằng byte. Đồng thời, một khối bộ nhớ có kích thước đó được cấp phát trên heap và con trỏ đến khối bộ nhớ này được trả về. Trong `delete`, khối bộ nhớ được giải phóng.Như có thể thấy từ thứ tự xuất ra của văn bản, bộ nhớ được khởi tạo trong `new` trước khi `constructor` được gọi, trong khi thứ tự bị đảo ngược đối với `destructor` và lời gọi `delete`
4. Kết luận
Cấp phát bộ nhớ động là một trong những kỹ năng quan trọng nhất mà lập trình viên C++ phải nắm vững. Từ malloc và free của C đến new và delete của C++, mỗi cơ chế phản ánh một cấp độ trừu tượng và triết lý thiết kế khác nhau. Hiểu rõ sự khác biệt giữa cấp phát bộ nhớ thô và quản lý vòng đời đối tượng là chìa khóa để viết chương trình C++ an toàn, hiệu quả và có khả năng mở rộng. Đây cũng là nền tảng để tiếp cận các kỹ thuật cao hơn như RAII, smart pointer và bộ cấp phát tùy chỉnh trong C++ hiện đại.


![Cấp phát bộ nhớ động [malloc & free] - [new & delete]](/_next/image?url=https%3A%2F%2Fdeviot.vn%2Fstorage%2Fdev-iot%2F1768587168165cpp-programming.jpg&w=3840&q=100)