JVM Garbage Collector

Nam Vu
11 min readOct 25, 2019

Chắc hẳn ai làm việc với lập trình lâu năm đều đã từng gặp các rắc rối với "memory leak” hay là “out of memory” nguyên nhân là việc quản lý bộ nhớ không tốt, rất may là với Java ta được cung cấp một bộ Garbage Collector (GC) sẽ tự động quét dọn bộ nhớ. Nhờ đến GC mà các LTV không cần quan tâm đến việc quản lý toàn bộ phần bộ nhớ hay là phân phối lại bộ nhớ của chương trình như mà C/C++ chúng ta phải làm.

Nhưng tại sao chúng ta vẫn gặp rắc rối về bộ nhớ trong java như “memory leak” hay là “out of memory” do GC hoạt động không tốt? mà thực ra là do trong quá trình code chúng ta tạo ra một mớ hỗn độn mà GC không thể dọn dẹp nổi.

Và nếu chúng ta hiểu được GC hoạt động như thế nào thì bản thân LTV sẽ dễ dàng "thấu cảm" được và từ đó sẽ viết ra nhưng phần mềm tốt và hoàn hảo hơn.

Vậy GC hoạt động như thế nào?

Đầu tiên ta hãy nhớ lại kiến trúc của JVM, trong module "Run time Area" của JVM ta có hai vùng nhớ để lưu bộ nhớ là vùng nhớ Stack và vùng nhớ Heap. Và trong đó thì Stack dùng để lưu trữ các tham số và các biến local và Heap dùng để lưu trữ các đối tượng khi từ khóa new được gọi ra và các biến static và các biến instance. Vùng nhớ Heap mới là vùng nhớ cần được dọn dẹp nhất bởi vì các Object được lưu tại Heap mà Object khi không cần dùng nữa nó cần phải xóa bỏ để giải phóng bộ nhớ.

Vậy GC dọn dẹp các Object rác như thế nào? Thực tế đây là một công việc cực kỳ phức tạp và tùy thuộc vào từng JVM, nhưng ta có thể hiểu nó một cách rất tổng quản như sau:

1. Xác định Object nào không còn được sử dụng nữa có nghĩa là Object không còn tham chiếu nào tới nó (unreferenced object)
2. Xóa các Object đã được đánh dấu ở bước 1
3. Sau bước hai khi xóa các Object thì bộ nhớ Heap sẽ bị phân mảnh (fragment), nên các Object đang còn "sống" sẽ được sắp xếp và gom lại gần nhau (dồn bộ nhớ), giúp việc cấp phát bộ nhớ HEAP cho các Object mới dễ dàng hơn.

Và để làm được điều này thì vùng nhớ Heap được chia thành ba phần là : Young generation - thế hệ “trai trẻ”, Old generation - thế hệ “ông già” và Permanent generation - thế hệ "bất tử"

Young generation : nhóm này lại được chia thành 2 nhóm con là eden (khởi thủy) và survivor (sống sót). Nhóm survivor lại được chia thành 2 nhóm nhỏ hơn là S0 và S1. Các object mới được khởi tạo sẽ nằm trong nhóm Eden. Sau 1 chu kỳ hoạt động của garbage collector, object nào “sống sót” sẽ được chuyển sang nhóm survivor. Sự kiện các object ở nhóm Young generation được thu hồi bởi Garbage collector được xem là Minor GC. Minor GC liên tục theo dõi các Object ở S0, S1. Sau “nhiều” chu kỳ quét mà Object vẫn còn được sử dùng thì chúng mới được chuyển sang vùng nhớ Old generation

Old generation : nhóm này chứa các object chuyển từ young generation (tất nhiên với thời gian hoạt động đủ lâu, mỗi bộ garbage collector sẽ định nghĩa bao nhiêu được coi là “lâu”). Sự kiện các object ở nhóm Old generation được thu hồi bởi garbage collector được xem là Major GC.

Permanent generation: hay còn gọi là vùng nhớ Perm. Perm không chứa Object, nó chứa metadata của JVM như các thư viện Java SE, mô tả các class và các method của ứng dụng. Do đó, khi phải “dọn” các class, method không cần thiết, garbage collector sẽ tìm kiếm trong nhóm này. Nhưng hầu như GC sẽ không "chạm" tới vùng nhớ này.

Tuy nhiên chính vì sự hoạt động này của GC mà chúng ta sẽ gặp phải một vài rắc rối như:

1. STW (Stop-the-world)

Khi quá trình Minor GC và Major GC được thực thi STW (Stop-the-world) sẽ diễn ra. STW được thực thi khi sự kiện copy các object sống dai sang các phân vùng "già hơn" (thăng chức) từ Young generation sang Old generation hoặc các phân vùng trong Young generation như từ phần vùng Eden qua S0 hay từ S0 qua S1...

Stop The World khi xảy ra thì ứng dụng của ta sẽ ngừng chạy và GC sẽ blocking toàn bộ các thread của chương trình cho tới khi nó hoạt động xong, Vậy nên nếu ứng dụng của ta là một WebServer thì mọi request vào sẽ phải chờ. Chính vì thế STW có thể gây ảnh hưởng tới performance của chương trình, và thực tế các bộ GC sau này đã có những thuật toán phức tạp để STW không ảnh hưởng quá nhiều tới hệ thống.

Ta có một vài tip để "tunning" STW bằng cách setting flag của JVM ví dụ như điều kiện "thăng chức" chỉ được xảy ra khi mà sau N lần quét rác mà một object vẫn ngoan cố không bị thủ tiêu, số N này được set qua option -XX:+MaxTenuringThreshold và giá trị mặc định của nó đang là 15. Và giả dụ khi bạn biết chắc rằng hệ thống của ta rất nhiều Object được tạo ra, và bạn biết chắc nó tồn tại trong thời gian thấp, nhưng GC không đủ nhanh để quét rác, dẫn đến việc nó được "thăng chức", thì trong trường hợp này bạn có thể có thể giải quyết bằng cách tăng tham số MaxTenuringThreshold lên.

2. Memory Leak và Out Of Memory.

Khi trong trường hợp Object hoàn thành xong sứ mệnh của nó thì nó sẽ được GC quét dọn để dành bộ nhớ cho những Object mới, nhưng vì một vài lý do nào đó như Object không được sử dụng nữa nhưng vẫn có reference từ các Object khác nên GC không thể thu dọn, điều này gây tới memory leak có nghĩa là bộ nhớ phải tiêu tốn tài nguyên cho những Object rác (không còn được dùng nữa). Và nếu trình trạng memory leak vẫn tiếp tục diễn ra thì bộ nhớ Heap sẽ tăng liên tục cho đến khi đạt đến ngưỡng giới hạn thì Out Of Memory sẽ diễn ra, lúc này ứng dụng sẽ bị treo crash và điều tồi tệ sẽ tới :'(

Vì vậy lúc phát triển hãy quan tâm tới GC, các hoạt động Minor GC hay Major GC càng ít thì STW càng ít xảy ra do đó ứng dụng của bạn ít bị ảnh hưởng nhất. Và điều đặc biệt là hãy để ý tới vòng đời của các Object và phải chắc chắn rằng Memory Leak sẽ không xảy ra.

GC Implementations:

Về lý thuyết cách hoạt động của GC là như thế, nhưng trong quá trình tiến hóa của JVM thì ta xuất hiện khá nhiều các phiên bản implementations của GC, và sau đây ta sẽ tìm hiểu hai phiên bản GC mặc định của hai phiên bản LTS là Java 8 và Java 11.

1. Parallel Garbage Collector

Đây chính là GC mặc định của Java 8, với Parallel GC quá trình sử lý các Minor hay Major GC được sử lý trên nhiều Thread (multi-thread) cho nên tốc độ sử lý của nó khá nhanh. Nhưng khi nó hoạt động thì các thread khác của chương trình sẽ bị dừng lại, điều này vẫn gây ảnh hưởng tới hệ thống.

Để kích hoạt Parallel GC ta dùng option sau:

-XX:+UseParallelGC

2. G1 Garbage Collector:

G1 hay còn gọi là Garbage First, xuất hiện từ Java 7 Update 4 và hiện tại đang là GC mặc định của Java 9,10 và Java 11.

G1 được ra đời để quản lý các vùng HEAP > 4G hiệu quả hơn. Khác với các GC khác G1 chia vùng nhớ HEAP thành các phần nhỏ hơn ( có dung lượng từ 1 đến 32MB), khi GC hoạt động GC sẽ đánh dấu (marking) vùng nhớ nào có nhiều "rác" nhất, từ đó sẽ dọn dẹp (sweeping) vùng nhớ đó đầu tiên và sẽ thực hiện việc dồn bộ nhớ ngay lúc đó, có nghĩa là G1 vừa thực hiện dọn rác và dồn bộ nhớ đồng thời. Đó là lý do tại sao bộ GC này có tên là G1 (Garbage First).

Để kích hoạt G1 Garbage Collector ta dùng option sau:

-XX:+UseG1GC

String deduplication : Nhắc đến G1, ta không thể bỏ qua tính năng này, nó xuất hiện từ phiên bản Java 8. Tính năng này có khả năng nhận diện các xâu ký tự trùng nhau trong heap (nhưng lại được tham chiếu bởi các object khác nhau) và sửa tham chiếu đến chúng, nhằm tránh các bản copy thừa của các xâu ký tự này, tránh tình trạng Memory Leak diễn ra từ đó tiết kiệm dung lượng trống cho vùng nhớ heap.

Để sử dụng tính năng String deduplication, ta sử dụng tham số:

-XX:+UseStringDeduplication

Và với các GC1 chúng ta có thể quản lý hay thay đổi các tham số của GC như sau:

+ Số lượng Thread của GC có thể control được nhờ vào option -XX:ParallelGCThreads=<N>. N là số lượng parallel thread mà GC có thể sử lý Minor hay Major GC một cách đồng thời. Giá trị mặc định tùy thuộc vào cấu hình phần cứng mà JVM được cài đặt.

+ Thời gian pause time của GC được tùy chỉnh bằng option -XX:MaxGCPauseMillis=<N>. N là khoảng thời gian tính bằng mili-second, giá trị mặc định là 200 milliseconds

+ Tổng số lượng concurrent thread mà GC có thể sử dụng được tùy chỉnh bằng option -XX:ConcGCThreads=<N>. N là tổng số lượng concurrent thread, giá trị mặc định cũng được JVM tự quyết định dựa vào cấu hình phần cứng mà JVM được cài đặt.

+ Để tùy chỉnh số phần trăm của HEAP size còn lại mà GC sẽ được kích hoạt ta sử dụng option -XX:InitiatingHeapOccupancyPercent=<N>. N là số phần trăm, và giá trị mặc định của nó là 45.

Trên môi trường Production ta có thể tối ưu cấu hình GC với cấu hình ví dụ sau:

Với server chạy replication thì cấu hình GC ta có thể tối ưu bằng cấu hình sau:

-server -Xms4G -Xmx4G -XX:PermSize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=20 -XX:ConcGCThreads=5 -XX:InitiatingHeapOccupancyPercent=70

Và với Server chạy standalone:

-server -Xms32G -Xmx32G -XX:PermSize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=20 -XX:ConcGCThreads=5 -XX:InitiatingHeapOccupancyPercent=70

3. ZGC - Z Garbage Collector

Phiên bản mới nhất của GC là bản ZGC xuất hiện ở Java 11 phiên bản 333 và ở phiên bản Java 13 mới nhất tại thời điểm hiện tại thì nó vẫn đang được hoàn thiện và phát triển dần, tuy nhiên đây mới chỉ là phiên bản GC dưới dạng "thử nghiệm" (experimental) và chỉ có trên môi trường Linux 64 bit, và chưa khuyến khích sử dụng ở môi trường Production ở thời điểm hiện tại (10–2019)

ZGC là một bản low-latency GC (GC có độ trễ thấp) được thiết kế để hoạt động với số lượng HEAP size rất lỡn cỡ vài terabyte. Cấu hình về phần cứng của Server trên môi trường Production đã tăng lên rất nhanh thời điểm hiện nay, dịch vụ AWS đã cung cấp một EC2 instance x1e.32xlarge có cấu hình lên tới 28 vCPUs và 3,904GB Ram. Từ đó để hoạt động trên một vùng HEAP size rất lớn cỡ Terabyte như vậy, vì thế ZGC được ra đời để hướng tới tương lai với việc GC có thể quét dọn vùng Heap lớn với pause times (STW) thấp (<10ms) và tác động thấp tới tổng thể ứng dụng (<15% on throughput).

Để làm được điều đó ZGC sử dụng hai kỹ thuật là "coloured pointers" và "load barriers".

Coloured Pointers: Là một kỹ thuật mà nó sẽ chứa các metadata về Object trong Heap qua chính các tham chiếu con trỏ mà theo ngôn ngữ Java ta hay gọi là "references". Điều này có thể làm được bởi vì trong các 64-bit platforms (ZGC chỉ hoạt động trên các 64-bit platforms) ta có nhiều "bit" (64-bit) hơn để lưu các thông tin về các Heap Object. Cụ thể ZGC giới hạn Heap Size là 4TB và nó chỉ cần 42-bits để lưu thông tin địa chỉ của Object, do đó GC có thêm 22-bits cho mỗi con trỏ để lưu thêm các thông tin khác về Object mà trong đó nó dùng 4-bits cho mỗi con trỏ cho để đại diện các thông tin trạng thái: finalizable, remap, mark0 and mark1 của Object

4 trạng thái finalizable, remap, mark0 and mark1 chính là các Coloured của con trỏ,

Marked1Marked0 để đại diện cho trạng thái Object này có được đánh dấu chưa? (đánh dấu là cần dọn dẹp hay sắp xếp dồn bộ nhớ)

Remapped để đại diện cho trạng thái Object này đã được sắp xếp dồn dọn di dời trong bộ nhớ HEAP khi bộ nhớ bắt đầu bị phân mảnh (fragmented)

Và cuối cùng là trạng thái Finalizable là trạng thái đánh dấu là nó cần phải dọn dẹp xóa bỏ khỏi bộ nhớ.

Chính vì việc đánh dấu Coloured từng Object trong mỗi con trỏ mà ZGC có thể thao tác cực kỳ nhanh với độ trễ thấp hơn rất nhiều (STW<10ms) so với các bộ GC khác (STW ~ 200ms với G1 GC), và kỹ thuật này rất phù hợp với bộ HEAP lớn.

Load barriers: Là một kỹ thuật sẽ được tự động thực hiện khi ứng dụng muốn đọc một Object reference từ HEAP size, có nghĩa là chỉ được thực hiện khi một Object A được reference từ Object B và ứng dụng muốn đọc Object A thông qua Object B, ví dụ:

Load barriers sẽ thực thi kiểm tra một vài trạng thái của Object reference đó và thực thi một vài công việc phụ thêm như kiểm tra các Coloured-bit để thực thi một vài thao tác (như mark, remap hay finalizable như đều cập bên trên) trước khi trả về cho ứng dụng. Tác vụ này ngoài việc tăng hiệu quả thực thi của GC mà cũng để tránh các sự cố về Memory leak như đề cập ở đầu bài viết.

Để kích hoạt ZGC ta dùng option sau:

-XX:+UseZGC

HẾT!

Link tham khảo:
http://cr.openjdk.java.net/~pliden/slides/ZGC-PLMeetup-2019.pdf
https://docs.oracle.com/cd/E40972_01/doc.70/e40973/cnf_jvmgc.htm

--

--