Cùng một ứng dụng Vert.x — một máy chủ HTTP với hai route — đóng gói ba cách rồi đo. Kết quả có một con số làm tôi phải đo lại hai lần.

Ba cách

Fat jar bằng maven-shade-plugin, chạy trên eclipse-temurin:21-jre-alpine. Jar 6,6 MB.

jlink: dựng một runtime Java chỉ chứa module thật sự cần. jdeps cho biết ứng dụng cần đúng năm module:

java.base, java.compiler, java.logging, java.naming, jdk.unsupported
jlink --add-modules java.base,java.compiler,java.logging,java.naming,jdk.unsupported \
      --strip-debug --no-header-files --no-man-pages --compress=zip-9 --output /rt

Runtime thu được 32 MB, so với JDK đầy đủ 336 MB — nhỏ hơn 10,5 lần.

Ảnh native bằng GraalVM native-image, dựng trong container, mất 28,7 giây. Tệp nhị phân 37 MB, không cần JVM.

Khởi động và bộ nhớ

Chạy trong Docker, đo từ lúc docker run tới lúc endpoint trả lời được:

Ảnh Docker Ứng dụng tự báo Phục vụ được sau Bộ nhớ lúc rảnh
Fat jar + JRE 299 MB 332 ms 532 ms 103,4 MB
Fat jar + jlink 92,4 MB 230 ms 322 ms 69,0 MB
Native 188 MB 25 ms 53 ms 9,5 MB

Native khởi động nhanh hơn 13 lần và tốn ít bộ nhớ hơn 11 lần. Đó là những con số làm nên danh tiếng của nó, và chúng đúng.

Hai chú thích về cột kích thước ảnh:

  • 188 MB của native không phải lỗi của native — tệp nhị phân chỉ 37 MB, phần còn lại là base image debian:12-slim tôi chọn. Đổi sang distroless/base-debian12 thì ảnh xuống 96,1 MB… nhưng không chạy được: error while loading shared libraries: libz.so.1. Tệp nhị phân native vẫn liên kết động tới thư viện hệ thống, nên base image phải có đủ chúng. Muốn ảnh thật nhỏ thì phải dựng liên kết tĩnh (--static), và đó là một cuộc chơi khác.
  • jlink là cách rẻ nhất để giảm ảnh mà không đổi gì trong mã: 299 MB xuống 92,4 MB, hai dòng Dockerfile, và mọi thứ vẫn là Java bình thường — vẫn gỡ lỗi được, vẫn dùng được mọi thư viện.

Con số tôi phải đo lại

Nếu bài dừng ở bảng trên thì kết luận sẽ là "dùng native đi". Nhưng khởi động chỉ xảy ra một lần, còn thông lượng thì suốt đời. Tôi bắn tải 100 kết nối:

5 giây đầu Sau 25 giây Bộ nhớ sau tải
Fat jar + JRE 29 826 req/s 35 765 req/s 214,3 MB
Native 21 296 req/s 26 531 req/s 15,8 MB

Native chậm hơn 26% ở thông lượng đỉnh. Đó là cái giá của biên dịch trước: JVM có JIT, nó quan sát mã đang chạy thật rồi tối ưu theo đường nóng thực tế — nội tuyến hàm, bỏ nhánh không bao giờ chạy, chuyên biệt hoá theo kiểu. native-image phải quyết định tất cả lúc biên dịch, khi chưa biết gì về dữ liệu thật.

Đáng chú ý là native cũng nóng lên: 21 296 → 26 531 req/s giữa 5 giây đầu và 25 giây sau, tức tăng 25%. Không phải JIT — GraalVM native có bộ dọn rác và các cấu trúc nội bộ cũng cần ổn định. Nên "AOT không cần hâm nóng" là câu nói không hoàn toàn đúng.

Còn cột bộ nhớ thì chênh lệch giữ nguyên dưới tải: 15,8 MB so với 214,3 MB — gấp 13,6 lần.

Chọn cái nào

