Các hàm quản lý bộ nhớ động trong C thước thử viện nào

Chào các bạn học viên đang theo dõi khóa học lập trình trực tuyến ngôn ngữ C++.

Trong bài học này, mình sẽ tiếp tục giới thiệu đến các bạn một số vấn đề về con trỏ và sử dụng con trỏ để quản lý bộ nhớ ảo trong ngôn ngữ C++.

Như mình đã đề cập trong bài học phạm vi của biến, thời gian tồn tại của biến phụ thuộc vào vị trí bạn khai báo biến.

  • Biến toàn cục [global variable] được khai báo bên ngoài khối lệnh, có thể được truy xuất tại bất cứ dòng lệnh nào đặt bên dưới biến đó. Biến toàn cục tồn tại đến khi chương trình bị kết thúc.
  • Biến cục bộ [local variable] được khai báo bên trong khối lệnh, có thể được truy xuất tại bất cứ dòng lệnh nào đặt bên dưới biến đó và trong cùng khối lệnh. Biến cục bộ bị hủy khi chương trình chạy ra ngoài khối lệnh chứa biến đó.

Tương ứng với 2 kiểu khai báo biến này là 2 cách thức cấp phát bộ nhớ cho chương trình trên bộ nhớ ảo:

Static memory allocation [cấp phát bộ nhớ tĩnh]

Static memory allocation còn được gọi là Compile-time allocation, được áp dụng cho biến static và biến toàn cục.

  • Vùng nhớ của các biến này được cấp phát ngay khi chạy chương trình.
  • Kích thước của vùng nhớ được cấp phát phải được cung cấp tại thời điểm biên dịch chương trình.
  • Đối với việc khai báo mảng một chiều, đây là lý do tại sao số lượng phần tử là hằng số.
Automatic memory allocation [cấp phát bộ nhớ tự động]

Automatic memory allocation được sử dụng để cấp phát vùng nhớ cho các biến cục bộ, tham số của hàm.

  • Bộ nhớ được cấp phát tại thời điểm chương trình đang chạy, khi chương trình đi vào một khối lệnh.
  • Các vùng nhớ được cấp phát sẽ được thu hồi khi chương trình đi ra khỏi một khối lệnh.
  • Kích thước vùng cần cấp phát cũng phải được cung cấp rõ ràng.
Nhược điểm của các phương thức cấp phát bộ nhớ đã học

Kích thước vùng nhớ cấp phát phải được cung cấp tại thời điểm biên dịch chương trình

Lấy ví dụ, chúng ta cần lưu trữ tên của tất cả sinh viên trong một lớp học. Chúng ta sẽ sử dụng một mảng các string để lưu trữ như sau:

string name_of_students[50];

Mình hiện tại không biết có bao nhiêu sinh viên trong một lớp học, nên mình chỉ ước tính con số tối đa lượng sinh viên của lớp này là 50 người. Vậy điều gì xảy ra khi lớp học có nhiều hơn 50 sinh viên? Mảng name_of_students sẽ không thể lưu hết tên của tất cả sinh viên được. Bên cạnh đó, nếu số lượng sinh viên của lớp học chỉ có 30 người, mảng name_of_students sẽ thừa ra 20 phần tử không cần sử dụng đến.

Cấp phát và thu hồi vùng nhớ do chương trình quyết định

Trong một số trường hợp, chúng ta cần sử dụng biến toàn cục để có thể truy cập vùng nhớ của biến tại nhiều khối lệnh khác nhau trong chương trình, nhưng thời gian tồn tại của biến toàn cục khá lâu, nên khi sử dụng biến toàn cục sẽ gây ảnh hưởng đáng kể lượng tài nguyên bộ nhớ của máy tính nếu chúng ta cấp phát cho biến toàn cục một vùng nhớ lớn.

Hoặc trong một số trường hợp khác, chúng ta vẫn muốn sử dụng tiếp vùng nhớ cấp phát cho biến bên trong hàm, nhưng biến cục bộ đặt trong khối lệnh [cùng với vùng nhớ nó quản lý] sẽ bị hủy khi hàm kết thúc.

Kích thước bộ nhớ dùng cho Static memory allocation và Automatic memory allocation bị giới hạn

