+8

Garbage Collection trong Java hoạt động như thế nào? Làm sao GC biết vùng nhớ nào không còn được sử dụng để giải phóng?

Trong một buổi phỏng vấn gần đây, mình được interviewer hỏi Java có những tính năng gì nổi bật để bạn quyết định học, một trong những lý do mình đưa ra đó là Java có bộ thu gom rác tự động - Garbage Collection, nó giúp developer không cần phải quản lý bộ nhớ thủ công như C/C++. Nhưng khi người phỏng vấn xoáy sâu hơn vào vấn đề: Làm sao GC biết vùng nhớ nào không còn được sử dụng để giải phóng? Mĩnh đã không trả lời được. Đó là lúc mình nhận ra mình chưa thực sự hiểu bản chất của Garbage Collection, cách mà nó hoạt động. Trong bài viết này, chúng ta sẽ cùng đi tìm câu trả lời cho các câu hỏi đó

1. What Is Garbage Collection?

Đúng vậy, đầu tiên ta cần biết Garbage Collection là gì?

Garbage Collection (GC), như tên gọi của nó, là một cơ chế nhằm giải phóng không gian bộ nhớ bị chiếm bởi các đối tượng không còn được sử dụng, đồng thời tránh tình trạng rò rỉ bộ nhớ (memory leaks). Thông qua việc thực hiện cơ chế GC, bộ nhớ khả dụng có thể được sử dụng hiệu quả hơn. Hơn nữa, trong quá trình này, các đối tượng đã "chết" (dead objects) hoặc không được sử dụng trong một thời gian dài trong bộ nhớ heap sẽ bị xóa và không gian bộ nhớ mà các đối tượng này sử dụng sẽ được thu hồi lại để sử dụng cho các mục đích khác.

Dead objects (đối tượng "chết") là các đối tượng trong bộ nhớ đã không còn tham chiếu nào trỏ đến chúng. Nói cách khác, không có bất kỳ phần nào trong chương trình có thể truy cập hoặc sử dụng lại các đối tượng này. Vì vậy, chúng trở thành "rác" trong bộ nhớ và có thể được Garbage Collector (GC) thu gom để giải phóng không gian bộ nhớ.

public class DeadObjectExample {
    public static void main(String[] args) {
        // Tạo một đối tượng và tham chiếu đến nó
        String str = new String("Hello, World!");

        // Tham chiếu bị gán lại, đối tượng trước đó không còn được tham chiếu
        str = null;

        // Lúc này, đối tượng "Hello, World!" trên heap trở thành dead object
        // và sẽ được GC thu gom khi cần.
    }
}

Trước khi ngôn ngữ Java xuất hiện, các lập trình viên thường viết chương trình bằng C hoặc C++. Vào thời điểm đó, một hiện tượng mâu thuẫn nghiêm trọng đã tồn tại. Khi tạo đối tượng trong C++ và các ngôn ngữ khác, lập trình viên phải liên tục cấp phát không gian bộ nhớ. Khi không sử dụng các đối tượng này nữa, họ cũng phải giải phóng không gian bộ nhớ đó. Do đó, bạn phải viết cả constructor (hàm khởi tạo) và destructor (hàm hủy). Trong nhiều trường hợp, cả hai hàm này thường được viết lặp đi lặp lại để thực hiện việc cấp phát và thu hồi bộ nhớ.

Sau đó, có người đã đề xuất rằng: nếu chúng ta có thể viết mã để tự động hóa mục đích này, khi bạn cấp phát và giải phóng không gian bộ nhớ, bạn có thể tái sử dụng mã đó mà không cần phải viết lại hai hàm này lặp đi lặp lại.

Vào năm 1960, khái niệm Garbage Collection (GC) lần đầu tiên được đề xuất trong ngôn ngữ Lisp tại MIT, trong khi Java vẫn chưa được phát minh vào thời điểm đó. Thực tế, GC không phải là công nghệ độc quyền của Java. Lịch sử của GC đã tồn tại lâu hơn rất nhiều so với lịch sử của Java.

