Key Takeaways
- Spring Boot 3 and Spring Framework 6, due in late 2022, will have built-in support for native Java.
- For Spring Framework 5.x and Spring Boot 2.x users, Spring Native is the way to go.
- Spring Native provides integrations for a vast ecosystem of libraries.
- But Spring Native also ships a component model that allows you to extend native compilation support for other libraries.
- GraalVM AOT compilation offers a lot of possibilities with some (negotiable) costs.
This article is part of the article series "Native Compilation Boosts Java". You can subscribe to receive notifications about new articles in this series via RSS. Java dominates enterprise applications. But in the cloud, Java is more expensive than some competitors. Native compilation with GraalVM makes Java in the cloud cheaper: It creates applications that start much faster and use less memory. So native compilation raises many questions for all Java users: How does native Java change development? When should we switch to native Java? When should we not? And what framework should we use for native Java? This series will provide answers to these questions. |
The Java Community Contains Multitudes
You know, Java is a fantastic ecosystem, and it’s hard to list all the things for which it’s ideally suited. The list seems endless. But it’s also not all that difficult to name at least a few of its warts. As the articles in this series have shown, applications running on the JRE often need ten or more seconds to start and take hundreds or - gasp! - thousands of megabytes of RAM.
This performance is not the best foot forward in today’s world. There are new frontiers, new opportunities: Function-as-a-service offerings. Containerization and container orchestrators. They have one thing in common: startup speed and memory footprint matter.
Go, Go, GraalVM!
GraalVM offers a path forward, with some costs. GraalVM is a drop-in OpenJDK replacement with an extra utility (called Native Image) that supports ahead-of-time (AOT) compilation.
AOT compilation is a little different than regular Java compilation. As the first article in this series put it so succinctly, Native Image "eliminates everything that is unnecessary" in your Java application. So, how does Native Image know what is unnecessary in Java or Spring Boot?
Native Image looks at your source code and determines all the reachable code — that you can link by invocation or usage to your code. Everything else, whether it’s in your application’s classpath or the JRE, is unnecessary and thrown out.
The trouble comes when you do something that Native Image can’t deterministically follow. After all, Java is a very dynamic language. It’s possible to create a Java application that, at runtime, compiles a string into a valid Java class file on the filesystem, loads that into the ClassLoader, and then reflectively creates an instance of it or creates a proxy out of it. It’s possible to serialize that instance to disk and then load it into another JVM. It’s possible to do all this without ever linking to a concrete type more specific than java.lang.Object
! But all that won’t work in native Java if the types are not in the native executable heap.
All is not lost, however. You can tell Native Image which types to preserve in a configuration file so that, if you do decide to do things like reflection, proxies, classpath resource loading, JNI, etc., at runtime, it still works.
Now the Java and Spring ecosystems are vast. Configuring everything could be very painful! So there are two options: 1) teach Spring to avoid some of these mechanisms where possible, or 2) teach Spring to furnish the configuration file as much as possible. This configuration file would necessarily include the Spring Framework and Spring Boot and, to some extent, the third-party integrations Spring Boot supports. Spoiler alert: We need both options!
You need GraalVM running on your machine for the example projects. The GraalVM website has the installation instructions. If you have a Mac, you can also install GraalVM with SDKMAN!
Spring Native
The Spring team started the Spring Native project in 2019 that introduces native executable compilation to the Spring Boot ecosystem. It has served as a research bed for several different approaches. But Spring Native doesn’t radically change Spring Framework 5.x or Spring Boot 2.x. And it’s not the end of the line, merely the first step on a longer journey: It has proven many concepts for the next generations of Spring Framework (6.x) and Spring Boot (3.x), both due later in 2022. These new generations can do more optimizations, so the future looks bright! As those releases aren’t out yet, we’ll look at Spring Native in this article.
Spring Native transforms the source code sent to Native Image. For instance, Spring Native turns the spring.factories
service-loader mechanism into static classes that the resulting Spring Native application knows to consult instead. It also transpiles all your Java configuration classes (those annotated with @Configuration
) into Spring’s functional configuration, eliminating whole swaths of reflection for your application and its dependencies.
Spring Native also automatically analyzes your code, detects scenarios that will require GraalVM configuration, and furnishes it programmatically. Spring Native ships with hint classes for Spring & Spring Boot and third-party integrations.
Your first Spring Native application: JPA, Spring MVC, and H2
You get started with Spring Native the same as with all things Spring: go to the Spring Initializr. Hit cmd + B or Ctrl + B or click on Add Dependencies and select Spring Native.
The Spring Initializr configures Apache Maven and Gradle builds. Then, just add the requisite dependencies. Let’s start with something typical. Change the Artifact name to jpa. Next, add the following dependencies: Spring Native, Spring Web, Lombok, H2 Database
, and Spring Data JPA
. Make sure to specify Java 17. You could use Java 11, just like you could run in circles brandishing a rubber chicken. But then you’d look silly, wouldn’t you? Click Generate. Unzip the project and import the project into your favorite IDE.
This example is a trivial but typical example. Change the JpaApplication.java
class to look like this:
package com.example.jpa;
import lombok.*;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.*;
import javax.persistence.*;
import java.util.Collection;
import java.util.stream.Stream;
@SpringBootApplication
public class JpaApplication {
public static void main(String[] args) {
SpringApplication.run(JpaApplication.class, args);
}
}
@Component
record Initializr(CustomerRepository repository)
implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
Stream.of("A", "B", "C", "D")
.map(c -> new Customer(null, c))
.map(this.repository::save)
.forEach(System.out::println);
}
}
@RestController
record CustomerRestController(CustomerRepository repository) {
@GetMapping("/customers")
Collection<Customer> customers() {
return this.repository.findAll();
}
}
interface CustomerRepository extends JpaRepository<Customer, Integer> {
}
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Table (name = "customer")
class Customer {
@Id
@GeneratedValue
private Integer id;
private String name;
}
It’s also possible to compile and run your tests as a native executable. Mind you, some things, like Mockito, just don’t work particularly well there yet. Change the test, JpaApplicationTests.java
, to look like this:
package com.example.jpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.Assert;
@SpringBootTest
class JpaApplicationTests {
private final CustomerRepository customerRepository;
@Autowired
JpaApplicationTests(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@Test
void contextLoads() {
var size = this.customerRepository.findAll().size();
Assert.isTrue(size > 0, () -> "there should be more than one result!");
}
}
I’ll show commands for macOS in this article. Please adjust them for Windows and Linux appropriately.
You can run the application and the tests on the JRE in the usual way, such as mvn spring-boot:run
from the terminal. It’s a good idea to run the examples for no other reason than to ensure that the application works. But it’s not why we’re here. Instead, we want to compile the application and its test(s) into a GraalVM native application.
If you’ve inspected the pom.xml, you will have already noticed the extensive extra configuration that sets up GraalVM Native Image and adds a Maven profile (called native
) to support building native executables. You can compile the application as usual with mvn
clean package
. Compile the application natively with mvn -Pnative clean package
. Remember you need to have GraalVM set as your JDK here! This process will take several minutes, so now’s a good time to get a cup of tea or coffee or water or whatever. I did. I needed it. When I came back, I saw this in the output:
...
13.9s (16.9% of total time) in 71 GCs | Peak RSS: 10.25GB | CPU load: 5.66
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 03:00 min
[INFO] Finished at: 2022-04-28T17:57:56-07:00
[INFO] ------------------------------------------------------------------------
Woof! Three minutes to compile the native tests and then, if they succeeded, the native application itself. Native Image used as much as 10.25 GB of RAM during the process. To keep things moving more quickly, I’ll skip compiling and running the tests in this article. So when we compile the following examples, use:
mvn -Pnative -DskipTests clean package
Compile times vary based on the application classpath. Anecdotally, most of my builds take anywhere from a minute to 90 seconds if I skip compiling the tests. For example, this application includes JPA (and Hibernate), Spring Data, the H2 database, Apache Tomcat, and Spring MVC.
Run the application:
./target/jpa
On my machine, I see:
…Started TraditionalApplication in 0.08 seconds (JVM running for 0.082)
Not bad! 80 milliseconds, or 80 thousandths of a second! Even better, this application takes next-to-nothing in memory. I use the following script to measure the application’s RSS (resident set size).
#!/usr/bin/env bash
PID=$1
RSS=`ps -o rss ${PID} | tail -n1`
RSS=`bc <<< "scale=1; ${RSS}/1024"`
echo "RSS memory (PID: ${PID}): ${RSS}M"
You’ll need the process ID (PID) of the running application. I can get it on macOS by running pgrep jpa
. I use the script like this:
~/bin/RSS.sh $(pgrep jpa)
RSS memory (PID: 35634): 96.9M
Nearly 97 megabytes of RAM! These numbers may vary based on which operating system and architecture you’re running. The numbers on Linux on Intel will be different than the numbers for macOS on M1. A marked improvement over any JRE application, certainly, but not the best we can get.
I love reactive programming, and I think it’s a much better fit for my workloads today. I put together an analogous reactive application. It not only took considerably less space (for many reasons, including that Spring Data R2DBC supports Java 17’s glorious records syntax), but the application compiled in 1:14 (almost two minutes faster!) and started in 0.044 seconds. And it takes 35% less RAM, 63.5 megabytes. Much better. This application will also handle more requests per second. So it’s faster to compile and execute, more memory efficient, quicker to startup, and handles more traffic. A good deal all around, I’d say.
An Integration Application
Spring is about a whole heckuva lot more than just HTTP endpoints. Countless other frameworks, including Spring Batch, Spring Integration, Spring Security, Spring Cloud, and an ever-growing list of others, all offer good Spring Native support.
Let’s quickly look at a Spring Integration application example. Spring Integration is a framework that supports enterprise-application integration (EAI). Gregor Hohpe and Bobby Woolf’s seminal tome Enterprise Integration Patterns gave the world the lingua franca of integration patterns. And Spring Integration gave it the abstraction in which to implement those patterns.
Go to the Spring Initializr, name the project integration, choose Java 17, add Spring Native,
Spring Integration
, Spring Web
, and click Generate
. You’ll need to add one dependency manually to your pom.xml
:
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-file</artifactId>
<version>${spring-integration.version}</version>
</dependency>
Change the code for IntegrationApplication.java
:
package com.example.integration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.IntegrationFlows;
import org.springframework.integration.file.dsl.Files;
import org.springframework.integration.file.transformer.FileToStringTransformer;
import org.springframework.integration.transformer.GenericTransformer;
import java.io.File;
@SpringBootApplication
public class IntegrationApplication {
@Bean
IntegrationFlow integration(@Value("file://${user.home}/Desktop/integration") File root) {
var in = new File(root, "in");
var out = new File(root, "out");
var inboundFileAdapter = Files
.inboundAdapter(in)
.autoCreateDirectory(true)
.get();
var outboundFileAdapter = Files
.outboundAdapter(out)
.autoCreateDirectory(true)
.get();
return IntegrationFlows //
.from(inboundFileAdapter, spec -> spec.poller(pm -> pm.fixedRate(1000)))//
.transform(new FileToStringTransformer())
.transform((GenericTransformer<String, String>) source -> new StringBuilder(source)
.reverse()
.toString()
.trim())
.handle(outboundFileAdapter)
.get();
}
public static void main(String[] args) {
SpringApplication.run(IntegrationApplication.class, args);
}
}
This application is trivial: it monitors a directory ($HOME/Desktop/integration/in
) for any new files. As soon as it sees any, it creates a copy with its String
contents reversed and writes that out to $HOME/Desktop/integration/out
. The application starts up in 0.429 seconds on the JRE! That’s pretty good, but let’s see what turning it into a GraalVM native executable buys us.
mvn -Pnative -DskipTests clean package
The application was compiled in 55.643 seconds! It started (./target/integration
) in 0.029 seconds and takes 35.5 megabytes of RAM. Not bad!
As you can see, there’s no such thing as a typical result. What you feed into the compilation process matters a lot in determining what comes out.
Taking the Application To Production
We’ll want to take these applications to production at some point, and these days production is Kubernetes. Kubernetes works in terms of containers. We need a container. The core concept behind the Buildpacks project is to centralize and reuse the formulae for turning application artifacts into containers. You can use Buildpacks through the pack CLI, from within your Kubernetes cluster with the KPack, or through Spring Boot build plugins. We’ll use this last option as it requires nothing more than Docker Desktop. Please check here for Docker Desktop.
mvn spring-boot:build-image
This command will build the native executable inside the container, so you’ll get Linux native binaries in a Linux container. You can then docker tag and docker push to your container registry of choice. As I write this in May 2022, Docker Buildpacks on M1 Macs are still a bit shaky. But I’m sure this will get sorted out soon!
Giving Native Image a Hint
In the examples so far, you didn’t have to do anything to make the application work as a native executable. It just worked. This ease-of-use is the kind of result you’d expect most of the time. But sometimes, you have to give a clue to Native Image, as I already mentioned in the "Go, Go, GraalVM!" section at the beginning.
Let’s look at another example. First, go to the spring Initializr, name the project extensions, choose Java 17, add Spring Native
, and click Generate
. Next, we will manually add one dependency not on the Initialzr:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
The goal here is to look at what happens when things go wrong. Spring Native provides a set of hints that make it trivial to augment the default configuration. Change ExtensionsApplication.jav
a to the following:
package com.example.extensions;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.framework.ProxyFactoryBean;
import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.core.io.ClassPathResource;
import org.springframework.nativex.hint.*;
import org.springframework.stereotype.Component;
import org.springframework.util.*;
import java.io.InputStreamReader;
import java.util.List;
import java.util.function.Supplier;
@SpringBootApplication
public class ExtensionsApplication {
public static void main(String[] args) {
SpringApplication.run(ExtensionsApplication.class, args);
}
}
@Component
class ReflectionRunner implements ApplicationRunner {
private final ObjectMapper objectMapper ;
ReflectionRunner(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
record Customer(Integer id, String name) {
}
@Override
public void run(ApplicationArguments args) throws Exception {
var json = """
[
{ "id" : 2, "name": "Dr. Syer"} ,
{ "id" : 1, "name": "Jürgen"} ,
{ "id" : 4, "name": "Olga"} ,
{ "id" : 3, "name": "Violetta"}
]
""";
var result = this.objectMapper.readValue(json, new TypeReference<List<Customer>>() {
});
System.out.println("there are " + result.size() + " customers.");
result.forEach(System.out::println);
}
}
@Component
class ResourceRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
var resource = new ClassPathResource("Log4j-charsets.properties");
Assert.isTrue(resource.exists(), () -> "the file must exist");
try (var in = new InputStreamReader(resource.getInputStream())) {
var contents = FileCopyUtils.copyToString(in);
System.out.println(contents.substring(0, 100) + System.lineSeparator() + "...");
}
}
}
@Component
class ProxyRunner implements ApplicationRunner {
private static Animal buildAnimalProxy(Supplier<String> greetings) {
var pfb = new ProxyFactoryBean();
pfb.addInterface(Animal.class);
pfb.addAdvice((MethodInterceptor) invocation -> {
if (invocation.getMethod().getName().equals("speak"))
System.out.println(greetings.get());
return null;
});
return (Animal) pfb.getObject();
}
@Override
public void run(ApplicationArguments args) throws Exception {
var cat = buildAnimalProxy(() -> "meow!");
cat.speak();
var dog = buildAnimalProxy(() -> "woof!");
dog.speak();
}
interface Animal {
void speak();
}
}
The example includes three ApplicationRunner instances that Spring runs when the application starts. Each bean does something that will annoy GraalVM Native Image. But it works just fine on the JVM: mvn spring-boot:run
The first ApplicationRunner, ReflectionRunner,
reads JSON data and reflectively maps the structure onto a Java class, Customer
. It won’t work because Native Image will remove the Customer
class! Build it with mvn -Pnative -DskipTests clean package
and run ./target/extensions
. Then you will see the "com.oracle.svm.core.jdk.UnsupportedFeatureError: Proxy class defined by interfaces" error yourself.
You can use the @TypeHint
annotation to fix this. Add the following to ExtensionsApplication
class:
@TypeHint(types = ReflectionRunner.Customer.class, access = { TypeAccess.DECLARED_CONSTRUCTORS, TypeAccess.DECLARED_METHODS })
Here, we say that we want reflective access over the constructors and methods of ReflectionRunner.Customer
. There are other TypeAccess
values for different kinds of reflection.
The second ApplicationRunner, ResourceRunner,
loads a file from the .jar
of one of the dependencies on the classpath. It won’t work and will give you a "java.lang.IllegalArgumentException: the file must exist" error! The reason is that this resource lives in some other .jar
, not our application code. Loading this resource would work if it lived in src/main/resources
. You can use the @ResourceHint
annotation to make it work. Add the following to the ExtensionsApplication
class:
@ResourceHint(patterns = "Log4j-charsets.properties", isBundle = false)
The third ApplicationRunner, ProxyRunner
, creates a JDK proxy. Proxies create subclasses or implementations of types. Spring knows about two kinds: JDK proxies and AOT proxies. JDK proxies are limited to interfaces with Java’s java.lang.reflect.Proxy
. AOT proxies are a Spring-ism, not part of the JRE. These JDK proxies are typically subclasses of a given concrete class and may include interfaces. Native Image needs to know which interfaces and concrete classes your proxy will use.
Go ahead and compile the third application into a native executable. Native Image will give you the friendly error message "com.oracle.svm.core.jdk.UnsupportedFeatureError: Proxy class defined by interfaces" again and list all the interfaces Spring is trying to proxy. Note those types: com.example.extensions.ProxyRunner.Animal, org.springframework.aop.SpringProxy, org.springframework.aop.framework.Advised
, and org.springframework.core.DecoratingProxy
. We’ll use them to craft the following hint for the ExtensionsApplication
class:
@JdkProxyHint(types = {
com.example.extensions.ProxyRunner.Animal.class,
org.springframework.aop.SpringProxy.class,
org.springframework.aop.framework.Advised.class,
org.springframework.core.DecoratingProxy.class
})
Everything should work flawlessly now if you build and run the example: mvn -DskipTests -Pnative clean package
and run ./target/extensions
.
Build Time and Run Time Processors
Spring’s got a lot of Processor
implementations. Spring Native brings some new Processor
interfaces that are active only at build time. They dynamically contribute hints to the build. Ideally, these processor implementations will live in a reusable library. Go to the Spring Initializr, name the project processors, and add Spring Native
. Open the generated project in your IDE and delete all the Maven plugin configuration by removing the build
node from your pom.xml. You’ll need to manually add a new library:
<dependency>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-aot</artifactId>
<version>${spring-native.version}</version>
<scope>provided</scope>
</dependency>
This Maven build produces a regular Java `.jar` artifact. You can install and deploy it as you would with any Maven `.jar`: mvn -DskipTests clean install
This new library introduces new types, including:
BeanFactoryNativeConfigurationProcessor
: used when you want a build time equivalent toBeanFactoryPostProcessor
BeanNativeConfigurationProcessor:
used when you want a build time equivalent toBeanPostProcessor
I find myself working in these two interfaces most of the time. In each interface, you receive a reference to a thing you can inspect and a reference to a registry that you can use to contribute hints programmatically. If you’re using the BeanNativeConfigurationProcessor
, you receive an instance of bean metadata for a bean in the bean factory. If you’re using BeanFactoryNativeConfigurationProcessor
, you receive a reference to the entire prospective BeanFactory
itself. Beware: you must take care to only work with bean names and BeanDefinition
instances, never with the actual beans. The BeanFactory
knows about all the objects that will exist at runtime, but it doesn’t instantiate them. Instead, it is there to help us understand the shape - the classes, methods, etc. - of the running application to derive appropriate hints.
You register these Processor
types not as regular Spring beans but in the spring.factories
service loader. So, given an implementation of BeanFactoryNativeConfigurationProcessor
called com.example.nativex.MyBeanFactoryNativeConfigurationProcessor
, and an implementation of BeanNativeConfigurationProcessor
called com.example.nativex.MyBeanNativeConfigurationProcessor
, the spring.factories
file might look like this:
org.springframework.aot.context.bootstrap.generator.infrastructure.nativex.BeanFactoryNativeConfigurationProcessor=\
com.example.nativex.MyBeanFactoryNativeConfigurationProcessor
org.springframework.aot.context.bootstrap.generator.infrastructure.nativex.BeanNativeConfigurationProcessor=\
com.example.nativex.MyBeanNativeConfigurationProcessor
These Processor types make it easier to consume your integration or library in their Spring Native applications. I’ve written a library (com.joshlong:hints:0.0.1) full of various integrations (Kubernetes Java client, the Fabric8 Kubernetes Java client, Spring GraphQL, Liquibase, etc.) that don’t quite fit in the official Spring Native release. It’s a hodgepodge at the moment, but the result is cool: just add things to your classpath, much like Spring Boot autoconfiguration, and you get an awesome result!
Next Step
I hope you’ve gotten something out of this brief introduction to native executables with Spring Native. Stay tuned to the Spring blog and my Twitter (@starbuxman) for more!
This article is part of the article series "Native Compilations Boosts Java". You can subscribe to receive notifications about new articles in this series via RSS. Java dominates enterprise applications. But in the cloud, Java is more expensive than some competitors. Native compilation with GraalVM makes Java in the cloud cheaper: It creates applications that start much faster and use less memory. So native compilation raises many questions for all Java users: How does native Java change development? When should we switch to native Java? When should we not? And what framework should we use for native Java? This series will provide answers to these questions. |