Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

GraalVM Native Image vs JVM JIT

Tung Bui Thanh (TungDaDev)12 mins read

Graalvm native image.webp

Làm phần mềm thì không có viên đạn bạc. GraalVM Native Image khởi động nhanh, RAM bé xíu, nhưng đổi lại build lâu, mất khả năng tối ưu động của JIT và đủ thứ rắc rối với Reflection.

# AOT đổi cuộc chơi của Java

Hơn hai chục năm nay, Java sống bằng câu "Write Once, Run Anywhere" nhờ JVM. Code Java compile ra bytecode, lúc chạy thì JVM HotSpot dùng JIT (Just-In-Time) Compiler (C1/C2) dịch dần bytecode thành mã máy đã tối ưu.

Có điều khi Cloud Native, Kubernetes và Serverless (AWS Lambda, Google Cloud Run) lên ngôi, JVM truyền thống lộ ra hai điểm yếu khá đau:

  • Khởi động chậm (cold start): một app Spring Boot lớn có thể mất 5 đến 30 giây để nạp class, inject bean rồi mới chạy được. Trên Serverless, khoảng thời gian này là request của người dùng phải đứng chờ.
  • Ăn RAM ngay từ đầu (memory footprint): bản thân JVM rỗng chỉ vài chục MB, nhưng một app Spring Boot lúc rảnh thường đã chiếm vài trăm MB RSS: heap, metaspace cho class metadata, code cache và bộ nhớ của JIT compiler, thread stack.

GraalVM Native Image ra đời để giải bài này bằng AOT (Ahead-Of-Time) Compilation: compile thẳng code Java thành file thực thi nhị phân của hệ điều hành (ELF binary trên Linux, exe trên Windows), chạy không cần JVM đi kèm.


# so số đo: Native Image vs HotSpot JIT

Bảng dưới là số đo minh hoạ cho một service Spring Boot 3.x REST API nối PostgreSQL, chạy trong container. Con số chỉ để thấy độ lớn của chênh lệch; app của bạn (số bean, thư viện, cấu hình heap, phần cứng) sẽ cho kết quả khác, nên tự đo trước khi quyết:

Tiêu chíHotSpot JIT (OpenJDK 21)GraalVM Native ImageChênh lệch
Startup Time3.8 giây0.15 giây (150ms)⚡ Nhanh hơn ~25 lần
First Request Latency250ms (do warm-up)12ms⚡ Gần như tức thì
RAM ban đầu (Idle)320 MB RSS85 MB RSS📉 Giảm ~70% RAM
Kích thước Container~300 MB (chứa JRE)~120 MB (Distroless)📦 Gọn hơn ~2.5 lần
Thời gian CI/CD Build~30 giây4 - 8 phút⏳ Chậm hơn ~10–15 lần
Peak Throughput (Max QPS)12,000 req/s9,800 req/s⚠️ JIT cao hơn ~20%
P99 Latency (sau Warm-up)1.8 ms2.6 msJIT ổn định hơn

# sao throughput đỉnh của JIT lại cao hơn Native Image?

Nhiều người nghĩ: "Đã compile sẵn ra mã máy thì kiểu gì chẳng nhanh hơn chạy qua máy ảo". Thực tế thì với backend chạy dài ngày, HotSpot JIT thường thắng Native Image về throughput đỉnh. Lý do như sau.

# Profile-Guided Optimization (PGO) lúc runtime

JIT của JVM không chỉ dịch code, nó còn ngồi quan sát app chạy thật:

  • Monomorphic Inlining: nếu tại một chỗ gọi, profile cho thấy chỉ có đúng một implementation chạy qua, JIT bỏ lời gọi ảo qua vtable, chỉ đặt một phép kiểm tra type rẻ rồi inline thẳng code vào caller.
  • Branch profile & Escape Analysis: JIT biết nhánh if-else nào hay chạy với traffic thật để xếp code cho nhánh nóng, và nếu object không thoát ra khỏi method thì C2 phân rã nó thành các biến cục bộ (scalar replacement), khỏi cấp phát trên heap.
  • De-optimization: đoán sai (ví dụ xuất hiện implementation thứ hai) thì JIT huỷ bản tối ưu, quay về interpreter rồi compile lại theo profile mới.

Còn GraalVM Community Edition thì compile AOT bằng phân tích tĩnh trước khi chương trình chạy, nên nhánh nào nó cũng phải chọn cách an toàn nhất.

(Oracle GraalVM, tên mới của GraalVM Enterprise, có hỗ trợ PGO: build một bản có gắn instrument, chạy với tải mẫu để thu profile ra file .iprof, rồi build lại. Quy trình này khá cồng kềnh, và profile chỉ tốt bằng tải mẫu bạn đưa vào.)


# mấy cái bẫy khi chuyển sang Native Image