2. How to Define Garbage?

2.1. Reference Counting Algorithm

Thuật toán Reference Counting (Reference Counting) cấp phát một trường trong phần tiêu đề (header) của đối tượng để lưu trữ số lượng tham chiếu (reference count) của đối tượng đó.

  • Nếu một đối tượng được tham chiếu bởi một đối tượng khác, số lượng tham chiếu của nó sẽ tăng lên 1.
  • Nếu tham chiếu đến đối tượng này bị xóa, số lượng tham chiếu sẽ giảm đi 1.
  • Khi số lượng tham chiếu của đối tượng giảm về 0, đối tượng này sẽ được thu gom bởi Garbage Collector (GC).

Ví dụ:

  • Đầu tiên, hãy tạo một chuỗi trong đó "jack" được tham chiếu bởi m.
String m = new String("jack");

image.png

  • Sau đó, đặt m thành null. Số lượng tham chiếu của "jack" bằng 0. Trong thuật toán này, memory for "jack" sẽ được thu hồi.
m = null;

image.png

Thuật toán Reference Counting thực hiện việc thu gom rác (GC) trong khi chương trình đang chạy. Thuật toán này không gây ra các sự kiện Stop-The-World.

Stop-The-World có nghĩa là chương trình sẽ bị tạm dừng để thực hiện việc thu gom rác cho đến khi tất cả các đối tượng trong vùng nhớ heap được xử lý. Do đó, thuật toán này không tuân theo nghiêm ngặt cơ chế GC kiểu Stop-The-World.

Thuật toán này có vẻ khá phù hợp để sử dụng trong việc thu gom rác. Tuy nhiên, chúng ta biết rằng việc thu gom rác trên máy ảo Java (JVM) lại theo cơ chế Stop-The-World.

Vậy tại sao chúng ta lại từ bỏ thuật toán Reference Counting? Hãy cùng xem xét ví dụ sau.

public class ReferenceCountingGC {

    public Object instance;

    public ReferenceCountingGC(String name){}
}

public static void testGC(){

    ReferenceCountingGC a = new ReferenceCountingGC("objA");
    ReferenceCountingGC b = new ReferenceCountingGC("objB");

    a.instance = b;
    b.instance = a;

    a = null;
    b = null;
}
  • Đầu tiên, chúng ta định nghĩa hai đối tượng.
  • Sau đó, thực hiện việc tham chiếu qua lại giữa hai đối tượng.
  • Cuối cùng, gán tham chiếu của cả hai đối tượng này về null.

image.png

Chúng ta có thể thấy rằng cả hai đối tượng không thể được truy cập nữa. Tuy nhiên, chúng được tham chiếu lẫn nhau, do đó số đếm tham chiếu của chúng sẽ không bao giờ giảm về 0. Vì vậy, bộ thu gom rác (GC) sẽ không được kích hoạt để thu gom các đối tượng này khi sử dụng thuật toán Reference Counting.

Vấn đề này được gọi là "Circular References" (Tham chiếu vòng tròn).

Giải quyết Circular References

  • Các JVM hiện đại sử dụng các thuật toán Mark-and-Sweep hoặc các biến thể khác như Generational GC, thay vì Reference Counting, để giải quyết vấn đề Circular References.
  • Các thuật toán này không chỉ dựa vào số lượng tham chiếu mà kiểm tra xem đối tượng có còn "reachable" (có thể truy cập được) từ các root references hay không.

Cụ thể hơn, chúng ta sẽ đi tìm hiểu Reachability Analysis Algorithm

2.2. Reachability Analysis Algorithm

Ý tưởng cơ bản của Reachability Analysis Algorithm là bắt đầu từ GC roots. Bộ thu gom rác (GC) duyệt qua toàn bộ đồ thị đối tượng trong bộ nhớ, bắt đầu từ các GC roots và lần theo các tham chiếu từ các roots này đến các đối tượng khác. Đường đi này được gọi là chuỗi tham chiếu (reference chain).