Bộ nhớ ảo được chia thành nhiều phân vùng khác nhau sử dụng cho những loại tài nguyên khác nhau. Trong đó, các phương thức cấp phát bộ nhớ Static memory allocation hay Automatic memory allocation sẽ sử dụng phân vùng Stack để lưu trữ. Chúng ta sẽ có một bài học để nói chi tiết về các phân vùng trên bộ nhớ ảo. Bây giờ các bạn tạm thời hình dung bộ nhớ ảo chúng ta sẽ chia thành các phần như sau:

Phân vùng Stack được đặt tại vùng có địa chỉ cao nhất trong dãy bộ nhớ ảo. Dung lượng của phân vùng này khá hạn chế. Tùy vào mỗi hệ điều hành mà dung lượng bộ nhớ của phân vùng Stack khác nhau. Đối với Visual studio 2015 chạy trên hệ điều hành Windows, dung lượng bộ nhớ của phân vùng Stack là khoảng 1MB [tương đương khoảng 1024 Kilobytes hay 1024*1024 bytes].

Với sự hạn chế về dung lượng bộ nhớ của phân vùng Stack, chương trình của chúng ta sẽ phát sinh lỗi stack overflow nếu các bạn yêu cầu cấp phát vùng nhớ vượt quá dung lượng của Stack. Các bạn có thể chạy thử 2 đoạn chương trình sau để kiểm chứng:

int main[]
{
    char ch_array[1024 * 1000];

    system["pause"];
    return 0;
}

Trong đoạn chương trình trên, mình khai báo một mảng kí tự có tên ch_array, như các bạn biết kiểu char có kích thước 1 byte cho mỗi biến đơn [tương ứng với mỗi phần tử trong mảng kí tự], 1024 bytes sẽ tương ứng với 1Kb [Kilobyte]. Do ch_array là biến cục bộ, nó sẽ được cấp phát vùng nhớ trên phân vùng Stack của bộ nhớ ảo. Như vậy, mảng ch_array sẽ được cấp phát 1000 kilobytes trên phân vùng Stack, nhưng con số này vẫn chưa vượt quá giới hạn 1Mb [1 Megabyte = 1024 Kilobytes] nên chương trình vẫn chạy bình thường. Bây giờ các bạn thử lại với đoạn chương trình sau:

int main[]
{
    char ch_array[1024 * 1024];

    system["pause"];
    return 0;
}

Kích thước vùng nhớ được yêu cầu cấp phát bây giờ là đúng bằng 1 Mb. Thử chạy chương trình ở chế độ Debug, Visual Studio 2015 trên máy tính mình đưa ra thông báo:

Việc cấp phát vùng nhớ có kích thước 1 Mb đã gây tràn bộ nhớ phân vùng Stack.

Đây là một số hạn chế của các phương thức cấp phát bộ nhớ Static memory allocation và Automatic memory allocation. Để khắc phục hạn chế này, mình giới thiệu đến các bạn một phương thức cấp phát bộ nhớ mới được ngôn ngữ C++ hổ trợ.

Dynamic memory allocation

Dynamic memory allocation là một giải pháp cấp phát bộ nhớ cho chương trình tại thời điểm chương trình đang chạy [run-time]. Dynamic memory allocation sử dụng phân vùng Heap trên bộ nhớ ảo để cấp phát cho chương trình.

Như các bạn thấy trong hình trên, phân vùng Heap của bộ nhớ ảo có dung lượng bộ nhớ lớn nhất. Do đó, bộ nhớ dùng để cấp phát cho chương trình trên phân vùng Heap chỉ bị giới hạn bởi thiết bị phần cứng [ví dụ là RAM] chứ không phụ thuộc vào hệ điều hành. Trong các máy tính hiện đại ngày nay, dung lượng bộ nhớ của phân vùng Heap có thể lên đến đơn vị GB [1 Gigabyte = 1024 Megabytes = 1024 * 1024 Kilobytes].

Đọc kỹ hướng dẫn sử dụng trước khi dùng

