JVM Memory và Garbage Collection: chọn GC, tuning heap và chạy Java trong container
Container Java bị OOMKilled dù heap vẫn còn trống
Một kịch bản quen thuộc: bạn deploy service Spring Boot vào container giới hạn 1GB RAM và đặt -Xmx1g cho "tận dụng hết". Vài giờ sau container bị kill với exit code 137. Không có OutOfMemoryError nào trong log, heap dump cũng không có. Kịch bản ngược lại cũng phổ biến không kém: không đặt gì cả, và JVM chỉ dùng một phần tư RAM của container, GC chạy liên tục trong khi 75% bộ nhớ nằm không.
Cả hai vấn đề đến từ cùng một chỗ: không hiểu JVM thực sự dùng bộ nhớ thế nào, và JVM tự cấu hình ra sao khi chạy trong container. Bài viết này đi qua các vùng nhớ của JVM, cách các garbage collector hoạt động, cách JVM chọn GC và kích thước heap trong container — tất cả kèm số liệu đo thật trên Java 25 (Eclipse Temurin trong Docker).
Bộ nhớ của một tiến trình JVM không chỉ là heap
-Xmx chỉ giới hạn heap. Một tiến trình JVM còn dùng nhiều vùng nhớ khác:
- Heap: nơi chứa object, do GC quản lý.
- Metaspace và class space: metadata của các class đã load. Ứng dụng Spring Boot lớn có thể dùng 100–200MB cho vùng này. Mặc định không có giới hạn trên.
- Code cache: mã máy do JIT biên dịch ra (mặc định dành sẵn 240MB).
- Thread stacks: mỗi platform thread dành sẵn khoảng 1MB.
- Cấu trúc dữ liệu nội bộ của GC: với G1, phần này có thể chiếm vài phần trăm kích thước heap.
- Direct buffers: bộ nhớ ngoài heap của NIO, Netty. Giới hạn mặc định bằng kích thước heap tối đa.
- Phần còn lại: thư viện native,
libjvm.so, CDS archive, bộ nhớmalloccủa các thư viện native.
Native Memory Tracking (NMT) cho phép nhìn thấy các vùng này. Chạy một chương trình gần như không làm gì, với -Xmx256m và 50 thread đang sleep:
java -XX:NativeMemoryTracking=summary -Xmx256m -cp out Idle &
jcmd <pid> VM.native_memory summary scale=MB
Total: reserved=1734MB, committed=47MB
- Java Heap (reserved=256MB, committed=16MB)
- Class (reserved=1024MB, committed=0MB)
- Thread (reserved=126MB, committed=6MB)
- Code (reserved=244MB, committed=7MB)
- GC (reserved=1MB, committed=0MB)
- Internal (reserved=1MB, committed=1MB)
- Symbol (reserved=1MB, committed=1MB)
- Shared class space (reserved=16MB, committed=14MB, readonly=0MB)
- Metaspace (reserved=64MB, committed=0MB)
Reserved là không gian địa chỉ ảo đã giữ chỗ (gần như miễn phí); committed là bộ nhớ thực sự được cấp. Trong khi đó, RSS của tiến trình (bộ nhớ vật lý mà hệ điều hành — và container — tính cho nó) là 179MB, trong khi heap chỉ committed 16MB. Phần chênh lệch gồm các vùng NMT liệt kê ở trên cộng những thứ NMT không theo dõi, như thư viện native được map vào bộ nhớ.
Bài học: đừng bao giờ đặt heap bằng giới hạn bộ nhớ của container. Khi RSS vượt giới hạn, kernel kill tiến trình ngay lập tức (exit code 137, OOMKilled) mà JVM không kịp ghi log hay tạo heap dump. Ngược lại, java.lang.OutOfMemoryError nghĩa là heap đã đầy — hai lỗi khác nhau, cần hai cách xử lý khác nhau.
Garbage collection hoạt động thế nào
Giả thuyết thế hệ (generational hypothesis)
Mọi GC hiện đại trong JDK đều dựa trên một quan sát thực nghiệm: hầu hết object chết rất trẻ. Object tạm trong một request, iterator, chuỗi trung gian... sống vài micro giây rồi không còn ai tham chiếu tới. Vì vậy heap được chia làm hai thế hệ:
- Young generation (gồm Eden và hai Survivor space): object mới được cấp phát ở đây. Khi Eden đầy, một minor GC chạy: nó chỉ sao chép những object còn sống sang Survivor, rồi xóa sạch Eden. Vì phần lớn object đã chết, chi phí tỷ lệ với số object sống — rất nhỏ.
- Old generation: object sống sót qua đủ nhiều lần minor GC được "thăng cấp" (promote) lên đây. Thu gom old generation tốn kém hơn nhiều, nên nó chạy ít hơn.
Thử với một chương trình cấp phát 3 triệu mảng 1KB, trong đó chỉ khoảng 1% được giữ lại lâu:
List<byte[]> retained = new ArrayList<>();
Random random = new Random(42);
for (int i = 0; i < 3_000_000; i++) {
byte[] garbage = new byte[1024]; // phần lớn chết ngay
if (random.nextInt(100) == 0) {
retained.add(garbage); // ~1% sống lâu
}
if (retained.size() > 20_000) {
retained.subList(0, 10_000).clear();
}
}
Log của G1 với -Xlog:gc:
[0.001s][info][gc] Using G1
[0.013s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 9M->1M(18M) 0.608ms
[0.014s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 8M->1M(18M) 0.391ms
[0.016s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 9M->1M(18M) 0.359ms
[0.026s][info][gc] GC(6) Pause Young (Normal) (G1 Evacuation Pause) 25M->2M(42M) 0.396ms
[0.030s][info][gc] GC(8) Pause Young (Normal) (G1 Evacuation Pause) 25M->2M(92M) 0.501ms
Đọc một dòng: GC(0) là số thứ tự; Pause Young (Normal) là một lần minor GC; 9M->1M(18M) nghĩa là heap đang dùng giảm từ 9MB xuống 1MB, trên tổng 18MB đã commit; 0.608ms là thời gian dừng ứng dụng. Mỗi lần dọn, gần như toàn bộ Eden là rác — đúng như giả thuyết thế hệ — nên mỗi lần dừng chưa tới 1ms. Để ý heap committed tăng dần (18M → 42M → 92M): G1 tự mở rộng heap khi thấy GC chạy quá dày.
Các garbage collector trong Java 25
| GC | Flag | Đặc điểm | Phù hợp |
|---|---|---|---|
| Serial | -XX:+UseSerialGC | Một thread, dừng toàn bộ ứng dụng khi GC | Heap nhỏ, 1 CPU, công cụ CLI |
| Parallel | -XX:+UseParallelGC | Nhiều thread, dừng toàn bộ ứng dụng, tối ưu throughput | Batch job, xử lý dữ liệu offline |
| G1 (mặc định) | -XX:+UseG1GC | Chia heap thành region, cân bằng throughput và độ trễ, mục tiêu pause 200ms | Đa số ứng dụng server |
| ZGC | -XX:+UseZGC | Gần như mọi việc chạy song song với ứng dụng, pause thường dưới 1ms bất kể heap lớn | Heap lớn, yêu cầu độ trễ thấp |
| Shenandoah | -XX:+UseShenandoahGC | Nén heap song song với ứng dụng, pause thấp | Tương tự ZGC; không có trong bản Oracle JDK |
G1: mặc định cho hầu hết ứng dụng
G1 chia heap thành các region bằng nhau (mặc định từ 1MB đến 32MB tùy kích thước heap). Một region có thể là Eden, Survivor hay Old tùy thời điểm. Các điểm cần biết:
- Young GC dọn Eden và Survivor. Khi old generation vượt ngưỡng (IHOP, được G1 tự điều chỉnh), G1 đánh dấu song song với ứng dụng rồi chạy các mixed GC: dọn young cùng những region old có nhiều rác nhất — đó là nguồn gốc cái tên "Garbage First".
-XX:MaxGCPauseMillis(mặc định 200) là mục tiêu chứ không phải cam kết. G1 điều chỉnh kích thước young generation để cố đạt mục tiêu này.- Humongous object: object lớn hơn một nửa region được cấp phát thẳng vào old generation. Cấp phát liên tục các mảng lớn (buffer vài MB) trong đường xử lý nóng sẽ khiến G1 làm việc vất vả.
- Thấy
Pause Fulltrong log là dấu hiệu xấu: G1 đã không theo kịp và phải dọn toàn bộ heap, thường do heap quá nhỏ hoặc rò rỉ bộ nhớ.
ZGC: khi độ trễ là ưu tiên số một
ZGC thực hiện cả việc đánh dấu lẫn di chuyển object song song với ứng dụng, nhờ colored pointers và load barriers. Thời gian pause gần như không phụ thuộc kích thước heap. Từ Java 21, ZGC có chế độ generational (JEP 439); Java 23 biến nó thành mặc định; Java 24 loại bỏ hẳn chế độ non-generational. Nghĩa là trên Java 25, chỉ cần -XX:+UseZGC:
[0.003s][info][gc] Using The Z Garbage Collector
[0.024s][info][gc] GC(0) Major Collection (Warmup)
[0.031s][info][gc] GC(0) Major Collection (Warmup) 26M(10%)->14M(5%) 0.007s
Cái giá của ZGC: tốn thêm CPU cho các barrier và thread GC chạy nền, cần dư địa heap lớn hơn G1, và throughput thường thấp hơn một chút. Với service cần p99 latency ổn định và heap vài GB trở lên, đó thường là cái giá đáng trả.
JVM chọn GC và heap thế nào trong container
Từ JDK 10 (và được backport về 8u191), JVM nhận biết giới hạn CPU và RAM của container thay vì nhìn toàn bộ máy host. Từ JDK 15 hỗ trợ cả cgroup v2. Kiểm tra JVM thấy gì:
docker run --rm -m 512m --cpus=1 eclipse-temurin:25-jdk java -XshowSettings:system -version
Operating System Metrics:
Provider: cgroupv2
Effective CPU Count: 1
CPU Period: 100000us
CPU Quota: 100000us
Memory Limit: 512.00M
Nhưng điều quan trọng hơn là JVM làm gì với những con số đó. Mình đo bằng java -XX:+PrintFlagsFinal -version với các cấu hình container khác nhau:
| Container | GC được chọn | Heap tối đa |
|---|---|---|
| 512MB, 1 CPU | Serial | 128MB |
| 2GB, 1 CPU | Serial | 512MB |
| 2GB, 2 CPU | G1 | 512MB |
2GB, 2 CPU, -XX:MaxRAMPercentage=75 | G1 | 1536MB |
Hai điều đáng chú ý:
- Heap mặc định chỉ bằng 25% RAM (
MaxRAMPercentage=25). Container 2GB mà heap chỉ 512MB là lãng phí lớn. - Container 1 CPU luôn dùng Serial GC, dù RAM nhiều đến đâu. JVM chỉ chọn G1 khi máy được coi là "server-class": ít nhất 2 CPU và ít nhất 1792MB RAM. Giới hạn 1 CPU cho mỗi container là cấu hình rất phổ biến trên Kubernetes, ECS hay Coolify — và khi đó service của bạn lặng lẽ chạy Serial GC, dừng toàn bộ ứng dụng mỗi lần dọn heap bằng một thread duy nhất. (JVM tính theo giới hạn CPU; nếu container không đặt giới hạn, nó thấy toàn bộ CPU của máy host.)
Cấu hình khuyến nghị cho container
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/app.jar app.jar
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps"
ENTRYPOINT ["java", "-jar", "app.jar"]
-XX:MaxRAMPercentage=75: heap theo tỷ lệ RAM của container thay vì con số cứng, tự điều chỉnh khi bạn đổi giới hạn. Chừa 25% cho các vùng ngoài heap; giảm xuống 60–70% nếu ứng dụng có nhiều thread hoặc dùng nhiều direct buffer (Netty, Kafka client).-XX:+UseG1GC: chỉ định GC tường minh, không phụ thuộc vào số CPU mà container được cấp.-XX:+ExitOnOutOfMemoryError: khi hết heap, thoát hẳn để orchestrator khởi động lại, thay vì tiếp tục chạy ở trạng thái hỏng.-XX:+HeapDumpOnOutOfMemoryError: ghi heap dump trước khi thoát./dumpsphải là một volume được mount, nếu không file dump sẽ mất cùng container.JAVA_TOOL_OPTIONSđược JVM tự đọc, tiện khi muốn đổi cấu hình qua biến môi trường mà không sửa Dockerfile.
Compact Object Headers (Java 25)
Mỗi object trên heap có một header chứa thông tin cho JVM (hash code, trạng thái lock, tuổi GC, con trỏ tới class). Trên JVM 64-bit với compressed class pointers, header chiếm 12 byte. Với object nhỏ, đó là một phần đáng kể: một record Point(int x, int y) có 8 byte dữ liệu nhưng chiếm 24 byte trên heap (12 header + 8 dữ liệu, làm tròn lên bội số của 8).
JEP 519 đưa Compact Object Headers thành tính năng chính thức ở Java 25: header chỉ còn 8 byte, và Point chỉ chiếm 16 byte. Tính năng chưa bật mặc định. Đo với 10 triệu Point:
record Point(int x, int y) {}
Point[] points = new Point[10_000_000];
for (int i = 0; i < points.length; i++) {
points[i] = new Point(i, -i);
}
System.gc();
Runtime rt = Runtime.getRuntime();
System.out.println("Heap đang dùng: " + (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024) + " MB");
$ java -Xmx1g -cp out Points
Heap đang dùng: 271 MB (10000000 Point)
$ java -Xmx1g -XX:+UseCompactObjectHeaders -cp out Points
Heap đang dùng: 194 MB (10000000 Point)
Giảm 28% heap chỉ bằng một flag, khớp với tính toán lý thuyết (8 byte tiết kiệm × 10 triệu object ≈ 76MB). Ứng dụng có nhiều object nhỏ — entity, DTO, node của cây hay graph — hưởng lợi nhiều nhất, và heap nhỏ hơn cũng nghĩa là GC ít việc hơn. Hãy đo trên workload thật của bạn trước khi bật trên production.
Công cụ chẩn đoán
GC log trên production
GC log gần như không ảnh hưởng hiệu năng, nên bật sẵn trên production với cơ chế xoay vòng file:
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=10m
Những gì cần theo dõi: thời gian pause so với SLA, tần suất GC, sự xuất hiện của Pause Full hay Evacuation Failure, và heap sau GC có tăng dần theo thời gian không — nếu có, gần như chắc chắn là rò rỉ bộ nhớ. Có thể dùng các công cụ phân tích như GCeasy hoặc JDK Mission Control để xem log dưới dạng biểu đồ.
jcmd, jstat và JFR
jcmd <pid> GC.heap_info # tình trạng heap hiện tại
jstat -gcutil <pid> 1s # % sử dụng từng vùng, cập nhật mỗi giây
jcmd <pid> GC.class_histogram | head -20 # class nào đang chiếm nhiều bộ nhớ nhất
jcmd <pid> GC.heap_dump /dumps/heap.hprof # heap dump để phân tích bằng Eclipse MAT
jcmd <pid> JFR.start duration=60s filename=/dumps/app.jfr # ghi JFR 60 giây
Lưu ý khi chạy trong container: image eclipse-temurin:25-jre không có jcmd hay jstat (thư mục bin chỉ có java, jfr, keytool và vài công cụ nhỏ). Có hai lựa chọn: dùng image JDK cho môi trường cần chẩn đoán, hoặc bật sẵn JFR qua flag -XX:StartFlightRecording rồi lấy file .jfr ra ngoài để phân tích bằng JDK Mission Control.
Phân tích heap dump
Mở file .hprof bằng Eclipse Memory Analyzer (MAT). Hai tính năng đáng dùng nhất: Leak Suspects Report tự động chỉ ra nhóm object chiếm nhiều bộ nhớ bất thường, và Dominator Tree cho biết object nào đang "giữ" phần lớn heap — xóa nó thì bao nhiêu bộ nhớ được giải phóng.
Những nguồn rò rỉ bộ nhớ thường gặp
- Collection static không giới hạn: một
static Mapdùng làm cache mà không bao giờ xóa phần tử. Dùng thư viện cache có giới hạn kích thước và thời gian sống như Caffeine. - ThreadLocal trong thread pool: thread không bao giờ chết, nên giá trị gắn vào nó cũng không bao giờ được thu gom nếu quên
remove(). - Listener và callback không hủy đăng ký: object đăng ký listener vào một đối tượng sống lâu hơn nó sẽ bị giữ lại mãi.
- Tài nguyên không đóng: stream, connection,
ResultSet— luôn dùng try-with-resources. - Rò rỉ ClassLoader: hot redeploy trong application server, khi một tham chiếu từ class cha giữ lại toàn bộ class của phiên bản ứng dụng cũ.
Checklist khi chạy Java trong container
- Không dùng
-Xmxbằng giới hạn container. Dùng-XX:MaxRAMPercentagekhoảng 60–75%. - Chỉ định GC tường minh; đừng để container 1 CPU âm thầm chạy Serial GC.
- Bật
ExitOnOutOfMemoryErrorvàHeapDumpOnOutOfMemoryErrorvới đường dẫn là volume được mount. - Bật GC log có xoay vòng file ngay từ đầu, không đợi đến khi có sự cố.
- Phân biệt OOMKilled (exit 137, RSS vượt giới hạn container) với
OutOfMemoryError(heap đầy). - Ứng dụng nhiều object nhỏ: đo thử
-XX:+UseCompactObjectHeaderstrên Java 25. - Cần latency thấp với heap lớn: thử ZGC và so sánh p99 bằng load test.
Kết luận
JVM làm rất nhiều việc tự động — chọn GC, tính kích thước heap, nhận biết container — nhưng các giá trị mặc định được thiết kế để an toàn, không phải để tối ưu cho trường hợp của bạn. Hiểu rằng heap chỉ là một phần bộ nhớ của JVM, rằng container 1 CPU đồng nghĩa với Serial GC, và rằng heap mặc định chỉ là 25% RAM, bạn đã tránh được phần lớn sự cố bộ nhớ khi chạy Java trong container. Phần còn lại là thói quen: bật GC log, đo trước khi tuning, và để số liệu dẫn đường thay vì cảm tính.