Nếu một đối tượng không có chuỗi tham chiếu nào đến GC roots, tức là đối tượng đó không thể được truy cập từ GC roots, thì đối tượng đó được coi là không khả dụng.

Miễn là một đối tượng không thể thiết lập kết nối trực tiếp hoặc gián tiếp với GC roots, hệ thống sẽ xác định rằng đối tượng đó cần được thu gom (garbage-collected).

image.png

Vậy một câu hỏi khác được đặt ra: GC roots là gì?

GC roots là điểm bắt đầu để GC kiểm tra trạng thái của các đối tượng trong bộ nhớ. GC roots bao gồm:

  • Biến cục bộ (Local variables) trong các stack frame hiện tại.
  • Các đối tượng static trong các class đã được tải.
  • Các đối tượng được tham chiếu bởi JNI (Java Native Interface). image.png

Giải quyết vấn đề tham chiếu vòng tròn (Cyclic Reference):

  • Trong thuật toán Reference Counting, nếu hai hoặc nhiều đối tượng tham chiếu lẫn nhau (vòng tròn), chúng sẽ không bao giờ có số tham chiếu bằng 0, dù chúng không còn được sử dụng. Điều này dẫn đến rò rỉ bộ nhớ (memory leak).
  • Với Reachability Analysis, GC không quan tâm đến số lượng tham chiếu, mà tập trung vào việc các đối tượng có thể kết nối đến GC roots hay không. Nếu không thể kết nối, chúng sẽ được giải phóng ngay cả khi có tham chiếu vòng.

Ví dụ

public class ReachabilityAnalysisExample {
    static class ObjectA {
        ObjectB refB; // Tham chiếu đến ObjectB
    }

    static class ObjectB {
        ObjectA refA; // Tham chiếu đến ObjectA
    }

    public static void main(String[] args) {
        // Tạo hai đối tượng với tham chiếu vòng tròn
        ObjectA objectA = new ObjectA();
        ObjectB objectB = new ObjectB();
        
        objectA.refB = objectB; // ObjectA tham chiếu đến ObjectB
        objectB.refA = objectA; // ObjectB tham chiếu đến ObjectA

        // Cả hai đối tượng hiện tại đều khả dụng vì có thể truy cập từ main()

        // Ngắt kết nối của objectA và objectB khỏi root
        objectA = null;
        objectB = null;

        // Lúc này, objectA và objectB chỉ tham chiếu lẫn nhau, nhưng không còn kết nối với GC roots
        // Thuật toán Reachability Analysis sẽ xác định cả hai đối tượng là "unreachable"
        // và đưa chúng vào danh sách thu gom rác.

        System.gc(); // Gợi ý GC thực thi (không đảm bảo thực thi ngay lập tức)

        System.out.println("GC đã chạy để thu gom các đối tượng không còn khả dụng.");
    }
}

Để hiểu rõ hơn về GC root, chúng ta sẽ đi vào phân tích một vài ví dụ trong từng trường hợp

GC root là các biến cục bộ (Local variables) trong các stack frame hiện tại.

Trong trường hợp này, s là một GC root. Khi s được gán giá trị null, đối tượng localParameter bị đứt chuỗi tham chiếu đến GC root, và sẽ bị thu hồi bởi bộ thu gom rác.

public class StackLocalParameter {
    public StackLocalParameter(String name){} // Constructor không thực hiện thao tác gì đặc biệt
}