Native Image dựa trên Closed-World Assumption: toàn bộ bytecode mà app gọi tới phải biết trước lúc build. Từ đó sinh ra 3 rắc rối lớn.

# Java Reflection và Dynamic Proxies

Java và Spring kiểu cũ dựa rất nhiều vào Reflection (Class.forName(), Method.invoke()) và CGLIB proxy. Khi compile AOT, mấy class được load động như vậy bị GraalVM coi là dead code và xoá sạch khỏi binary!

Spring Boot 3 có bước xử lý AOT lúc build, tự sinh hint cho bean, proxy và các thứ Spring biết. Phần bạn phải tự khai báo là chỗ code của chính bạn (hoặc thư viện) dùng reflection mà Spring không đoán được, bằng AOT Reachability Metadata:

@Configuration
@ImportRuntimeHints(NativeRuntimeConfig.PaymentHints.class) // Registrar phải được đăng ký thế này (hoặc qua META-INF/spring/aot.factories)
public class NativeRuntimeConfig {
 
    static class PaymentHints implements RuntimeHintsRegistrar {
 
        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            // Khai báo rõ ràng với GraalVM rằng class này sẽ được gọi qua Reflection
            hints.reflection().registerType(PaymentPayload.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                MemberCategory.INVOKE_DECLARED_METHODS);
 
            // Khai báo file resource cần đóng gói vào binary
            hints.resources().registerPattern("templates/*.html");
        }
    }
}

Một class @Configuration tự implement RuntimeHintsRegistrar thì Spring không tự nhận ra đâu; phải đăng ký qua @ImportRuntimeHints như trên. Nếu chỉ cần (de)serialize JSON một DTO thì @RegisterReflectionForBinding(PaymentPayload.class) gọn hơn. Với thư viện bên thứ ba, xem trước GraalVM Reachability Metadata Repository (plugin Maven/Gradle của Native Build Tools tự dùng), hoặc chạy app trên JVM với tracing agent (-agentlib:native-image-agent) để nó ghi lại các lời gọi reflection thật.

# build CI/CD lâu và ngốn tài nguyên

Để phân tích hết cây phụ thuộc của một app Spring Boot, GraalVM cần:

  • Máy CI/CD nhiều RAM, app Spring Boot cỡ vừa thường cần vài GB trở lên cho riêng bước build native.
  • Vài phút chạy gần kín CPU, thay vì vài chục giây như build JAR. Cấu hình không khéo là feedback loop của dev chậm đi thấy rõ.

# quản lý bộ nhớ và Garbage Collection

Mặc định Native Image dùng Serial GC (stop-the-world, một luồng, hợp với heap nhỏ). GraalVM Community Edition chỉ có Serial GC và Epsilon GC (không dọn rác); G1 GC cho Native Image chỉ có ở Oracle GraalVM (trên Linux), chứ không phải chỉnh flag là có được. Heap lớn mà dùng Serial GC thì pause dài, đây cũng là một lý do P99 của native kém hơn ở bảng trên.


# lựa chọn ở giữa: AOT cache của Project Leyden

Trước khi quyết định đổi cả cách build sang Native Image, nên biết là OpenJDK giờ đã có một lựa chọn nằm ở giữa. Ý tưởng của Project Leyden khá đơn giản: phần lớn việc JVM làm lúc khởi động (đọc và parse class, verify, link, chạy static initializer, rồi lần mò xem method nào nóng) thì lần chạy nào cũng giống lần nào. Vậy thì chạy thử một lần, ghi lại kết quả vào một file cache, những lần sau JVM nạp thẳng từ cache.

Khác với Native Image, đây vẫn là JVM với JIT bình thường. Reflection, dynamic proxy, nạp class lúc runtime vẫn chạy như cũ, không cần reachability metadata, không cần build lại. Đổi lại, startup chỉ nhanh hơn chứ không nhảy xuống cỡ mili-giây như Native Image.

Các mảnh đã vào JDK:

  • JEP 483 (JDK 24): cache class đã nạp và link sẵn. JEP ghi Spring PetClinic khởi động từ 4,486 giây xuống 2,604 giây, nhanh hơn khoảng 42%.
  • JEP 514 (JDK 25): gộp các bước tạo cache thành một cờ -XX:AOTCacheOutput.
  • JEP 515 (JDK 25): lưu luôn profile của method vào cache, JIT biên dịch được ngay từ đầu nên app nóng máy nhanh hơn.
  • JEP 516 (JDK 26): cache object chạy được với mọi GC, kể cả ZGC (trước đó AOT cache không dùng được với ZGC).

Với Spring Boot trên JDK 25, cách làm theo docs chính thức là giải nén jar rồi chạy một lượt "huấn luyện":

# 1. Giải nén jar (AOT cache cần classpath gồm các file jar, không dùng được fat jar lồng nhau)
java -Djarmode=tools -jar my-app.jar extract --destination application
cd application
 
