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 <data_type>;
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_int
và p_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 << "Put value into memory area" << endl;
cin >> *p_int;
cout << "Value at " << p_int << " is " << *p_int << endl;
Chúng ta còn có thể vừa cấp phát bộ nhớ vừa khởi tạo giá trị tại vùng nhớ đó cho một biến đơn:
int *p1 = new int(5);
int *p2 = new int { *p1 };
usage of delete operator
Khi không muốn sử dụng tiếp vùng nhớ đã được cấp phát cho chương trình trên Heap, chúng ta nên trả lại vùng nhớ đó cho hệ điều hành. Thật ra khi chương trình kết thúc, tất cả vùng nhớ của chương trình đều bị hệ điều hành thu hồi, nhưng chúng ta nên giải phóng vùng nhớ không cần thiết càng sớm càng tốt.
Để xóa một vùng nhớ, chúng ta cần có một địa chỉ cụ thể, địa chỉ đó được giữ bởi con trỏ sau khi gán địa chỉ cấp phát cho nó:
int *p = new int;
//using memory area at p
//and then set it free
delete p;
Lúc này, con trỏ p vẫn còn giữ địa chỉ của vùng nhớ đã được cấp phát trên Heap. Nếu may mắn, vùng nhớ đó chưa được hệ điều hành cấp phát cho chương trình khác, chúng ta vẫn có thể dùng con trỏ p để thay đổi giá trị bên trong nó.
int *p = new int
delete p;
//keep using that memory area
*p = 10;
cout << p << endl;
Nếu không may mắn, con trỏ p sẽ mang tội danh xâm nhập bất hợp pháp vào vùng nhớ của chương trình khác, và chương trình của chúng ta sẽ bị crash.
mean of delete operator
Sử dụng toán tử delete không có nghĩa là delete tất cả mọi thứ bên trong vùng nhớ mà con trỏ trỏ đến. Toán tử new và delete chỉ mang ý nghĩa về “quyền sử dụng” vùng nhớ. Toàn bộ dãy địa chỉ trên bộ nhớ ảo được quản lý bởi một chương trình mang tên “Hệ điều hành”, và hệ điều hành có quyền trao lại quyền sử dụng một vùng nhớ nào đó (trên Stack hoặc trên Heap…) cho những chương trình đáng tin cậy trên máy tính.
Và toán tử new dùng để làm hợp đồng sử dụng vùng nhớ trên Heap, các bạn lấy vùng nhớ được cấp phát thông qua hợp đồng (make by new operator) để chương trình chạy, vậy khi bạn sử dụng toán tử delete, đơn giản là bạn chỉ xé bản hợp đồng đó đi (hoặc đưa lại cho hệ điều hành). Lúc này, Giá trị trên vùng nhớ đó có thể vẫn còn giữ nguyên do chưa có chương trình nào can thiệp vào.
Toán tử delete không tác động gì đến con trỏ.
Dangling pointer
“Con trỏ bị treo” thường xảy ra sau khi giải phóng vùng nhớ bằng toán tử delete. Sau khi sử dụng toán tử delete, vùng nhớ được cấp phát được trả lại cho hệ điều hành quản lý, nhưng con trỏ vẫn còn trỏ vào địa chỉ đó. Sử dụng toán tử dereference cho con trỏ tại thời điểm này sẽ gây ra lỗi undefined behavior.
int main()
{
int *ptr = new int; // dynamically allocate an integer
*ptr = 7; // put a value in that memory location
delete ptr; // return the memory to the operating system. ptr is now a dangling pointer.
std::cout << *ptr; // Dereferencing a dangling pointer will cause undefined behavior
delete ptr; // trying to deallocate the memory again will also lead to undefined behavior.
return 0;
}
Còn nhiều trường hợp khác nhau có thể khiến con trỏ bị treo, mình sẽ dành ra một bài học để nói về cách quản lý vùng nhớ và con trỏ khi sử dụng kỹ thuật Dynamic memory allocation.
Điều gì xảy ra khi xin cấp phát vùng nhớ trên Heap thất bại?
Quá trình cấp phát vùng nhớ trên Heap thất bại có thể do có chương trình nào đó đang sử dụng lượng bộ nhớ quá lớn (ví dụ chương trình tạo máy ảo), và chương trình của bạn yêu cầu cung cấp vùng nhớ có kích thước nên hệ điều hành không thế tìm thấy đoạn vùng nhớ nào đủ cho yêu cầu của chương trình của bạn.
Chúng ta cùng xem lại các protoyte của toán tử new:
void* operator new (std::size_t size); // (1)
void* operator new (std::size_t size, const std::nothrow_t& nothrow_value) noexcept; // (2)
void* operator new (std::size_t size, void* ptr) noexcept; // (3)
Mặc định, chúng ta sử dụng toán tử new ở cách khai báo (1), trong trường hợp này, nếu cấp phát vùng nhớ thất bại, toán tử new sẽ ném ra ngoại lệ std::bad_alloc
. Nếu ngoại lệ này không được xử lý, chương trình chúng ta sẽ bị kết thúc với lỗi unhandled exception error.
Trong một số trường hợp, chúng ta không muốn dính đến ngoại lệ (exception) trong C++, chúng ta nên chọn sử dụng phiên bản toán tử new (2), ví dụ:
int *p = new (std::nothrow) int;
Sử dụng cách này, nếu quá trình cấp phát thất bại, toán tử new sẽ trả về giá trị NULL. Lúc này, chúng ta có thể kiểm tra xem chương trình của chúng ta có xin được vùng nhớ hay không:
if (p == NULL)
{
cout << "Could not allocate memory on Heap partition" << endl;
exit(1);
}
else
{
//use that memory area
//and then delete it
delete p;
}
Sử dụng cách này sẽ giúp chương trình chúng ta sử dụng con trỏ an toàn hơn khi sử dụng kỹ thuật Dynamic memory allocation.
Dynamically allocate arrays
Để xin cấp phát và giải phóng vùng nhớ cho mảng một chiều trên Heap, chúng ta cũng sử dụng toán tử new và delete để xử lý.
Dynamically allocate arrays
Đối với việc yêu cầu cấp phát bộ nhớ cho biến đơn trên Heap, chúng ta chỉ cần cung cấp kiểu dữ liệu cho toán tử new, hệ điều hành sẽ tự tính được kích thước cần cấp phát (tương tự việc sử dụng toán tử sizeof). Nhưng khi cần cấp phát một dãy vùng nhớ liên tục nhau (mảng một chiều), ngoài kiểu dữ liệu chúng ta cần cung cấp thêm số lượng phần tử.
new <data_type>[num_of_elements];
Nếu quá trình cấp phát thành công, toán tử new sẽ trả về địa chỉ của phần tử đầu tiên của vùng nhớ được cấp phát, và tương tự như cấp phát cho biến đơn, chúng ta cho 1 con trỏ có kiểu dữ liệu phù hợp lưu trữ địa trả về để quản lý vùng nhớ. Ví dụ:
int *p_arr = new int[10];
//using this memory area
for (int i = 0; i < 10; i++)
{
//Set value for each element
cin >> *p_arr[i];
}
Chúng ta có thể khởi tạo cho vùng nhớ đã được cấp phát tương tự như khởi tạo mảng một chiều thông thường. Ví dụ:
int arr[5] = { 1, 2, 3, 4, 5 };
int *p_arr = new int[5] { 1, 2, 3, 4, 5 }; //no operator = between array-size and initializer list
Lưu ý cách này chỉ sử dụng được trong chuẩn C++11 trở lên.
Trường hợp mảng kí tự luôn là trường hợp đặc biệt của mảng một chiều. Chúng ta không thể sử dụng cách khởi tạo này trong chuẩn C++11:
char *c_str = new char [100] { "Allocated on Heap partition" };
Nhưng trường hợp này có thể chạy được trên Visual studio 2015 với chuẩn C++14.
Điều khiến cho kỹ thuật Dynamic memory allocation khác với Static memory allocation là số lượng phần tử có thể được cung cấp trong khi chương trình đang chạy. Ví dụ:
int num_of_elements;
cout << "Enter number of elements you want to create: ";
cin >> num_of_elements;
int *p_arr = new int[num_of_elements];
Chúng ta sử dụng giá trị của biến num_of_elements
làm số lượng phần tử cung cấp cho toán tử new, và giá trị này chỉ được xác định sau khi người dùng nhập vào từ bàn phím. Để hạn chế trường hợp người dùng nhập số âm, chúng ta cần kiểm tra trước khi xin cấp phát:
int num_of_elements;
cout << "Enter number of elements you want to create: ";
cin >> num_of_elements;
if(num_of_elements > 0)
int *p_arr = new int[num_of_elements];
dynamically delete arrays
Đối với dãy vùng nhớ liên tục được cấp phát trên Heap, chúng ta cần thêm vào toán tử [ ]
để báo với hệ điều hành rằng vùng nhớ đã được cấp phát không dùng cho một biến đơn.
int *p_arr = new int[10];
//...........
delete[] p_arr;
Sử dụng toán tử delete theo cách giải phóng vùng nhớ biến đơn cho dãy vùng nhớ liên tục có thể gây ra nhiều vấn đề khác nhau cho chương trình (memory leak, data corruption, …).
resizing dynamic arrays
Trong nhiều trường hợp, chúng ta cần thay đổi kích thước vùng nhớ đã được cấp phát cho phù hợp với yêu cầu của chương trình. Cách duy nhất là:
- Cấp phát lại vùng nhớ mới.
- (Copy dữ liệu từ vùng nhớ cũ sáng vùng nhớ mới nếu cần).
- Giải phóng vùng nhớ cũ.
- Cho con trỏ trỏ đến vùng nhớ mới.
int main()
{
int *p = new int[5];
for (int i = 0; i < 5; i++)
{
cin >> *(p + i);
}
//re-allocate
int *p_temp = p;
p = new int[10];
//copy data
for (int i = 0; i < 5; i++)
{
*(p + i) = *(p_temp + i);
}
//dealocate old memory area
delete[] p_temp;
//keep using data
//and then delete it
delete[] p;
system("pause");
return 0;
}
Do vùng nhớ mới sẽ có địa chỉ khác với vùng nhớ đã cấp phát ban đầu, mình cần sử dụng con trỏ p_temp
để giữ lại khả năng truy cập đến vùng nhớ ban đầu. Sau khi copy toàn bộ dữ liệu từ vùng nhớ cũ sang vùng nhớ mới, chúng ta nên giải phóng vùng nhớ cũ ngay để khỏi lãng phí tài nguyên hệ thông.
Tổng kết
Trong bài học này, chúng ta đã tìm hiểu về kỹ thuật Dynamic memory allocation trong ngôn ngữ C++. Kỹ thuật này giúp chương trình chúng ta ít bị giới hạn dung lượng bộ nhớ hơn. Nhưng bên cạnh đó, chúng ta cần có kỹ năng về quản lý các vùng nhớ trong chương trình. Sử dụng kỹ thuật Dynamic memory allocation không thành thạo là nguyên nhân gây phổ biến gây ra lỗi memory leak. Do đó, chúng ta sẽ có một bài học nói về các lỗi thường gặp khi sử dụng Dynamic memory allocation và cách kiểm soát các lỗi này.
Hẹn gặp lại các bạn trong bài học tiếp theo trong khóa học lập trình C++ hướng thực hành.
Mọi ý kiến đóng góp hoặc thắc mắc có thể đặt câu hỏi trực tiếp tại diễn đàn.