public static void testGC() {
    StackLocalParameter s = new StackLocalParameter("localParameter");
    s = null; // Gán null cho biến s
}
  1. Phân tích từng bước:
    • Bước 1: Khi phương thức testGC() được gọi, một đối tượng StackLocalParameter được tạo ra và gán cho biến cục bộ s. Lúc này:
      • Biến s nằm trong bảng biến cục bộ của khung ngăn xếp (stack frame) của phương thức testGC().
      • s trở thành một GC root (gốc thu gom rác), vì nó được tham chiếu trực tiếp từ ngăn xếp.
    • Bước 2: Khi thực thi s = null;, tham chiếu từ s đến đối tượng StackLocalParameter bị hủy. Lúc này:
      • Đối tượng StackLocalParameter không còn được tham chiếu bởi bất kỳ GC root nào (không có biến static, không có tham chiếu từ các đối tượng khác, v.v.).
      • Chuỗi tham chiếu (reference chain) từ GC root (s) đến đối tượng bị đứt.
    • Bước 3: Bộ thu gom rác (Garbage Collector) xác định đối tượng này là không thể truy cập (unreachable) và thu hồi bộ nhớ của nó trong lần dọn rác tiếp theo.
  2. Giải thích khái niệm:
    • GC Root: Là các điểm bắt đầu để thu gom rác. Các đối tượng được tham chiếu trực tiếp từ GC root được coi là "sống", các đối tượng không thể truy cập từ GC root sẽ bị xóa.
    • Local Variable Table: Là một phần của stack frame, lưu trữ các biến cục bộ và tham số của phương thức. Các biến này tự động trở thành GC root khi phương thức đang chạy.
    • Reference Chain: Chuỗi các tham chiếu từ GC root đến một đối tượng. Khi chuỗi này bị đứt, đối tượng trở thành rác.
  3. Kết luận:
    • Việc gán s = null làm mất tham chiếu cuối cùng đến đối tượng StackLocalParameter, khiến nó đủ điều kiện bị thu hồi.
    • Quá trình này minh họa cách JVM quản lý bộ nhớ dựa trên cơ chế reachability: Chỉ các đối tượng "reachable" (có thể truy cập từ GC root) mới được giữ lại, các đối tượng "unreachable" sẽ bị xóa.

GC root là các đối tượng static trong các class đã được tải.

public class MethodAreaStaicProperties {
    public static MethodAreaStaicProperties m;
    public MethodAreaStaicProperties(String name) {}
}

public static void testGC() {
    MethodAreaStaicProperties s = new MethodAreaStaicProperties("properties");
    s.m = new MethodAreaStaicProperties("parameter");
    s = null;
}
  1. Khi phương thức testGC() bắt đầu, JVM tạo một stack frame mới.
  2. Biến cục bộ s được khởi tạo và trỏ đến một đối tượng "properties" trong Heap.
  3. Biến mthuộc tính static, nên nó nằm trong Method Area và là GC Root.
  4. s.m = new MethodAreaStaicProperties("parameter");
    • Một đối tượng "parameter" mới được tạo.
    • m trỏ đến đối tượng "parameter".

Khi gán s = null;

  • s không còn tham chiếu đến "properties" nữa.
  • Đối tượng "properties" không còn kết nối với GC Root.
  • GC sẽ thu gom "properties" vì nó không còn được tham chiếu.

Điều gì xảy ra với "parameter"?

  • m (thuộc tính static) vẫn trỏ đến "parameter".
  • m là một GC Root, đối tượng "parameter" vẫn còn sống.
  • GC không thu gom "parameter".

GC root là các đối tượng được tham chiếu bởi JNI (Java Native Interface).

Native Method Stack là một phần của bộ nhớ trong JVM dùng để xử lý các phương thức gốc (native methods) được viết bằng ngôn ngữ khác như C/C++.

Khi sử dụng JNI (Java Native Interface) để gọi một phương thức viết bằng C, JVM sử dụng C stack thay vì Java stack. Khi một luồng gọi một phương thức Java, JVM sẽ tạo một stack frame mới trong Java Stack. Tuy nhiên, khi nó gọi một phương thức native, JVM giữ nguyên Java Stack và không tạo thêm stack frame trong Java stack nữa. Thay vào đó, JVM kết nối động và gọi trực tiếp phương thức native. Giả sử ta có một phương thức native trong Java:

