GraalVM Native Images
Compile a Spring Boot API to a native executable with GraalVM for millisecond startup and low memory, and handle reflection with runtime hints.
@ImportRuntimeHints(MyHints.class)
class MyHints implements RuntimeHintsRegistrar {
public void registerHints(RuntimeHints hints, ClassLoader cl) {
hints.resources().registerPattern("templates/*.txt");
}
}GraalVM's native-image compiles your application and every library it uses into a single platform-specific executable. There is no JVM at runtime: a typical REST service starts in well under a second and uses a fraction of the memory. The cost is a long build, a closed world, and some loss of peak throughput because there is no JIT compiler optimising hot code as it runs.
When it is worth it
- Serverless and scale-to-zero platforms, where every cold start is user-visible.
- Many small services where memory per instance is the main cost.
- Command-line tools built with Spring.
For a busy, long-running API on a few large instances, the JVM with an AOT cache is usually the better trade.
Building
Projects from Spring Initializr with the GraalVM Native Support dependency include the native Maven profile. Two ways to build:
./mvnw -Pnative native:compile # needs GraalVM installed locally
./mvnw -Pnative spring-boot:build-image # builds a container image, no local GraalVMExpect several minutes and several gigabytes of memory per build. Build native in CI, and keep everyday development on the JVM.
The closed world
The compiler includes only code it can prove is reachable. Anything found at runtime by name, such as reflection, resources loaded from the classpath, dynamic proxies and Java serialisation, is invisible unless it is declared. Spring's AOT engine generates those declarations for everything it manages: beans, controllers, configuration properties, JPA entities, and request and response types of your controllers. Many libraries ship their own metadata through the GraalVM reachability metadata repository, which the build tools use automatically.
Your own dynamic code is your job. Register it with a RuntimeHintsRegistrar imported by @ImportRuntimeHints, or use @RegisterReflectionForBinding for types you serialise to or from JSON yourself.
Mistakes people make
- Only testing on the JVM. A missing hint shows up as a
ClassNotFoundExceptionor an empty JSON object only in the native binary. Run the test suite natively with./mvnw -PnativeTest test. - Expecting profiles to switch beans at runtime. As with Spring AOT, bean conditions are fixed at build time.
- Assuming native is always faster. Measure throughput under realistic load before switching a high-traffic service.
Example
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
interface InvoiceFormatter {
String format(long cents);
}
class EuroFormatter implements InvoiceFormatter {
public EuroFormatter() {
}
public String format(long cents) {
return String.format("EUR %d.%02d", cents / 100, cents % 100);
}
}
class InvoiceHints implements RuntimeHintsRegistrar {
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Loaded by name from configuration: invisible to the native compiler otherwise.
hints.reflection().registerType(EuroFormatter.class, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
// Read from the classpath at runtime.
hints.resources().registerPattern("invoice/*.txt");
}
}
(InvoiceHints.class)
class NativeHintsConfig {
}
public class InvoiceRenderer {
public String render(String formatterClass, long cents) throws Exception {
InvoiceFormatter formatter = (InvoiceFormatter) Class.forName(formatterClass)
.getConstructor()
.newInstance();
return template().replace("{total}", formatter.format(cents));
}
private String template() throws IOException {
try (InputStream in = new ClassPathResource("invoice/template.txt").getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
When to use it
- A webhook receiver on a serverless platform starts in tens of milliseconds instead of several seconds, so cold starts stop timing out.
- A company runs 40 small internal services and cuts their combined memory footprint by moving them to native images.
- A native build answers with empty JSON for one endpoint; adding @RegisterReflectionForBinding for the manually parsed record fixes it.
More examples
Binding hints for JSON you parse yourself
Spring registers hints for types that appear in controller signatures, but a payload read with the mapper inside a service is invisible to it. Without the annotation, the native binary often deserialises the record with every field missing or fails outright. <code>@RegisterReflectionForBinding</code> registers the record and the types it refers to, such as <code>LineItem</code>, for Jackson.
import java.util.List;
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
import org.springframework.stereotype.Service;
import tools.jackson.databind.json.JsonMapper;
record WebhookEvent(String id, String type, List<LineItem> items) {}
record LineItem(String sku, int quantity) {}
@Service
@RegisterReflectionForBinding(WebhookEvent.class)
public class WebhookParser {
private final JsonMapper mapper;
WebhookParser(JsonMapper mapper) {
this.mapper = mapper;
}
public WebhookEvent parse(String payload) {
// Not a controller parameter, so Spring AOT cannot infer the type on its own.
return mapper.readValue(payload, WebhookEvent.class);
}
}
Checking hints in an ordinary unit test
Native builds are too slow to catch every missing hint, so test the registrar on the JVM in milliseconds. <code>RuntimeHintsPredicates</code> answers whether a given reflective call or resource lookup is covered. A rename of the template folder or the formatter class then fails a normal unit test instead of a production request.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
class EuroFormatter {
public EuroFormatter() {
}
}
class InvoiceHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection().registerType(EuroFormatter.class, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
hints.resources().registerPattern("invoice/*.txt");
}
}
class InvoiceHintsTest {
@Test
void registersFormatterAndTemplates() {
RuntimeHints hints = new RuntimeHints();
new InvoiceHints().registerHints(hints, getClass().getClassLoader());
assertThat(RuntimeHintsPredicates.reflection().onType(EuroFormatter.class)).accepts(hints);
assertThat(RuntimeHintsPredicates.resource().forResource("invoice/template.txt")).accepts(hints);
}
}
Building and running natively
The first form is fastest to iterate on when GraalVM is installed. The buildpack route needs only Docker and produces a small image that contains just the executable and its base layer. The native test run is the step teams skip and regret: it is the only place reflection and resource gaps show up before production.
# Native executable (requires GraalVM on the build machine)
./mvnw -Pnative native:compile
./target/app
# Container image built by Cloud Native Buildpacks, no local GraalVM
./mvnw -Pnative spring-boot:build-image
docker run --rm -p 8080:8080 app:0.0.1-SNAPSHOT
# Run the test suite compiled as a native image
./mvnw -PnativeTest test
Discussion