Hãy so hai cách có một bữa ăn. Bữa đông lạnh: mở tủ, cho vào lò vi sóng, mười giây sau đã ăn được, hộp lại gọn nhẹ — nhưng nó đã được nấu và đúc cứng từ trước, bạn không nêm lại được, và món nào không gói sẵn thì đơn giản là không có. Bữa do đầu bếp nấu: nhóm bếp lâu hơn, chiếm cả gian bếp, nhưng người đầu bếp nếm và chỉnh theo khẩu vị bạn suốt bữa, và nấu được cả món phát sinh giữa chừng. Ảnh native GraalVM là bữa đông lạnh. JVM với JIT là người đầu bếp. Cả bài này là đo xem bữa đông lạnh nhanh gọn tới đâu, và bạn trả giá bằng những gì. Ảnh native biên dịch ứng dụng Java thành mã máy. Bài này đo nó trên một ứng dụng Spring Boot thật.
Bốn con số
khởi động ảnh native : 0,171 s | 0,100 s | 0,103 s
khởi động JVM : 1,188 s | 1,135 s | 1,057 s
RSS ảnh native : 115 744 KB (113 MB)
RSS JVM : 341 924 KB (334 MB)
kích thước ảnh native : 78,9 MB (chạy độc lập)
kích thước jar : 24,7 MB (cần thêm JRE)
thời gian build native : 45,9 s | đỉnh RSS 4,85 GB | CPU load 9,64
Khởi động nhanh hơn mười lần. Bộ nhớ ít hơn 66%.
Đó là hai lời hứa của ảnh native, và cả hai đều đúng.
Cái giá
Build
45,9 giây cho một ứng dụng gần như trống. Ứng dụng thật với JPA, Security và vài chục phụ thuộc thường mất năm tới mười lăm phút.
Và nó ngốn tài nguyên: đỉnh RSS 4,85 GB, CPU load 9,64. Máy CI của bạn phải chịu được điều đó, và mỗi lần build tốn ngần ấy.
Cái này kéo theo một chuyện thực tế: vòng lặp phát triển của bạn không dùng ảnh native. Bạn phát triển trên JVM và chỉ dựng native cho bản phát hành — nghĩa là có hai chế độ chạy, và thứ chạy được ở chế độ này có thể hỏng ở chế độ kia.
Phản chiếu
Ảnh native dùng phân tích tĩnh đóng thế giới: mọi thứ được gọi phải biết trước lúc biên dịch. Nhưng phản chiếu, proxy động, và nạp tài nguyên lúc chạy đều không nhìn thấy được bằng phân tích tĩnh. Đây đúng là "món nào không gói sẵn thì không có" của bữa đông lạnh.
Spring Boot xử lý phần lớn qua AOT: lúc build, nó chạy trước phần cấu hình và sinh ra siêu dữ liệu cho GraalVM. Nhờ vậy phép đo ở trên chạy được mà tôi không phải khai gì.
Nhưng nó chỉ lo được thứ Spring biết. Thư viện bên thứ ba dùng phản chiếu mà không có tệp hint sẽ hỏng — và hỏng lúc chạy, ở đúng nhánh mã hiếm gặp, chứ không phải lúc build.
Cách xử lý:
@RegisterReflectionForBinding(LopCuaToi.class)
hoặc chạy ứng dụng trên JVM với tác nhân theo dõi rồi lấy siêu dữ liệu nó sinh ra:
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image -jar app.jar
Cách thứ hai đòi bạn thực thi mọi nhánh mã trong lúc theo dõi — nhánh nào không chạy thì nhánh đó không được ghi lại.
Hiệu năng đỉnh
Tôi đã thử đo thông lượng và không tách được phần của máy chủ khỏi chi phí khởi động tiến trình curl — cả hai cho ra khoảng 3,9 giây cho 2000 request, tức là tôi đang đo curl chứ không đo ứng dụng. Nên tôi không đưa con số đó ra như một kết luận.
Điều biết được từ nguyên lý: ảnh native không có JIT, nên nó không tối ưu theo hồ sơ chạy thật — người đầu bếp không còn nếm và chỉnh nữa. Bài 85 sê-ri Java nói về các tầng JIT và vì sao C2 quan trọng. Với tải chạy dài, JVM đã làm nóng thường nhanh hơn ảnh native — GraalVM có chế độ PGO để thu hẹp khoảng cách, nhưng nó thuộc bản Oracle GraalVM và thêm một bước build nữa.
Khi nào nó đáng
Nền không máy chủ. Mỗi lời gọi có thể là một lần khởi động nguội. 0,1 giây so với 1,1 giây là khác biệt giữa dùng được và không.
Co giãn theo tải, rất nhanh. Pod mới phục vụ được sau một phần mười giây.
Container nhiều, RAM đắt. 113 MB so với 334 MB, nhân với hàng trăm bản sao.
CLI viết bằng Java. Khởi động JVM là thứ làm CLI Java bị chê, và ảnh native xoá hẳn nó.
Khi nào không
Dịch vụ chạy dài, tải ổn định. Khởi động một lần rồi chạy hàng tuần — 1 giây so với 0,1 giây không có nghĩa gì, mà bạn mất JIT.
Dùng nhiều thư viện chưa hỗ trợ native. Bạn sẽ dành nhiều thời gian viết hint hơn viết tính năng.
Đội chưa quen. Gỡ lỗi ảnh native khó hơn hẳn: không có JFR đầy đủ, không có heap dump như thường, và công cụ mà bài 89 sê-ri Java giới thiệu phần lớn không dùng được.
CI không chịu nổi. 5 GB RAM và mười lăm phút cho mỗi lần build là chi phí thật.
Với phần lớn ứng dụng Spring Boot chạy trong Kubernetes, câu trả lời thành thật là: chưa cần. Bài 56 cho thấy có nhiều cách rẻ hơn để khởi động nhanh hơn — CDS chẳng hạn — mà không đánh đổi gì.
Nếu vẫn muốn thử
<profile>
<id>native</id>
<build><plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId><artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins></build>
</profile>
mvn -Pnative native:compile
# hoặc dựng thẳng ảnh Docker, không cần cài GraalVM cục bộ:
mvn -Pnative spring-boot:build-image
Ba lời khuyên:
Chạy bộ test ở chế độ native — mvn -PnativeTest test. Đây là cách duy nhất bắt được lỗi phản chiếu trước khi lên sản xuất.
Đọc kỹ khuyến nghị lúc build. Bản build của tôi in ra ba dòng: đặt trần heap, bật --strict-image-heap, bật -march=native. Chúng đáng làm.
Bắt đầu từ dự án mới, đừng chuyển dự án đang chạy. Chuyển đổi là công việc dài và dễ nản.
Còn nếu chưa biết mình có thuộc nhóm cần nó không, thì kiểm nhanh một dòng — nhìn log khởi động:
grep "Started .* in" <log khởi động>
Nếu con số đó dưới hai giây và bạn không chạy trên nền không máy chủ — hãy đóng bài này lại. Bài 56 có ba cách cải thiện rẻ hơn nhiều, và không cách nào đòi bạn viết tệp hint phản chiếu. Còn nếu bạn thấy 0,1 giây là thứ thay đổi được kiến trúc của mình, thì bảng đầu bài là điểm khởi đầu — kèm cả cột chi phí.
Mẫu số chung
Cái căng thẳng "biên dịch trước để khởi động nhanh, hay tối ưu lúc chạy để đạt đỉnh cao" — AOT so với JIT — không phải chuyện riêng của GraalVM. Nó là một trục thiết kế cơ bản, và mỗi ngôn ngữ ngồi ở một chỗ khác nhau trên trục đó.
- Go và Rust sinh ra đã ở đúng chỗ mà ảnh native cố bò tới: biên dịch trọn vẹn ra mã máy, khởi động tức thì, RAM thấp. Đổi lại chúng không có JIT — không thích ứng theo hồ sơ chạy thật — nên với tải chạy rất dài, một JVM đã làm nóng đôi khi vẫn vượt được. Đây là bữa đông lạnh làm nghề chính.
- C#/.NET là ca thú vị nhất vì nó có cả hai, giống Java: JIT phân tầng mặc định, cộng Native AOT cho đúng đánh đổi khởi động nhanh — và Native AOT cũng hạn chế phản chiếu, cũng cần cắt tỉa và khai hint, y hệt bài toán bạn vừa thấy ở GraalVM.
- JavaScript (V8) thì thuần JIT thích ứng — luôn là người đầu bếp nếm và chỉnh, không có bản đông lạnh.
Và cái bẫy "đóng thế giới" là hệ quả tất yếu của mọi con đường AOT, ở đâu cũng vậy: Rust không có phản chiếu ngay từ đầu nên không vướng; Go gói cả chương trình vào một tệp nên reflect vẫn chạy; còn .NET Native AOT thì cấm bớt phản chiếu và đòi hint — đúng như GraalVM. Sợi chỉ chung đáng mang theo: không có "nhanh hơn" tuyệt đối, chỉ có nhanh-lúc-khởi-động hay nhanh-lúc-chạy-dài, và bạn không được cả hai cùng một lúc. Điều khiến Java (và C#) đặc biệt là bạn chọn được cho từng bản triển khai — cùng một mã, dựng kiểu nào cũng được. Nên câu hỏi đúng không phải "native có nhanh hơn không", mà là "ứng dụng của tôi sống bằng những lần khởi động, hay bằng những giờ chạy liên tục" — và như bài đã đo, phần lớn dịch vụ Kubernetes thuộc vế sau, nên câu trả lời thành thật vẫn là người đầu bếp.
Ngày mai — bài cuối: nhìn lại sáu mươi ngày.