public class NativeExample {
    static {
        System.loadLibrary("NativeLib"); // Nạp thư viện native
    }

    public native void nativeMethod(); // Khai báo phương thức native

    public static void main(String[] args) {
        new NativeExample().nativeMethod();
    }
}

Phương thức native nativeMethod() được triển khai trong C:

#include <jni.h>
#include <stdio.h>
#include "NativeExample.h"

JNIEXPORT void JNICALL Java_NativeExample_nativeMethod(JNIEnv *env, jobject obj) {
    printf("Hello from C native method!\n");
}

Khi nativeMethod() được gọi:

  1. Java stack giữ nguyên mà không thêm stack frame mới.
  2. JVM kết nối tới C stack và gọi trực tiếp phương thức C.

image.png

Như vậy, chúng ta đã biết làm sao để GC nhận biết các đối tượng không còn được sử dụng, nhưng làm sao để duyệt qua Java Heap space và tìm ra các dead object một cách hiệu quả, hay nói cách khác ***How Does GC Work?***. Câu trả lời sẽ được trình bày trong phần 3 của bài viết này.

3. How Does GC Work?

Sau khi xác định được các đối tượng rác cần thu gom, bộ thu gom rác (GC) bắt đầu công việc của mình. Tuy nhiên, điều này đặt ra một câu hỏi: Làm thế nào để thực hiện GC một cách hiệu quả? Thông số kỹ thuật của JVM không quy định cụ thể cách triển khai Garbage Collector (GC). Do đó, các JVM khác nhau từ các nhà sản xuất khác nhau có thể triển khai GC theo các cách khác nhau. Dưới đây là những ý tưởng cốt lõi của một số thuật toán GC phổ biến.

3.1. Copying Algorithm

image.png

Thuật toán Copying được phát triển từ Mark-Sweep nhằm giải quyết vấn đề phân mảnh bộ nhớ. Nó chia bộ nhớ thành hai phần bằng nhau, gọi là semi-spaces. Chỉ có một semi-space hoạt động tại một thời điểm, phần còn lại không được sử dụng.

Cơ chế hoạt động của Copying GC

Khi semi-space đang hoạt động đầy, các đối tượng còn sống được sao chép sang semi-space còn lại. Sau đó, semi-space cũ được dọn sạch hoàn toàn. Điều này giúp bộ nhớ luôn được cấp phát liên tục, không bị phân mảnh.

Ưu điểm:

  • Loại bỏ phân mảnh bộ nhớ (vì luôn sao chép vào vùng trống).
  • Thuật toán đơn giản, tốc độ cao (vì chỉ làm việc với một semi-space).

🚨 Nhược điểm chính:

  • Chỉ sử dụng được 50% bộ nhớ, gây lãng phí.
  • Không phù hợp cho Old Generation, nơi các đối tượng sống lâu.

3.2. Mark-Sweep Algorithm

image.png

Thuật toán Mark-Sweep là thuật toán GC phổ biến nhất, thực hiện hai bước chính:

  • Mark: Đầu tiên, nó đánh dấu các đối tượng cần thu gom rác trong bộ nhớ
  • Sweep: Sau đó, dọn dẹp (sweep) các đối tượng đã được đánh dấu khỏi Heap.

Vấn đề của Mark-Sweep: Phân mảnh bộ nhớ

Thuật toán có logic rõ ràng, dễ thực hiện, nhưng gặp vấn đề lớn về phân mảnh bộ nhớ.

Giả sử có một khối bộ nhớ 2 MB, một khối 1 MB, và một khối 4 MB. Sau khi GC thu gom rác, bộ nhớ trống nhưng bị chia nhỏ thành nhiều đoạn rời rạc. Bộ nhớ chỉ có thể được cấp phát dưới dạng các khối liên tục. Nếu chương trình cần một khối bộ nhớ 2 MB, thì hai khối 1 MB riêng lẻ không thể sử dụng được. Kết quả là nhiều vùng nhớ bị lãng phí do phân mảnh.

