Garbage collector là cái gì ?
Để hiểu hơn về việc tại sao các ngôn ngữ bậc cao như Java, Go lại sinh ra GC, ta cùng quay lại thời C/C++ một tí xíu để xem cách mà nó quản lý bộ nhớ. Ví dụ với đoạn code đơn giản sau:
void memoryManagementExample() { int* ptr = new int[1000]; // Cấp phát bộ nhớ động // Thực hiện các thao tác trên ptr delete[] ptr; // Giải phóng bộ nhớ khi không còn cần thiết
}
Ở đây tôi có 1 đối tượng là 1 mảng với 1000 phần tử được cấp phát bộ nhớ bằng malloc
hoặc new
và khi không còn muốn dùng tới đối tượng này nữa tôi đơn giản là gọi tới lệnh delete để giải phóng bộ nhớ. Nhưng giả sử vào một ngày với tâm trạng toàn mây đen vì vừa bị crush từ chối, tôi lại quên mất việc phải gọi delete để giải phóng các object mà tôi đã xin trước đó mà cứ liên tục xin cấp object mới để làm việc thì điều gì xảy ra…?
Bùm! Lập tức những exception liên quan tới memory leak, file descriptor leak như Out of Memory, Segmentation Fault,… ném ngay vào mặt. Một ngày đã buồn nay lại còn buồn hơn.
Trên tinh thần như vậy, GC ra đời với sứ mệnh là làm giảm sự phức tạp của việc phải cấp phát và thu hồi tài nguyên trên bộ nhớ cho developer. Cũng với đoạn code như trên, đối với Java tôi chỉ cần:
void memoryManagementExample() { int[] ptr = new int[1000]; // Cấp phát bộ nhớ động // Thực hiện các thao tác trên ptr // GC sẽ tự động giải phóng bộ nhớ khi không còn cần thiết
}
Khi các object không còn được sử dụng trong chương trình nữa, GC sẽ tự động dọn dẹp chúng và trả lại bộ nhớ về address space. Developer không còn phải lo lắng rằng mình đã thu hồi hết những object đã xin cấp phát trước đó chưa hay bận tâm về những lỗi về memory leak do cố truy cập vào tài nguyên đã được cấp cho object khác.
Vậy Java GC hoạt động như thế nào ?
Gc hoạt động trên tinh thần là “một object trên heap sẽ được GC loại bỏ và giải phóng vùng nhớ khi không có bất kì một tham chiếu nào tới object này trong chương trình”.
Cách đơn giản nhất để đạt được điều này là duyệt qua các reference object trong chương trình khi đó các object còn lại sẽ được coi là rác và sẽ bị dọn đi. Rõ ràng đây là thuật toán đơn giản cho việc implement và cũng dọn dẹp một cách triệt để các object dư thừa. Tuy nhiên nó lại có độ phức tạp tuyến tính theo số lượng object trong hệ thống nên với những hệ thống tải cao, yêu cầu xử lý lượng dữ liệu lớn thì việc dọn dẹp này rất mất thời gian dẫn tới pause time lớn và giảm đi đáng kể performance của hệ thống.
JVM đương nhiên là không làm theo kiểu ngây thơ như vậy mà nó có cung cấp nhiều thuật toán khác nhau (mình sẽ nói rõ hơn ở phần sau về tuning GC) cho việc dọn dẹp, và cơ bản tất cả chúng đều dựa trên concept về generation của object.
Generation của object.
Đầu tiên ta ngó qua xíu về thống kê của oracle trên những ứng dụng Java. Cột x biểu diễn lifetime của object đo bằng số byte được cấp phát, cột y biểu diễn tổng số byte được cấp phát cho các object trên lifetime tương ứng. Từ biểu đồ ta nhận thấy 1 điều rằng đa phần các object và bộ nhớ cấp phát cho nó đều tồn tại trên 1 lifetime rất ngắn. Ví dụ như các object interator trong hàm for, các object return cho API,… Ngược lại cũng có những object được sinh ra và sống sót tới lúc JVM được shutdown như các object global, các object về mapper, connection,… nhưng số lượng bộ nhớ cấp phát cho các đối tượng kiểu này cũng thực sự ít.
Từ concept rằng đa phần các object đều sẽ chết trẻ mà JVM chia bộ nhớ ra làm 2 thế hệ là young generation và old generation. Việc kiểm tra và thu gom rác trên từng generation là độc lập với nhau. Young generation, do là vùng chứa phần lớn object, sẽ được cấp vùng nhớ lớn hơn và được dọn dẹp với tần suất nhiều hơn hẳn so với old generation.
Việc dọn dẹp diễn ra như thế nào
JVM chia nhỏ young generation hơn nữa cho dễ dàng hơn trong việc dọn dẹp, bao gồm: vùng eden và 2 vùng survivor. Trong đó 1 vùng survivor sẽ luôn được để trống và trở thành đích đến của các object trong các lần dọn dẹp. Đầu tiên khi các object được khởi tạo nó sẽ được đặt trong vùng eden. Khi GC thực hiện dọn dẹp, nó sẽ quét trên vùng eden và vùng survivor có dữ liệu, xem xét các object nào vẫn còn reference (còn sống) và copy nó qua vùng survivor đang để trống. Sau khi hoàn tất quá trình này, nó empty vùng eden và survivor vừa quét. Ở lần dọn dẹp sau 2 vùng survivor lúc này sẽ hoán đổi vai trò cho nhau, 1 vùng có dữ liệu được quét và 1 vùng là đích đến của object. Tần suất dọn dẹp trên young generation là khá thường xuyên vì JVM cần đảm bảo eden luôn có chỗ cho các object mới. Một object khi được move qua lại nhiều lần giữa 2 vùng survivor sẽ được coi là đã sống sót đủ lâu và sẽ được move qua old generation. Quá trình này gọi là aging.
Trên old generation, GC thường sử dụng một trong hai kỹ thuật chính để dọn dẹp là: Mark-Sweep-Compact và Concurrent Mark-Sweep (CMS).
- Mark-Sweep-Compact:
- Đánh dấu (Mark): GC sẽ quét qua toàn bộ vùng old generation và "đánh dấu" các object nào vẫn còn tham chiếu (vẫn đang được sử dụng). Các object này được coi là sống.
- Quét (Sweep): GC sẽ dọn dẹp (xóa) các object không có tham chiếu (đã chết) khỏi bộ nhớ. Sau quá trình quét, vùng bộ nhớ sẽ có các vùng trống do các object đã chết để lại.
- Nén (Compact): Do các vùng trống không liền kề nhau sau quá trình quét, GC thực hiện nén các object sống lại gần nhau, giúp giải phóng một vùng bộ nhớ lớn liên tục. Việc này giúp cải thiện hiệu suất cấp phát bộ nhớ sau này.
Kỹ thuật Mark-Sweep-Compact có thể gây ra Stop-the-World (STW), nghĩa là toàn bộ ứng dụng sẽ bị tạm dừng trong khi GC thực hiện dọn dẹp. Tuy nhiên, điều này đảm bảo việc quản lý bộ nhớ hiệu quả cho old generation.
- Concurrent Mark-Sweep (CMS):
- Initial Mark: GC sẽ đánh dấu sơ bộ các object gốc (root objects). Bước này thường gây dừng ứng dụng trong thời gian ngắn.
- Concurrent Mark: GC tiếp tục quét các object để đánh dấu các object sống mà không dừng ứng dụng.
- Remark: Đây là bước đánh dấu cuối cùng để đảm bảo các object còn sống đã được đánh dấu chính xác. Bước này cũng sẽ tạm dừng ứng dụng nhưng trong thời gian ngắn.
- Concurrent Sweep: GC giải phóng bộ nhớ của các object chết mà không dừng ứng dụng.
CMS giảm thời gian dừng ứng dụng, tuy nhiên sẽ không thực hiện giai đoạn compact. Vì vậy, vùng old generation có thể bị phân mảnh, gây khó khăn cho cấp phát bộ nhớ khi cần một vùng bộ nhớ lớn liên tục.
Các phiên bản Java sau này cũng cung cấp nhiều thuật toán collect hơn như G1(ưu tiên các vùng có nhiều object chết nhất trước ), ZGC, Shenandoah**,**… nhằm tối ưu hơn cho việc dọn dẹp. Mình sẽ nói kỹ hơn ở bài về tuning và chọn GC phù hợp với hệ thống.
Ảnh hưởng của GC tới hệ thống
Thú thực, dù là 1 Java developer nhưng trước đây mình không để tâm tới GC nhiều lắm, coi nó như 1 phần mặc định của JVM và đã làm tốt nhiệm vụ thu dọn thôi. Cho tới khi thực hiện phép đo trên hệ thống hiện tại mà bản thân đang code thì mới biết: trung bình cứ 1 phút chạy chương trình thì có 10 - 15 giây dành cho GC (tôi còn đọc được ở 1 số system thì là 20-25 giây). Gần 25% thời gian chạy của cả chương trình, vậy mà tôi đã đối xử với em nó chưa đúng mực.
Cụ thể ở đây GC có ảnh hưởng lớn đến hiệu suất và hành vi của hệ thống với các tác động cụ thể như sau:
- Thời gian tạm dừng (Pause Time): Đa phần khi GC thực hiện dọn dẹp bộ nhớ đều dẫn tới việc phải tạm dừng ứng dụng hay ta còn biết tới với thuật ngữ Stop-the-World (STW). Thời gian tạm dừng dài hoặc thường xuyên có thể làm chậm hiệu suất ứng dụng, đặc biệt với các ứng dụng thời gian thực yêu cầu đáp ứng nhanh như các ứng dụng streaming hoặc các giao dịch tài chính.
- Sử dụng CPU: GC sử dụng một lượng tài nguyên CPU đáng kể để thực hiện dọn dẹp, đặc biệt là với các bộ thu gom như Parallel GC và G1 GC khi chúng có nhiều luồng thực hiện song song. Các tài nguyên CPU bị chiếm dụng cho GC sẽ giảm tài nguyên dành cho ứng dụng, khiến throughput của ứng dụng giảm.
- Tác động đến bộ nhớ cache: Quá trình GC có thể ảnh hưởng đến bộ nhớ cache CPU, do liên tục truy xuất và di chuyển dữ liệu giữa các vùng nhớ, làm cache miss tăng lên. Điều này làm giảm hiệu suất bộ nhớ đệm, đặc biệt trên các hệ thống sử dụng nhiều luồng.
Tổng kết
Giờ chúng ta đã hiều hơn GC và những lợi ích mà em nó mang lại như giải phóng lập trình viên khỏi việc quản lý bộ nhớ thủ công và bảo vệ khỏi rò rỉ bộ nhớ, nhưng cũng có các ảnh hưởng không mong muốn như làm chậm hiệu suất hệ thống, tăng độ trễ và gây phân mảnh bộ nhớ nếu không được cấu hình phù hợp. Vì thế nên là 1 Java developer, Ta nên chọn chiến lược GC hợp lý, tối ưu hóa heap và cấu hình GC đúng cách, để giúp hệ thống duy trì hiệu suất ổn định và tối ưu hơn.
Happy coding !!!