# 2. Training run: khởi động tới khi context refresh xong thì thoát, ghi ra app.aot
java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar my-app.jar
 
# 3. Production: chạy kèm cache
java -XX:AOTCache=app.aot -jar my-app.jar

Mình thử với một app Spring Boot 4.1.1 nhỏ (web, actuator, JPA, H2) trên eclipse-temurin:25-jdk, container giới hạn 2 CPU và 1 GB RAM, chạy mỗi kiểu 5 lần. Không có cache, process khởi động mất khoảng 2,6 giây; có AOT cache thì còn khoảng 1,2 giây. File app.aot nặng khoảng 93 MB. Không thấm vào đâu so với Native Image, nhưng gần như miễn phí: không đổi code, không đổi thư viện, chỉ thêm một bước vào Dockerfile.

Có mấy điều kiện cần giữ, không thì JVM sẽ bỏ qua cache (in cảnh báo rồi chạy như bình thường chứ không lỗi):

  • Training run và production phải dùng cùng bản JDK, cùng kiến trúc CPU. Nâng JDK hay build image cho arm64 lẫn amd64 là phải tạo lại cache cho từng bản.
  • Classpath phải giống hệt, chỉ gồm file jar. Đổi một dependency là tạo lại cache, nên đặt bước training ngay trong Dockerfile, sau bước copy jar.
  • Không dùng agent sửa bytecode lúc nạp class (kiểu một số APM agent dùng JVMTI ClassFileLoadHook) trong lúc huấn luyện.

Nếu team bạn đang ở JDK 21 hay đời trước thì Spring Boot cũng hỗ trợ CDS (Class Data Sharing) theo cách tương tự với cờ -XX:ArchiveClassesAtExit và -XX:SharedArchiveFile, mức cải thiện ít hơn nhưng cách làm y hệt.


# khi nào nên, khi nào không nên dùng GraalVM Native Image?

# chỗ hợp nhất với GraalVM Native Image

  1. Serverless Functions (AWS Lambda, Azure Functions): giải quyết gần như dứt điểm cold start ở phía app. Riêng trên Lambda, JVM thường còn có lựa chọn SnapStart (khôi phục từ snapshot) nên đáng so cả hai.
  2. Kubernetes Horizontal Pod Autoscaler (HPA): traffic tăng vọt thì app trong Pod mới khởi động gần như tức thì, khỏi chờ 15-30 giây. Thời gian còn lại là của Kubernetes (lên lịch Pod, kéo image, readiness probe).
  3. Command Line Tools (CLI) viết bằng Java: bấm là chạy, nhanh như tool viết bằng Go hay Rust.
  4. Giảm chi phí hạ tầng cloud: RAM mỗi Pod thấp hơn nhiều thì xếp được nhiều Pod hơn trên một node, bớt được node RAM đắt đỏ trên AWS/GCP.

# chỗ nên ở lại với HotSpot JIT

  1. Service chạy dài ngày, throughput cao: core banking, sàn thương mại điện tử chạy quanh năm. JIT sau khi warm-up cho throughput cao hơn và P99 đều hơn.
  2. Dự án dính nhiều thư viện legacy: thư viện cũ không có reachability metadata sẽ thỉnh thoảng ném ClassNotFoundException hoặc NoSuchMethodException lúc runtime, mà chỉ lộ ra khi chạy đúng nhánh code đó.
  3. Team cần dev nhanh: compile tức thì, restart nhanh với HotSwap / DevTools.

# lời kết

GraalVM Native Image sinh ra không phải để khai tử HotSpot JIT. Nó cho Java thêm một lựa chọn để đấu sòng phẳng với Go, Rust và Node.js ở mảng Cloud-Native Fast Startup & Low Footprint.

Có hai chuyện nên theo dõi trước khi đặt cược lớn vào Native Image. Thứ nhất, tháng 9/2025 Oracle tách GraalVM khỏi dòng sản phẩm Java SE: GraalVM for JDK 24 là bản cuối nằm trong gói Java SE, Native Image không còn được hỗ trợ cho khách hàng Java SE, đội GraalVM chuyển trọng tâm sang GraalPy, GraalJS. Native Image vẫn có ở GraalVM Community Edition và Oracle GraalVM (giấy phép GFTC), nhưng hướng chính thức cho Java giờ là Project Leyden trong OpenJDK. Thứ hai, chính Leyden đã vào JDK và đang tiến thêm từng bước: JDK 28 dự kiến có thêm JEP 544 để cache luôn code đã biên dịch. Nếu bạn chỉ cần startup nhanh hơn chứ chưa cần tới vài chục mili-giây, thử AOT cache trên JDK 25 như ở mục trên trước khi tính chuyện chuyển hẳn sang Native Image.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.