3.3. Mark-Compact Algorithm

image.png

  • Là cải tiến của thuật toán Mark-Sweep: Sau bước Mark, thay vì chỉ xóa rác, GC sẽ di chuyển (compact) các đối tượng còn sống về một phía của Heap. Điều này giúp loại bỏ phân mảnh bộ nhớ và tạo không gian liên tục.

Nhược điểm của Mark-Compact

Thuật toán này có vẻ khá tốt. Tuy nhiên, nó thay đổi bộ nhớ thường xuyên hơn do di chuyển nhiều đối tượng. Nó phải cập nhật lại tất cả địa chỉ tham chiếu của đối tượng còn sống. Điều này làm giảm hiệu suất đáng kể so với thuật toán Copying.

3.4. Generational Collection Algorithm

Generational Collection không phải là một thuật toán riêng biệt, mà là sự kết hợp của ba thuật toán trước đó. Nó kết hợp các thuật toán GC khác nhau để phù hợp với từng tình huống.

Bộ nhớ được chia thành nhiều vùng dựa trên tuổi thọ của đối tượng.

Java Heap được chia thành hai vùng chính:

  • Young Generation.
  • Old Generation.

Mỗi vùng sử dụng thuật toán GC phù hợp nhất để tối ưu hiệu suất.

Thu gom rác trong từng phân vùng

Young Generation

Trong Young Generation, đa số đối tượng sẽ có vòng đời ngắn, chỉ có một số ít đối tượng sống sót sau mỗi chu kỳ GC. Vì vậy, JVM dùng thuật toán Copying để sao chép các đối tượng còn sống và thu hồi toàn bộ vùng nhớ còn lại.

Lý do chọn Copying GC:

  • Tối ưu cho đối tượng sống ngắn hạn → Copy ít đối tượng, tốc độ cao.
  • Không gây phân mảnh bộ nhớ → Vì luôn sao chép vào vùng trống.

Old Generation

Trong Old Generation, JVM sử dụng Mark-Sweep hoặc Mark-Compact để thu gom rác. Vì đối tượng trong Old Generation có tỷ lệ sống sót cao, nên Copying không còn phù hợp vì tốn bộ nhớ và thời gian sao chép.

Lý do chọn Mark-Sweep hoặc Mark-Compact:

  • Tỷ lệ đối tượng còn sống cao → Không phù hợp với Copying GC.
  • Mark-Compact giúp giảm phân mảnh bộ nhớ, phù hợp cho Old Generation.

Vậy, bộ nhớ được chia thành các phần nào, và thuật toán GC nào phù hợp cho từng phần?

3.5. Memory Model and Collection Policy

image.png

Java Heap là vùng nhớ lớn nhất do JVM quản lý, cũng là vùng mà GC hoạt động chính.

Java Heap chủ yếu được chia thành hai phần:

  • Young Generation
  • Old Generation

Young Generation lại được chia nhỏ hơn thành:

  • Eden Space (Nơi đối tượng mới được tạo).
  • Survivor Space (Nơi giữ đối tượng còn sống sau GC).

Survivor Space lại được chia thành:

  • From Space (Giữ đối tượng sau GC).
  • To Space (Lưu tạm trước khi chuyển sang Old Generation).

Minor GC vs Major GC

Trước khi đi vào chi tiết các thành phần trong Java Heap, chúng ta hãy điểm qua một chút về 2 loại GC phổ biến mà JVM dùng để quản lý bộ nhớ theo từng phân vùng: Minor GC và Major GC. Trong Java Garbage Collection (GC), JVM chia quá trình thu gom rác thành Minor GC và Major GC (hay Full GC) để tối ưu hóa hiệu suất. Vì bài viết này khá dài, nên mình sẽ tóm tắt 2 loại GC này trong bảng so sánh bên dưới:

