In this fourth article in the series on the Micronaut framework the benefits of building Micronaut applications as native images is covered. The approach to building the companion application in both its flavours of Kotlin and Java into native images will be explored. The images will be compiled using GraalVM, and an overview and benefits of GraalVM is provided.
The source code for the companion application, in Kotlin and Java, is available here: [Kotlin version | Java version].
This is the fourth article in a six part series on the Micronaut framework.
Java Virtual Machine (JVM) based applications (including Kotlin and Java) can be compiled into native images, which is the transformation of Java bytecode into machine code. This results in a native binary that can be executed directly by the operating system without requiring a JVM at runtime. Native images exhibit significant improvements in startup time, memory usage, and overall performance.
The compiled native image only includes the necessary components of the application and its dependencies. As they only contain the code and data needed at runtime they have a smaller footprint, and typically consume less memory compared to traditional JVM-based applications. This makes native images a great choice for cloud deployments where resource efficiency translates to significant cost savings.
With fewer runtime components in the executable there is a reduced attack surface. Additionally as there is no JVM, potential vulnerabilities associated with the JVM are eliminated. These factors lead to an improved security of a native application over the traditional JVM-based applications.
Native images do not require JVM initialization or just-in-time (JIT) compilation, and so can start almost instantaneously. Applications such as serverless functions, microservices and command-line tools in particular are therefore a great fit for executing as native images.
GraalVM was developed by Oracle, initially released in 2018, as a high-performance runtime to enhance the efficiency of Java-based applications. As well as a sophisticated JIT compiler that results in significant performance improvements, it offers an ahead-of-time (AOT) compiler which is used to build the native executables.
The compilation process begins with the source code being compiled into an intermediate representation, Java bytecode. The GraalVM Native Image tool analyses the bytecode and performs global optimisations across the method and class boundaries to optimise the code. This is then translated into the machine code that is specific to the target operating system and architecture. High-level operations are translated into low-level instructions that the CPU can execute directly. At this point the compiler links the code with necessary runtime components and libraries, ensuring all the required functionality is included. The compiled code is then packaged into the final executable, containing everything needed to run the application, eliminating the need for a separate runtime environment like the JVM.
The Micronaut framework is designed with AOT compilation from the ground up, hence making it an excellent choice for applications that need the benefits provided by GraalVM's native image capabilities. The pertinent features of the framework are its minimal use of reflection, AOT dependency injection, aspect-oriented programming (AOP) handling, and approach to configuration and metadata. These features align with GraalVM's requirements for native image generation.
Applications that require reflection are a challenge for AOT compilation due to its dynamic nature, as programs inspect and modify their own structure and behaviour at runtime. AOT requires all types, methods and fields to be known at compile time in order to generate efficient machine code. The AOT compiler needs to analyse and include all code that might be used during compilation, but reflection means that code can be referenced that is not explicitly visible at compile time. A reflective call might create instances of classes or access methods that the compiler cannot see during compilation.
An AOT compiler would not be able to make assumptions about the code in order to optimise performance, as reflection introduces unpredictability due to its dynamic nature. Reflection can also bypass normal access controls, allowing code to interact with private fields and methods, which again complicate the AOT process. Reflective operations can lead to significant runtime overhead as the program has to look up metadata about the classes, methods and fields at runtime, making the execution less efficient, which is at odds with the goals of native executables.
Micronaut minimises the use of reflection by leveraging static compile-time dependency injection. This means that dependency injection logic is resolved during compilation rather than at runtime. It uses annotation processors to generate the necessary metadata and bytecode for dependency injection at compile time, avoiding the need for runtime reflection. Micronaut's Bean Context is built using static analysis of the source code rather than via reflection or runtime proxies.
AOP separates cross-cutting concerns such as logging, security and transaction management from the main business logic of the application. Micronaut supports AOP and ensures its implementation is compatible with AOT compilation. While traditional AOP frameworks use runtime proxies and dynamic weaving at runtime, Micronaut performs AOP weaving at compile time. Aspects are applied to the target classes during compilation. Annotation processors handle AOP annotations at compile time, with the compiler generating the necessary proxy classes and applying the advice logic statically.
Micronaut can process configuration at compile time. This includes reading and validating configuration properties, which are embedded into the generated classes. Micronaut avoids dynamic class loading with all necessary classes determined and loaded at compile time.
Reflection can be used with AOT compilation if required. Explicit configuration must be included in the native image build process in the form of a reflection configuration json file, typically named reflect-config.json
. This file specifies which classes, fields, methods and constructors should be available for reflection, and ensures that the necessary metadata is available at runtime.
There are downsides to using reflection with AOT compilation however. The manual configuration adds extra steps to the build, and greater complexity in the development process. It requires the developer to understand which parts of their code use reflection and all the necessary configuration is in place. There is more onus placed on testing, as if not all reflective calls are captured, then less common code paths could fail with unexpected runtime errors. There is also more overhead on the maintenance of the reflection configurations keeping these in sync with an evolving codebase.
There can be some impact to performance, albeit typically slight, with reflection metadata increasing the size of the native image and affecting startup time and memory usage. Reflective calls are typically slower than direct method calls, so extensive reflection could introduce runtime overhead. From a security perspective, the inclusion of reflection metadata can increase the attack surface of the application, with reflection exposing more entry points for vulnerabilities. Reflection also limits the optimisations that AOT compilation produces as the compiler needs to accommodate the dynamic nature of these calls.
To compile the Kotlin or Java applications into native images using GraalVM that will be executable without a JVM, the following commands are run:
./gradlew clean test
./gradlew nativeCompile
The nativeCompile
task will take longer than the standard compile
, as it performs a more comprehensive analysis, optimization and code generation process to produce the highly optimised native executable. The next task then runs the native executable file that has been generated:
./gradlew nativeRun
The same curl
commands that were used to exercise the application endpoints in the second article in the series Micronaut: Demo Application, can now be run to perform CRUD operations on item
entities. For example, an item can be created with the following command:
curl -i -X POST localhost:9001/v1/items -H "Content-Type: application/json" -d '{"name": "test-item"}'
HTTP/1.1 201 Created
location: 264aaf74-80c3-4f4c-9976-cf45923533b5
date: Sat, 25 May 2024 12:59:21 GMT
content-length: 0
In the third article in the series, Micronaut: Testing, component testing the application was covered. This involved building a Docker image with the command as the first step:
docker build -t ct/micronaut-rest-kotlin:latest .
In order to run the component tests against the native executable, the Docker image needs to therefore contain the native executable. This requires configuring the Dockerfile, compiling the executable, and packaging it into the Docker image. The following Gradle task performs these steps:
./gradlew dockerBuildNative
It makes use of two custom tasks in the build.gradle
[Kotlin | Java] to achieve these steps:
tasks {
dockerfileNative {
baseImage.set("ghcr.io/graalvm/native-image-community:21")
jdkVersion.set("21")
}
dockerBuildNative {
images.add("ct/micronaut-rest-kotlin")
}
}
The dockerfileNative
task specifies the configuration for creating the Dockerfile, which is used by the dockerBuildNative
task to build and tag the image with the required name.
The component tests in EndToEndCT
[Kotlin | Java], can now be run using this native executable as described in the third article in the series, Micronaut: Testing, by running the following command:
./gradlew componentTest --rerun-tasks
Component testing a native executable is vital as it is the only layer of testing that proves that everything that is needed in the executable for the application to run correctly is indeed present.
Building native images with Micronaut and GraalVM can significantly enhance application performance, provide faster startup times, reduce memory usage, and enhance security. These advantages make native images particularly well-suited for modern cloud deployments, where efficiency and cost savings are vital. Micronaut's design, minimising reflection and utilising compile-time dependency injection, align perfectly with GraalVM's requirements for native image generation, enabling the creation of highly optimised native applications.
There are two flavours of the Micronaut application, one in Kotlin and one in Java, and the source code is available here:
Kotlin version: https://github.com/lydtechconsulting/micronaut-rest-kotlin/tree/v1.0.0
Java version: https://github.com/lydtechconsulting/micronaut-rest-java/tree/v1.0.0
In the fifth article in the series (coming soon) the example Micronaut applications will be enhanced to integrate with a Postgres database backing store.
View this article on our Medium Publication.