Kỹ thuật Dynamic memory allocation dùng để cấp phát bộ nhớ tại thời điểm run-time. Tại thời điểm này, chúng ta không thể tạo ra tên biến mới, mà chỉ có thể tạo ra vùng nhớ mới. Do đó, cách duy nhất để kiểm soát được những vùng nhớ được cấp phát bằng kỹ thuật Dynamic memory allocation là sử dụng con trỏ lưu trữ địa chỉ đầu tiên của vùng nhớ được cấp phát, thông qua con trỏ để quản lý vùng nhớ trên Heap.

Vậy, việc thực hiện cấp phát bộ nhớ cần thực hiện qua 2 bước:

  • Yêu cầu cấp phát vùng nhớ trên Heap.
  • Lưu trữ địa chỉ của vùng nhớ vừa được cấp phát bằng con trỏ.

Để yêu cầu cấp phát bộ nhớ trên Heap, chúng ta sử dụng new operator.

Vùng nhớ được cấp phát trên Heap sẽ không tự động hủy bởi chương trình khi kết thúc khối lệnh, việc thu hồi vùng nhớ đã cấp phát trên Heap được giao cho lập trình viên tự quản lý. Nếu trong chương trình có yêu cầu cấp phát bộ nhớ trên Heap mà không được thu hồi hợp lý sẽ gây lãng phí tài nguyên hệ thống. Cũng giống như xin nhà nước cấp phát cho một vùng đất để xây dựng nhà máy, đang xây giữa chừng thì bên thầu công trình ăn hết vốn nên dự án xây dựng nhà máy bị hoãn lại, nhưng đất được nhà nước cấp phát không được trả lại cho nhà nước để làm việc khác, thế là lãng phí một vùng đất mà không làm được gì, tài nguyên trên máy tính cũng tương tự như vậy.

Để thu hồi vùng nhớ đã được cấp phát thông qua toán tử new, chúng ta sử dụng toán tử delete.

Dynamically allocate single variables

new operator

Toán tử new được dùng để xin cấp phát vùng nhớ trên phân vùng Heap của bộ nhớ ảo.

Toán tử new trong chuẩn C++11 được định nghĩa với 3 prototype như sau:

void* operator new [std::size_t size];
void* operator new [std::size_t size, const std::nothrow_t& nothrow_value] noexcept;
void* operator new [std::size_t size, void* ptr] noexcept;

Các bạn chưa cần phải hiểu những tham số khai báo cho toán tử new, mà hiện tại chỉ cần chú ý kiểu trả về của nó [void *]. Toán tử new sau khi xin cấp phát vùng nhớ trên Heap sẽ trả về một con trỏ chứa địa chỉ của vùng nhớ được cấp phát [nếu cấp phát thành công].

Kiểu trả về của toán tử new là con trỏ kiểu void, đây là một con trỏ đặc biệt, chúng ta sẽ tìm hiểu nó trong bài học sau. Nhưng dù nó là con trỏ kiểu gì thì mục đích của nó vẫn là chứa địa chỉ, do đó, chúng ta có thể gán giá trị trả về của toán tử new cho một con trỏ khác để quản lý vùng nhớ đã được cấp phát.

usage of new operator

Cú pháp sử dụng toán tử new như sau:

new ;

Ví dụ:

new int; //allocate 4 bytes on Heap partition to an int variable
new double; //allocate 8 bytes on Heap partition to a double variable

Khi chương trình đang chạy, nếu quá trình cấp phát bộ nhớ trên thành công, chúng ta sẽ có địa chỉ của 2 vùng nhớ được trả về. Nhưng như mình đã nói, chúng ta không thể tạo thêm tên biến mới khi chương trình đang chạy, do đó chúng ta cần gán nó cho những con trỏ cùng kiểu để quản lý:

int *p_int = new int;
double *p_double = new double;

Bây giờ, vùng nhớ được cấp phát sẽ được quản lý bởi 2 con trỏ p_intp_double, 2 vùng nhớ này được hệ điều hành trao quyền sử dụng tạm thời cho chương trình của chúng ta, thông qua con trỏ, chúng ta có thể thay đổi giá trị bên trong vùng nhớ này. Ví dụ:

int *p_int = new int;
cout  *p_int;
cout 

Chủ Đề