Đặt hai bảng cạnh nhau thì lựa chọn khá rõ, và nó phụ thuộc vào vòng đời của tiến trình:

  • Máy chủ chạy liên tục nhiều tháng → fat jar + jlink. Khởi động 322 ms một lần trong đời là không đáng kể, còn 35% thông lượng thì đáng suốt đời. Cộng thêm việc bạn giữ được toàn bộ công cụ Java quen thuộc.
  • Hàm serverless, tác vụ theo lịch, công cụ dòng lệnh → native. Ở đó khởi động phần lớn vòng đời, và 25 ms so với 332 ms là khác biệt giữa "phản hồi ngay" với "người dùng thấy chờ".
  • Container bị siết bộ nhớ chặt → native, vì 15,8 MB so với 214,3 MB có thể là khác biệt giữa chạy được và bị hệ điều hành giết.
  • Chạy nhiều bản sao nhỏ → native, vì bộ nhớ nhân với số bản sao.

Ba lỗi khi dựng native, và cách tìm ra chúng

Đây là phần tốn thời gian nhất, và tôi ghi lại đủ vì tài liệu chính thức không nói.

Lỗi 1 — đoán cờ --initialize-at-run-time. Tôi bắt đầu bằng --initialize-at-run-time=io.netty vì nghe hợp lý. Ảnh dựng xong, chạy thì chết:

java.lang.ExceptionInInitializerError
    at io.netty.util.internal.PlatformDependent$Mpsc.newMpscQueue

Netty đã có sẵn cấu hình native-image trong META-INF/native-image/ (tôi kiểm tra: fat jar giữ đủ 49 mục đó, maven-shade không làm mất). Cờ tôi thêm vào đã đè lên cấu hình của họ và làm hỏng nó. Đừng khai bừa --initialize-at-run-time cho cả một gói lớn.

Lỗi 2 — bỏ cờ đó ra thì build hỏng, nhưng vì lý do khác:

Error: Classes that should be initialized at run time got initialized during image building:
  io.netty.buffer.PooledByteBufAllocator ...

Thay vì đoán tiếp, tôi dùng đúng công cụ GraalVM gợi ý ngay trong thông báo:

native-image --trace-class-initialization=io.netty.buffer.PooledByteBufAllocator -jar app.jar

Nó in ra chuỗi gọi, và thủ phạm hiện ngay:

at io.netty.buffer.PooledByteBufAllocator.<clinit>
at io.netty.buffer.ByteBufUtil.<clinit>
at io.netty.handler.ssl.JdkSslServerContext.<clinit>

Lớp SSL của Netty được khởi tạo lúc build và kéo theo bộ cấp phát buffer. Thêm đúng một cờ hẹp:

--initialize-at-run-time=io.netty.handler.ssl

Build xong trong 28,7 giây và chạy được. Bài học: đừng đoán cờ, hãy dùng --trace-class-initialization — nó cho câu trả lời trong một lần chạy.

Lỗi 3 — RUN … | tail -40 nuốt mã thoát. Tôi thêm | tail -40 vào lệnh native-image trong Dockerfile để log gọn hơn. Trong sh, mã thoát của đường ống là mã thoát của lệnh cuối, tức là tail — luôn bằng 0. Docker coi bước đó thành công, đi tiếp, rồi hỏng ở bước sau với thông báo chẳng liên quan:

failed to compute cache key: "/b/maychu": not found

Không có gì đặc trưng cho native ở đây, nhưng nó làm tôi mất thời gian đúng vào lúc đang gỡ một lỗi khác. Trong Dockerfile, hoặc bỏ đường ống, hoặc thêm set -o pipefail.

Thử ba mươi giây

Xem ảnh Docker của bạn giảm được bao nhiêu mà không cần đổi gì trong mã:

jdeps --print-module-deps --ignore-missing-deps --multi-release 21 target/app.jar

Con số module in ra thường ít hơn nhiều so với dự đoán — của tôi là năm. Đưa danh sách đó vào jlink trong tầng build của Dockerfile là ảnh của bạn giảm ba lần, và mọi thứ khác giữ nguyên.

Phần sau đo thông lượng theo số event loop, số worker và kích thước pool — và chỉ ra chỗ tăng thêm không còn tác dụng.