.NET runtime (trình thực thi các ứng dụng .NET) cung cấp khả năng quản lý bộ nhớ tự động thông qua bộ dọn rác (garbage collector – GC). Với bất kỳ ngôn ngữ nào, mô hình quản lý bộ nhớ luôn là một trong những đặc tính quan trọng nhất. Điều này cũng đúng với .NET.
Các lỗi liên quan đến bộ nhớ heap (bộ nhớ nơi chúng ta xin cấp phát và giải phóng – https://daohainam.com/2021/08/14/bo-nho-heap-la-gi/) thường rất khó để debug. Không có gì lạ khi thấy các kỹ sư phải mất hàng tuần, thậm chí hàng tháng trời để có thể dò ra chúng. Nhiều ngôn ngữ dùng một bộ dọn rác (GC) như một cách thân thiện để loại bỏ các bug đó vì GC sẽ đảm bảo quản lý vòng đời cái đối tượng một cách chính xác. Thường thì GC sẽ giải phóng bộ nhớ hàng loạt để hiệu quả hơn. Việc tạm dừng để dọn rác có thể sẽ không phù hợp với các chương trình có yêu cầu rất cao về độ trễ, và bản thân việc sử dụng bộ nhớ có thể sẽ cao hơn. GC có memory locality (1) tốt hơn và một số có khả năng dồn các vùng nhớ giúp nó ít bị phân mảnh (2) hơn.
.NET có một bộ GC có khả năng tự điều chỉnh, hoạt động theo kiểu tracing (3). Nó nhằm mục đích mang lại khả năng vận hành “không cần thao tác” trong những trường hợp thông thường, đồng thời cung cấp các tùy chọn cấu hình với trường hợp khối lượng công việc lớn. GC là kết quả của nhiều năm đầu tư, cải tiến và học hỏi từ nhiều loại khối lượng công việc.

Bump pointer allocation — các đối tượng được cấp phát bằng cách tăng con trỏ phân phối theo kích thước cần thiết (thay vì tìm khoảng trống trong các khối bộ nhớ trống tách biệt) để các đối tượng được cấp phát cùng nhau có xu hướng ở cùng nhau. Và vì các đối tượng thường được truy cập cùng nhau nên điều này cho phép memory locality (1) tốt hơn, điều này rất quan trọng đối với hiệu suất.
Generational collections (các tập đối tượng được chia theo thế hệ) — một điều cực kỳ phổ biến là vòng đời của đối tượng tuân theo giả thuyết về thế hệ, rằng một đối tượng sẽ tồn tại rất lâu hoặc chết rất nhanh. Vì vậy, sẽ hiệu quả hơn nhiều nếu một GC chỉ thu thập bộ nhớ do các đối tượng tạm thời (ephemeral objects) chiếm giữ trong hầu hết thời gian chạy (gọi là ephemeral GC), thay vì phải thu thập toàn bộ heap (gọi là full GC).
Compaction (thu gọn) – cùng một lượng không gian trống nhưng nếu chúng nằm trong ít đoạn lớn sẽ hiệu quả hơn so với nằm trong nhiều phần nhỏ hơn. Trong quá trình thu gọn GC, các đối tượng còn sót lại được di chuyển cùng nhau để có thể hình thành các không gian trống lớn hơn. Hành vi này yêu cầu triển khai phức tạp hơn so với một GC không di chuyển vì nó cần cập nhật các tham chiếu đến các đối tượng đã di chuyển này. .NET GC được điều chỉnh động để chỉ thực hiện nén khi nó xác định bộ nhớ được thu hồi xứng đáng với chi phí GC. Điều này có nghĩa là các tập tạm thời sẽ thường được nén.
Parallel — GC có thể chạy trên một hoặc nhiều thread. Workstation flavor thực hiện GC trên một luồng trong khi Server flavor thực hiện trên nhiều luồng GC để nó có thể hoàn thành nhanh hơn nhiều. GC máy chủ cũng có thể đáp ứng tỷ lệ phân phối lớn hơn vì có nhiều heap để ứng dụng cấp phát dữ liệu trên đó, do vậy nó rất tốt cho throughput (thông lượng).
Concurrent — Thực hiện công việc GC trong khi user thread bị tạm dừng — được gọi là Stop-The-World — giúp việc triển khai trở nên đơn giản hơn nhưng thời lượng của những lần tạm dừng này có thể không được chấp nhận. .NET cung cấp khả năng xử lý đồng thời để giảm thiểu vấn đề đó.
Pinning — .NET GC hỗ trợ ghim đối tượng (khóa lại không cho phép vùng nhớ đó được chuyển sang chỗ khác), cho phép tương tác zero-copy với native code. Khả năng này cho phép tương tác gốc hiệu suất cao và độ trung thực cao, với chi phí hoạt động cho GC được giảm thiểu.
Standalone GC — Một standalone GC có thể được dùng với một implementation khác (được chỉ định qua cấu hình và đáp ứng các yêu cầu về giao diện). Điều này làm cho việc dò lỗi và thử các tính năng mới dễ dàng hơn nhiều.
Diagnostics — GC cung cấp thông tin lượng phong phú về bộ nhớ và các tập hợp, được cấu trúc theo cách cho phép bạn tương quan dữ liệu với phần còn lại của hệ thống. Ví dụ: bạn có thể đánh giá tác động của GC đối với độ trễ bằng cách bắt các sự kiện GC và tương quan chúng với các sự kiện khác như IO để tính toán mức độ đóng góp của GC so với các yếu tố khác, nhờ đó, bạn có thể dành nỗ lực của mình cho các thành phần phù hợp.

(1) memory locality: memory locality nói đến việc truy cập vào cùng một phần bộ nhớ trong thời gian ngắn. Nôm na là việc truy cập vào cùng một phần bộ nhớ, hoặc gần với phần bộ nhớ đã truy cập, trong một khoảng thời gian ngắn sẽ nhanh hơn (do tận dụng được cache). Các bạn có thể tham khảo bài viết này: VÌ SAO DUYỆT MẢNG THEO DÒNG LẠI NHANH HƠN THEO CỘT? và GIẢI THÍCH: Vấn đề nằm ở CPU cache.
(2) memory fragmentation – phân mảnh bộ nhớ: Quá trình cấp phát/giải phóng sẽ tạo ra các vùng nhớ trống không liên tục (vì các vùng nằm giữa chúng vẫn chưa được giải phóng). Phân mảnh làm cho việc quản lý bộ nhớ không hiệu quả.
(3) tracing GC: tracing GC là kỹ thuật xác định các đối tượng đang được dùng bằng cách dò theo các đối tượng “gốc”. Ví dụ nếu bạn có object Parent, chứa đối tượng Child (thuộc tính Child, hoặc một biến Child), người ta dò từ Parent sẽ biết Child vẫn đang được dùng. Các đối tượng không thuộc “gốc” nào sẽ là các đối tượng cần giải phóng. Khác với tracing GC là reference counting GC – các vùng nhớ sẽ chứa số đếm số lượng biến đang trỏ đến, nếu số lượng này bằng 0 có nghĩa là GC có thể giải phóng vùng nhớ tương ứng.
* Hai hình ảnh trong bài này được lấy từ https://prodotnetmemory.com/