Đặc điểm Minor GC 🚀 Major GC (Full GC) 🛑
Phạm vi Young Generation Old Generation + Young Generation
Khi nào xảy ra? Khi Eden Space đầy Khi Old Generation đầy hoặc khi JVM yêu cầu
Tốc độ Nhanh ⚡ Chậm 🐢
Dừng ứng dụng (STW) Ít ảnh hưởng Có thể gây gián đoạn đáng kể
Mục tiêu chính Xóa các đối tượng ngắn hạn Xóa các đối tượng lâu dài
Ảnh hưởng hiệu suất Nhẹ, thường xuyên Nặng, cần tối ưu hóa

Eden Space

Theo một nghiên cứu chuyên sâu của IBM, gần 98% đối tượng trong bộ nhớ có vòng đời ngắn. Do đó, hầu hết các đối tượng mới đều được cấp phát trong Eden Space thuộc Young Generation. Khi dung lượng của Eden Space không còn đủ để cấp phát thêm bộ nhớ, JVM sẽ kích hoạt Minor GC để thu gom rác.

Minor GC diễn ra thường xuyên hơn Major GC và có tốc độ thu gom nhanh hơn. Sau khi Minor GC hoàn tất, Eden Space sẽ được dọn sạch và hầu hết các đối tượng trong đó sẽ bị thu gom. Những đối tượng còn sống sót sau quá trình này sẽ được chuyển vào From Space (một phần của Survivor Space). Nếu From Space không đủ dung lượng để chứa các đối tượng này, chúng sẽ được đưa trực tiếp lên Old Generation.

Survivor Space

Tương tự như đèn vàng trong giao thông, Survivor Space đóng vai trò như một vùng đệm giữa Eden SpaceOld Generation. Vùng này được chia thành hai phần: From SpaceTo Space.

Sau mỗi lần Minor GC, các đối tượng còn sống sót trong Eden SpaceFrom Space sẽ được chuyển sang To Space. Nếu To Space không đủ chỗ chứa, các đối tượng này sẽ được đưa trực tiếp lên Old Generation.

Tại sao cần có Survivor Space?

Thoạt nhìn, có vẻ như một đối tượng có thể đi thẳng từ Eden Space lên Old Generation sau mỗi Minor GC. Vậy tại sao cần thêm Survivor Space, khiến quá trình trở nên phức tạp hơn?

Giả sử không có Survivor Space, các đối tượng còn sống sau Minor GC sẽ được chuyển thẳng lên Old Generation. Điều này sẽ nhanh chóng làm đầy Old Generation, gây ra Major GC thường xuyên hơn. Tuy nhiên, thực tế là nhiều đối tượng không bị xóa ngay trong lần Minor GC đầu tiên, nhưng cũng không tồn tại lâu dài. Chúng có thể bị thu gom ở lần Minor GC thứ hai hoặc thứ ba. Nếu đưa ngay lên Old Generation quá sớm, ta sẽ lãng phí tài nguyên và làm giảm hiệu suất GC.

Do đó, Survivor Space ra đời để giảm số lượng đối tượng được chuyển lên Old Generation, từ đó giảm tần suất Major GC. Cơ chế lọc này giúp đảm bảo chỉ những đối tượng sống sót qua 16 lần Minor GC mới được đưa lên Old Generation.

Tại sao Survivor Space được chia thành hai phần?

Lợi ích lớn nhất của việc chia Survivor Space thành hai vùng (From SpaceTo Space) là giải quyết vấn đề phân mảnh bộ nhớ.

