Khởi động nhanh quan trọng hơn người ta nghĩ: nó quyết định bạn triển khai được bao nhiêu lần một ngày, và hệ thống hồi phục nhanh thế nào sau sự cố.

Năm cấu hình

  mặc định                     khởi động 1,086 s | RSS 320 060 KB
  lazy-initialization=true     khởi động 0,928 s | RSS 297 784 KB
  -XX:TieredStopAtLevel=1      khởi động 0,845 s | RSS 181 840 KB
  luồng ảo                     khởi động 1,057 s | RSS 308 984 KB
  -XX:MaxRAMPercentage=50      khởi động 1,038 s | RSS 322 464 KB

Dòng thứ ba là con số lớn nhất: RSS giảm 43% và khởi động nhanh 22%.

Nhưng đừng dùng nó trên sản xuất

-XX:TieredStopAtLevel=1 bảo JIT dừng ở tầng biên dịch C1 và không bao giờ lên C2. Ứng dụng lên nhanh hơn vì JVM không tốn công biên dịch tối ưu — và chạy chậm hơn đáng kể khi vào tải thật.

Bài 85 sê-ri Java nói về các tầng JIT: C1 biên dịch nhanh và tối ưu ít, C2 ngược lại. Cắt C2 là cắt phần tối ưu quan trọng nhất.

Nên tham số này đúng cho: chạy dev cục bộ (khởi động lại liên tục), bộ test, và công việc ngắn chạy vài giây rồi thoát. Với dịch vụ chạy dài, nó là đánh đổi sai.

lazy-initialization

spring:
  main:
    lazy-initialization: true

Bean chỉ được tạo khi có ai cần. Nhanh hơn 15% trên ứng dụng nhỏ này; trên ứng dụng có hàng trăm bean, khác biệt lớn hơn nhiều.

Cái giá thật sự không phải hiệu năng: lỗi cấu hình chuyển từ lúc khởi động sang lúc chạy. Một bean khai sai sẽ không nổ lúc khởi động mà nổ ở request đầu tiên chạm tới nó — có thể là ba ngày sau, trên sản xuất.

Điều đó phá vỡ một trong những điểm mạnh nhất của Spring: bài 23 và bài 50 đều cho thấy ứng dụng không khởi động khi cấu hình sai, và đó là tính năng.

Với dev thì tôi bật. Với sản xuất thì không.

Thứ thật sự làm khởi động chậm

Trên ứng dụng thật, thời gian khởi động phần lớn nằm ở bốn chỗ:

Quét classpath. Spring quét mọi lớp trong package để tìm @Component. Càng nhiều phụ thuộc, càng lâu. Thu hẹp @ComponentScan giúp được, nhưng dễ gãy khi tái cấu trúc.

Auto-configuration. Spring Boot xét hàng trăm lớp cấu hình với @Conditional. Xem cái gì được bật:

java -jar app.jar --debug | grep -A5 "Positive matches"

Danh sách đó thường có thứ bạn không dùng — loại chúng bằng spring.autoconfigure.exclude.

Kết nối ra ngoài. Pool kết nối CSDL khởi tạo minimum-idle kết nối lúc khởi động (bài 34), Flyway chạy migration (bài 33), Redis bắt tay. Đây thường là phần lớn nhất, và nó không giảm được nhiều — chỉ chuyển sang lười khởi tạo, với cùng đánh đổi ở trên.

Nạp và xác minh lớp. Với hàng chục nghìn lớp, đây là công thật của JVM.

Muốn biết chính xác chỗ nào tốn thời gian:

@SpringBootApplication
public class App {
    public static void main(String[] a) {
        var app = new SpringApplication(App.class);
        app.setApplicationStartup(new BufferingApplicationStartup(2048));
        app.run(a);
    }
}

Rồi đọc /actuator/startup. Nó liệt kê từng bước khởi động kèm thời gian — chính xác hơn nhiều so với đoán.

AOT và CDS

AOT của Spring (spring-boot:process-aot) sinh sẵn mã cấu hình lúc build, nên lúc chạy Spring không phải suy diễn bằng phản chiếu. Nó là nền cho ảnh native (bài 58) nhưng cũng dùng được với JVM thường.

CDS — chia sẻ dữ liệu lớp — lưu sẵn bản đã phân tích của các lớp:

java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
java -XX:SharedArchiveFile=app.jsa -jar app.jar

Từ Spring Boot 3.3, có hỗ trợ sẵn qua -Djarmode=tools ... extract --launcher. Nó cắt được khoảng 20–30% thời gian khởi động mà không đánh đổi hiệu năng lúc chạy — khác hẳn TieredStopAtLevel=1.

Bộ nhớ

RSS 320 MB cho một ứng dụng gần như trống là điều bình thường gây ngạc nhiên. Nó gồm heap, metaspace, ngăn xếp luồng, bộ đệm mã, bộ đệm trực tiếp — và JVM giữ bộ nhớ đã xin kể cả khi không dùng.

Bài 82 và 97 sê-ri Java đã đo phần này kỹ. Ba điều đáng nhắc lại cho Spring Boot trong container:

Dùng -XX:MaxRAMPercentage, đừng dùng -Xmx cố định. Cùng một ảnh chạy đúng trên mọi kích thước container. Mặc định của JVM là 25% RAM container — thường quá ít.

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70 -XX:MaxMetaspaceSize=256m"

JAVA_TOOL_OPTIONS được JVM đọc tự động nên không phải sửa CMD, và người vận hành ghi đè được từ bên ngoài.

Đặt trần cho metaspace. Không có trần, nó lớn tới khi container bị OOM-kill — và OOM-kill không để lại heap dump, nên bạn không biết vì sao.

Chọn bộ thu gom rác theo tải. Serial GC tốn ít bộ nhớ nhất và hợp với container nhỏ; G1 là mặc định hợp lý; ZGC cho độ trễ thấp nhưng tốn thêm bộ nhớ.

Có nên tối ưu không

Câu hỏi thật là: 1,1 giây có phải vấn đề không?

Với ứng dụng chạy dài, khởi động lại vài lần một ngày — không. Đừng đánh đổi hiệu năng lúc chạy để lấy vài trăm mili giây lúc khởi động.

Nó thành vấn đề khi: bạn co giãn tự động theo tải và cần pod mới phục vụ được ngay; bạn triển khai hàng chục lần một ngày; hoặc bạn chạy trên nền không máy chủ, nơi mỗi lời gọi có thể là một lần khởi động nguội.

Trường hợp cuối là lúc ảnh native đáng cân nhắc — bài ngày mai.

Thử ba mươi giây

java -jar app.jar 2>&1 | grep "Started .* in"

Con số đó nhân với số lần bạn khởi động lại mỗi ngày là thời gian bạn đang trả. Nếu nó dưới hai giây và bạn triển khai vài lần một tuần, hãy bỏ qua cả bài này và đi làm việc khác.

Ngày mai: đóng gói và triển khai — jar phân tầng, và tầng ứng dụng chỉ nặng 12 kilobyte.