Các hàm quản lý bộ nhớ động trong C thước thử viện nào
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. 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òn được gọi là Compile-time allocation, được áp dụng cho biến static và biến toàn cục. 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. 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: 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 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: Trong đoạn chương trình trên, mình khai báo một mảng kí tự có tên 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 allocationDynamic 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 ( Đọc kỹ hướng dẫn sử dụng trước khi dùngKỹ 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 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 variablesnew operatorToá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:
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ó ( 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 operatorCú pháp sử dụng toán tử new như sau:
Ví dụ:
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ý:
Bây giờ, vùng nhớ được cấp phát sẽ được quản lý bởi 2 con trỏ
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:
usage of delete operatorKhi 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ó:
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ó.
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 operatorSử 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.
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:
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ệ 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ụ:
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:
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ử.
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ụ:
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ụ:
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:
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ụ:
Chúng ta sử dụng giá trị của biến
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ử
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 arraysTrong 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à:
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ỏ Tổng kếtTrong 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. www.daynhauhoc.com Link Videos khóa họchttps://www.udemy.com/c-co-ban-danh-cho-nguoi-moi-hoc-lap-trinh/learn/v4/overview
|