Giả sử chỉ có một Survivor Space, sau mỗi lần Minor GC, Eden Space sẽ được dọn sạch và các đối tượng còn sống sẽ được chuyển vào Survivor Space. Tuy nhiên, các đối tượng cũ đã tồn tại trong Survivor Space cũng có thể cần được xóa. Vậy làm thế nào để thu gom rác trong đó?

  • Nếu chỉ có một Survivor Space, JVM phải dùng Mark-Sweep, một thuật toán gây phân mảnh bộ nhớ nghiêm trọng, đặc biệt trong Young Generation, nơi đối tượng có vòng đời ngắn.
  • Nhờ có hai Survivor Spaces, các đối tượng sống sót trong EdenFrom Space sau Minor GC sẽ được sao chép vào To Space.
  • Trong Minor GC tiếp theo, vai trò của From SpaceTo Space sẽ đổi chỗ: các đối tượng còn sống sót từ EdenTo Space sẽ được sao chép lại vào From Space.
  • Quá trình này tiếp tục lặp lại, giúp loại bỏ phân mảnh và giữ cho một vùng Survivor luôn trống để cấp phát bộ nhớ dễ dàng hơn.

Nhiều bạn cũng có thể thắc mắc rằng:*** Vậy tại sao không chia Survivor Space thành 3, 4 hoặc nhiều hơn?*** Nếu chia nhỏ hơn nữa, dung lượng của từng vùng sẽ bị giảm, khiến mỗi vùng nhanh bị đầy và hiệu quả giảm sút. Vì vậy, hai Survivor Spaces là phương án tối ưu sau khi đã cân nhắc giữa hiệu suất và quản lý bộ nhớ.

Old Generation

Old Generation chiếm hai phần ba dung lượng bộ nhớ heap và chỉ được thu gom khi diễn ra Major GC. Mỗi lần GC sẽ kích hoạt sự kiện Stop-The-World, tạm dừng toàn bộ ứng dụng. Dung lượng bộ nhớ càng lớn, thời gian Stop-The-World càng dài, nên việc tăng kích thước bộ nhớ không phải lúc nào cũng có lợi.

Thuật toán Copying không phù hợp cho Old Generation do tỷ lệ sống sót của đối tượng cao, dẫn đến hiệu suất kém. Thay vào đó, thuật toán Mark-Compact được sử dụng để giảm phân mảnh bộ nhớ.

Ngoài ra, với cơ chế Promotion Failure Handling, nếu không gian Survivor không đủ chỗ, đối tượng sẽ được chuyển thẳng lên Old Generation. Những đối tượng sau đây cũng được đặt trong Old Generation:

Large Objects Large Objects là những đối tượng yêu cầu một vùng nhớ liên tục đáng kể. Những đối tượng này sẽ được đưa thẳng vào thế hệ cũ, bất kể vòng đời của chúng, nhằm tránh việc sao chép nhiều lần giữa không gian Eden và Survivor. Nếu có quá nhiều Large Objects nhưng tồn tại ngắn hạn, hệ thống có thể bị quá tải do GC.

Long-lived Objects JVM gán một bộ đếm tuổi cho mỗi đối tượng. Khi đối tượng tồn tại qua nhiều lần Minor GC, tuổi của nó sẽ tăng lên. Khi đạt đến ngưỡng 15, đối tượng sẽ được thăng cấp lên thế hệ cũ. Tuy nhiên, JVM cho phép điều chỉnh ngưỡng này.

Ngoài ra, JVM còn hỗ trợ tuổi động của đối tượng (Dynamic Object Age). Nếu các đối tượng cùng độ tuổi chiếm hơn một nửa không gian Survivor, những đối tượng có tuổi bằng hoặc lớn hơn mức đó sẽ được chuyển đến Old Generation mà không cần chờ đến ngưỡng tuổi tối đa.

4. Lời kết

Đến đây thì bài viết cũng đã khá dài rồi, hẳn các bạn cũng đã có câu trả lời cho những câu hỏi mà chúng ta đặt ra ở đầu bài. Nếu có bất cứ thắc mắc gì, hãy đặt câu hỏi ở phía dưới bài viết để chúng ta có thể trao đổi nhiều hơn nhé. Nếu thấy bài viết hay, hãy cho mình xin một upvote nhé, many thanks 😁.

References: https://www.alibabacloud.com/blog/how-does-garbage-collection-work-in-java_595387


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí