Garbage collector trong Java: G1, ZGC và cách chọn
1. Tổng quan
Garbage collector (GC) quyết định ứng dụng Java của bạn dừng bao lâu và tốn bao nhiêu CPU để dọn bộ nhớ. Nếu chọn sai, bạn có thể gặp latency tăng vọt dù code không đổi gì.
Bài này giải thích cách G1 và ZGC hoạt động trong Java 21, cách bật và đọc GC log, cách kiểm tra GC đang chạy bằng code và test. Cuối bài là các tiêu chí để chọn GC cho ứng dụng thực tế.
2. Các GC có sẵn trong Java 21
HotSpot JDK 21 có các GC chính sau:
| GC | Flag | Mục tiêu |
|---|---|---|
| Serial | -XX:+UseSerialGC |
Heap nhỏ, 1 CPU, footprint thấp |
| Parallel | -XX:+UseParallelGC |
Throughput tối đa, chấp nhận pause dài |
| G1 | -XX:+UseG1GC |
Cân bằng throughput và pause |
| ZGC | -XX:+UseZGC |
Pause cực ngắn, heap lớn |
| Shenandoah | -XX:+UseShenandoahGC |
Pause ngắn (chỉ có trong một số bản build) |
Nếu bạn không truyền flag nào, JVM tự chọn. Trên máy “server-class” (ít nhất 2 CPU và khoảng 1792 MB RAM), JVM dùng G1. Ngược lại, JVM dùng Serial.
Lưu ý: Trong container, JVM đọc giới hạn CPU và memory của cgroup. Một Pod giới hạn 1 CPU sẽ bị JVM coi là không phải server-class và âm thầm chạy Serial GC. Đây là nguyên nhân phổ biến khiến service trên Kubernetes có pause dài bất thường.
3. G1 hoạt động thế nào
G1 (Garbage-First) chia heap thành nhiều region bằng nhau, thường từ 1 MB đến 32 MB. Mỗi region đóng một vai trò: Eden, Survivor, Old hoặc Humongous (dành cho object lớn).
Chu trình cơ bản gồm:
- Young GC: dừng toàn bộ thread ứng dụng (stop-the-world), copy object còn sống từ Eden sang Survivor hoặc Old.
- Concurrent marking: khi Old đầy tới một ngưỡng, G1 đánh dấu object sống song song với ứng dụng.
- Mixed GC: dọn cả young lẫn những region Old có nhiều rác nhất trước. Đây là ý nghĩa của “Garbage-First”.
Điểm mạnh của G1 là bạn đặt được mục tiêu pause:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar
MaxGCPauseMillis=100 yêu cầu G1 cố giữ mỗi pause dưới 100 ms (mặc định là 200 ms). G1 điều chỉnh kích thước young generation để đạt mục tiêu này. Đây là mục tiêu mềm, không phải cam kết. Pause của G1 vẫn tăng theo số object còn sống cần copy.
4. ZGC hoạt động thế nào
ZGC làm gần như mọi việc nặng (marking, relocation, cập nhật tham chiếu) song song với ứng dụng. Nó dùng colored pointers và load barrier: mỗi lần code đọc một tham chiếu, barrier kiểm tra object đã bị di chuyển chưa và sửa tham chiếu nếu cần.
Kết quả là pause thường dưới 1 ms và không tăng theo kích thước heap. Heap 2 GB hay 2 TB đều cho pause tương đương.
4.1. Generational ZGC trong Java 21
Java 21 giới thiệu Generational ZGC (JEP 439). Phiên bản này tách young và old generation, nhờ đó dọn object ngắn hạn hiệu quả hơn nhiều. Trong Java 21, bạn phải bật thêm flag:
java -XX:+UseZGC -XX:+ZGenerational -Xmx4g -jar order-service.jar
Nếu thiếu -XX:+ZGenerational, Java 21 chạy ZGC kiểu cũ (non-generational). Từ JDK 23, generational là mặc định. Từ JDK 24, chế độ non-generational đã bị loại bỏ. Vì vậy, nếu dùng ZGC trên Java 21, bạn nên bật generational ngay.
4.2. Cái giá phải trả
Pause ngắn có chi phí:
- Load barrier làm chậm mỗi lần đọc tham chiếu, nên throughput thường thấp hơn G1 vài phần trăm.
- ZGC cần headroom, tức phần heap còn trống. Ứng dụng vẫn cấp phát trong lúc GC chạy song song. Nếu heap đầy trước khi GC xong, thread bị chặn lại (“Allocation Stall”). Khi đó latency tăng vọt, ngược hẳn với mục tiêu ban đầu.
- GC thread chạy song song nên tranh CPU với ứng dụng.
5. Thử nghiệm với một chương trình nhỏ
Chương trình dưới đây tạo nhiều Order ngắn hạn và giữ lại một phần để mô phỏng cache:
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class OrderLoadSimulator {
record Order(String id, String customer, byte[] payload) {}
public static void main(String[] args) {
List<Order> cache = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 5_000_000; i++) {
Order order = new Order(UUID.randomUUID().toString(), "customer-" + (i % 1000), new byte[512]);
if (i % 100 == 0) {
cache.add(order); // giữ lại 1% -> lên old generation
}
if (cache.size() > 20_000) {
cache.subList(0, 10_000).clear(); // xả bớt, tạo rác ở old
}
}
System.out.printf("Done in %d ms, cache=%d%n",
System.currentTimeMillis() - start, cache.size());
}
}
Mỗi vòng lặp tạo một Order với payload 512 byte. Phần lớn object chết ngay, đúng với giả thuyết “đa số object chết trẻ”. Khoảng 1% được giữ trong cache đủ lâu để bị đưa lên old generation. Lệnh subList(...).clear() thỉnh thoảng giải phóng chúng.
Chạy với G1 và bật log GC bằng unified logging (-Xlog):
java -XX:+UseG1GC -Xmx512m -Xlog:gc OrderLoadSimulator.java
Output sẽ có dạng:
[0.008s][info][gc] Using G1
[0.312s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 25M->3M(512M) 2.814ms
[0.401s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 28M->4M(512M) 1.957ms
...
Done in 3120 ms, cache=15000
Mỗi dòng Pause Young cho biết heap trước và sau GC (25M->3M), tổng heap đã commit (512M) và thời gian dừng (2.814ms).
Giờ chạy với Generational ZGC:
java -XX:+UseZGC -XX:+ZGenerational -Xmx512m -Xlog:gc OrderLoadSimulator.java
[0.010s][info][gc] Using The Z Garbage Collector
[0.420s][info][gc] GC(0) Minor Collection (Allocation Rate) 60M(12%)->14M(3%) 0.021s
[0.610s][info][gc] GC(1) Minor Collection (Allocation Rate) 72M(14%)->16M(3%) 0.018s
...
Done in 3350 ms, cache=15000
Thời gian ở cuối mỗi dòng ZGC (0.021s) là thời lượng cả chu kỳ GC, phần lớn chạy song song với ứng dụng. Đó không phải thời gian ứng dụng bị dừng. Để xem pause thật, bạn dùng -Xlog:gc,gc+phases=debug và tìm các dòng Pause Mark Start, Pause Mark End, Pause Relocate Start. Các pause này thường chỉ vài chục micro giây.
Số liệu cụ thể sẽ khác trên máy của bạn. Xu hướng thường thấy là ZGC hoàn thành chậm hơn một chút về tổng thời gian nhưng không có pause dài.
6. Kiểm tra GC đang chạy bằng code
Trong môi trường production, bạn nên xác nhận GC thực tế thay vì tin vào cấu hình. GarbageCollectorMXBean cho biết điều đó:
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
public class GcInspector {
public static List<String> activeCollectors() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.map(GarbageCollectorMXBean::getName)
.toList();
}
public static void main(String[] args) {
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.printf("%s: count=%d, time=%d ms%n",
gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
}
}
Mỗi GC đăng ký một hoặc nhiều MXBean với tên đặc trưng. Ví dụ với G1:
G1 Young Generation: count=0, time=0 ms
G1 Concurrent GC: count=0, time=0 ms
G1 Old Generation: count=0, time=0 ms
Với Generational ZGC, tên sẽ là ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles và ZGC Major Pauses.
6.1. Viết test xác nhận cấu hình GC
Bạn có thể khóa cấu hình GC bằng test, để không ai vô tình đổi JVM options. Trước hết, cấu hình Surefire chạy test với ZGC:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-XX:+UseZGC -XX:+ZGenerational</argLine>
</configuration>
</plugin>
Sau đó viết test JUnit 5 kết hợp AssertJ:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GcInspectorTest {
@Test
void whenRunningWithZgcFlag_thenZgcCollectorsAreActive() {
var collectors = GcInspector.activeCollectors();
assertThat(collectors)
.isNotEmpty()
.allMatch(name -> name.startsWith("ZGC"));
}
@Test
void whenGenerationalZgc_thenMinorCyclesBeanExists() {
assertThat(GcInspector.activeCollectors())
.contains("ZGC Minor Cycles");
}
}
Test đầu tiên kiểm tra mọi collector đều thuộc ZGC. Test thứ hai đảm bảo chế độ generational đã bật. Nếu ai đó xóa -XX:+ZGenerational, tên bean sẽ đổi thành ZGC Cycles và test thất bại.
Lưu ý: Test này phụ thuộc JVM options của tiến trình test. Nếu IDE của bạn không đọc
argLinetừ Surefire, test sẽ fail khi chạy trực tiếp trong IDE.
7. Cấu hình GC cho ứng dụng Spring Boot
Spring Boot không tự chọn GC. Bạn truyền flag cho JVM theo cách triển khai. Khi chạy local bằng plugin Maven:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<jvmArguments>-XX:+UseZGC -XX:+ZGenerational -Xmx2g</jvmArguments>
</configuration>
</plugin>
Trong container, cách gọn nhất là dùng biến môi trường JAVA_TOOL_OPTIONS. JVM tự đọc biến này khi khởi động:
docker run -e JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -Xlog:gc*:stdout:time" \
--cpus=2 --memory=2g payment-service:1.0
MaxRAMPercentage=75 đặt heap tối đa bằng 75% memory của container. Phần còn lại dành cho metaspace, thread stack và direct buffer. Hãy luôn ghi rõ -XX:+UseG1GC hoặc -XX:+UseZGC thay vì để JVM tự chọn, vì lý do đã nêu ở mục 2.
Nếu đã có Spring Boot Actuator và Micrometer, metric jvm.gc.pause cho bạn phân bố thời gian pause theo từng loại GC. Hãy theo dõi p99 và max của metric này trước và sau khi đổi GC.
8. Cách chọn GC
Bắt đầu từ yêu cầu của ứng dụng, không bắt đầu từ GC “mới nhất”:
Chọn G1 khi:
- Bạn chưa có lý do cụ thể để đổi. G1 là lựa chọn mặc định hợp lý cho đa số REST API và service.
- Heap từ vài trăm MB tới khoảng vài chục GB.
- Pause vài chục đến 200 ms là chấp nhận được.
- CPU bị giới hạn chặt và bạn cần throughput tốt.
Chọn ZGC (generational) khi:
- Yêu cầu latency p99 hoặc p999 rất chặt, ví dụ trading, game server, API có SLA dưới 50 ms.
- Heap lớn (hàng chục GB trở lên), khi pause của G1 bắt đầu dài vì nhiều object sống.
- Bạn có dư CPU và memory để làm headroom.
Chọn Parallel khi: chạy batch job, ETL, xử lý dữ liệu offline. Tổng thời gian quan trọng hơn pause.
Chọn Serial khi: chạy CLI nhỏ, function serverless với heap dưới khoảng 100 MB và 1 CPU.
Lưu ý: Đừng dùng ZGC trên container nhỏ (1 CPU, heap 512 MB). GC thread sẽ tranh CPU với ứng dụng, heap ít headroom dễ gây Allocation Stall. Kết quả có thể tệ hơn cả G1.
Một số lỗi hay gặp khi tuning:
- Copy hàng loạt flag cũ như
-XX:NewRatiohay-XX:SurvivorRatiovào G1. Các flag này cản trở cơ chế tự điều chỉnh để đạtMaxGCPauseMillis. Bạn nên bắt đầu chỉ với-Xmxvà mục tiêu pause. - Đặt
MaxGCPauseMillisquá thấp, ví dụ 10 ms, cho G1. G1 sẽ thu nhỏ young generation, GC chạy dày đặc và throughput giảm mạnh. Nếu cần pause cỡ đó, hãy chuyển sang ZGC. - Đổi GC để chữa memory leak. Không GC nào giải phóng được object vẫn còn được tham chiếu. Hãy phân tích heap dump trước.
- Đánh giá bằng benchmark vài giây. Hãy đo bằng tải giống production trong ít nhất vài chục phút, và so sánh p99 latency, CPU, memory.
9. Kết luận
G1 là lựa chọn mặc định cân bằng, phù hợp với phần lớn ứng dụng. ZGC, nhất là bản generational trong Java 21, cho pause dưới mili giây với heap rất lớn, đổi lại tốn thêm CPU và cần nhiều headroom hơn.
Hãy luôn khai báo GC rõ ràng, bật -Xlog:gc và xác nhận GC đang chạy bằng GarbageCollectorMXBean hoặc test. Chỉ đổi GC khi số liệu p99 latency cho thấy pause thực sự là vấn đề.