class Application {
public static void main(String... args) {
Micronaut.build(args)
.environmentPropertySource(false)
.mainClass(Application.class)
.start();
}
}
Micronaut AOT
Build time optimizations for Micronaut
Version: 3.2.0-SNAPSHOT
1 Introduction
| Unlike other Micronaut modules, Micronaut AOT is not a dependency which needs to be added to your application: it’s a framework which requires integration with your build process: it is typically aimed at being integrated into build tool plugins. If you are looking into documentation about how to configure Micronaut AOT, you should look into the corresponding build tool documentation. Micronaut AOT support is implemented by the Micronaut Gradle plugin and Micronaut Maven plugin. |
Micronaut AOT is a framework which implements ahead-of-time (AOT) optimizations for Micronaut application and libraries. Those optimizations consist of computing at build time things which would normally be done at runtime. This includes, but is not limited to:
-
pre-parsing configuration files (yaml, properties, …)
-
pre-computing bean requirements in order to reduce startup time
-
performing substitution of classes with "optimized" versions for a particular environment
Micronaut AOT can generate specific optimizations for different environments, in particular, it can make a difference between optimizations needed in JIT mode (traditional JVM applications) and native mode (application compiled with GraalVM native-image).
2 Release History
For this project, you can find a list of releases (with release notes) here:
3 Quick Start
Applicability
The goal of Micronaut AOT is to create an optimized "binary" for a particular deployment environment. It is not to make development experience faster: because build time optimizations require deep analysis of the application, it will actually make the local development slower if you use optimized binaries.
You should consider Micronaut AOT similarly to what GraalVM’s native-image tool is: a "compiler" which generates a different application, aimed at a particular runtime. That is, depending on the optimizations which are enabled, a binary optimized by AOT may, or may not, work in a specific deployment environment.
As a consequence, it is recommended to build your Micronaut AOT optimized application on the same environment as the deployment one. Please refer to the documentation of your build tool for configuration options.
The following documentation is for users who want to implement their own AOT optimizers.
Micronaut AOT projects
The Micronaut AOT project consists of 5 main modules:
-
micronaut-aot-coreprovides the APIs for implementing "AOT optimizers", or code generators. -
micronaut-aot-apiexposes the public API for interacting with the AOT compiler. It mostly consists of theMicronautAotOptimizerclass which is responsible for loading the different AOT modules via service loading, then driving the AOT process. -
micronaut-aot-std-optimizersimplements a number of standard Micronaut AOT optimizers. -
micronaut-aot-cliis the command line tool responsible for invoking the AOT compiler. It is recommended to integrate with Micronaut AOT via this CLI to ensure proper classloader isolation. -
micronaut-aot-logbackcompiles an application’slogback.xmlinto a LogbackConfiguratorat build time. The Micronaut build plugins call it; its API is internal to them. It is independent of the other modules and of the AOT compiler: see Precompiling logback.xml.
How it works
Optimization process
Micronaut AOT is a post-processing tool. By that, we mean that it takes the output of regular Micronaut application compilation, then performs an analysis of this and generates new classes, resources, etc. which are then used to create a new binary (jar, native binary, …).
In a nutshell, the inputs of Micronaut AOT are:
-
the application runtime classpath
-
the Micronaut AOT runtime (including the AOT optimizers)
-
the AOT optimizer configuration (including the target runtime, e.g JIT vs native)
And its outputs are:
-
generated source files and their compiled (
.class) versions -
generated resource files
-
a list of resources which should be removed from the final binary (for example, if a YAML file is replaced with Java configuration, a class would be generated, but then we know we don’t need the YAML file anymore in the final binary)
-
log files (to diagnose what happened during the AOT process)
The MicronautAotOptimizer class is a special case of code generator which integrates the dynamically loaded AOT optimizers, and generates an ApplicationContextConfigurer which will initialize the optimizations.
It’s then the responsibility of integrators to take those outputs to generate different binaries.
User-code loading
In order to perform optimizations, so called optimizers (or AOT modules) are used. Those modules need access to the application context, so that they can, for example, determine if a bean is going to be needed in a particular deployment environment. Or, they may need access to the configuration which is dynamically loaded from an external source (think of distributed configuration) to generate static configuration instead.
Therefore, the AOT compiler needs to be executed in the same classloader as the application code itself. This is why there are 2 different classpaths for the AOT compiler:
-
the application classpath corresponds to the application runtime classpath. It is, technically speaking, the result of previous compilation plus all transitive dependencies needed by the application (or library).
-
the AOT classpath, which is the classpath of the AOT compiler and the AOT optimizers.
The role of the application context configurers
Given the application classpath, the AOT compiler will instantiate an ApplicationContext, which will have all the context configurers (classes implementing the ApplicationContextConfigurer interface and annotated with @ContextConfigurer) applied automatically.
This is how the AOT compiler will "know" about particular application context customizations which can be done by a user.
Therefore, it is critical to understand that the AOT compiler cannot figure out arbitrary application context customizations. For example, in the following code:
there’s an environmentPropertySource() call which is opaque to the AOT compiler: it cannot know that the application is configured that way (for this it would actually have to start the application and perform runtime interception which would be too expensive or impossible).
Therefore, all customizations need to be done using a different pattern:
class Application {
@ContextConfigurer
public static class MyConfigurer implements ApplicationContextConfigurer {
@Override
public void configure(ApplicationContextBuilder context) {
context.environmentPropertySource(false);
}
}
public static void main(String... args) {
Micronaut.run(Application.class, args);
}
}
Because @ContextConfigurer makes sure that any application context created will see the customizer applied, the application context created by the AOT compiler for its internal use will see the customizations.
Implementing an AOT optimizer
Current capabilities
Now that we understand how the AOT optimization environment is bootstrapped, we can start implementing an AOT optimizer.
An optimizer can do one or more of the following:
-
generate static initializers which will automatically be loaded thanks to the
ApplicationContextConfigurermechanism -
generate new source files
-
generate new resource files
-
perform substitutions of one class with another
-
filter out resources
New capabilities will be included as part of AOT development.
Code generators
At the core of AOT optimizations is a code generator.
A code generator needs to implement the AOTCodeGenerator interface and be annotated with @AOTModule.
The implementation class should be compiled from the regular Java source root of an AOT optimizer module, for example src/main/java.
Register the generator with Java’s ServiceLoader mechanism by adding its fully qualified class name to src/main/resources/META-INF/services/io.micronaut.aot.core.AOTCodeGenerator.
Build tool integrations load optimizer modules from the AOT optimizer classpath; for example, Gradle users can add the optimizer artifact or project dependency to the aotPlugins configuration.
The AOTModule annotation is responsible for giving metadata about the code generators, including:
-
an
idis used to identify the code generator, and enable/disable it via configuration -
a number of options (
@Option) which are used to describe the parameters that the code generator takes (those are provided via configuration) -
possibly dependencies to other code generators (for example, some code generators may only work properly if they execute after another one)
-
the target runtimes it applies to
Code generators contribute code via the AOTContext interface, which allows:
-
getting the name of the package of generated classes
-
registering generated code (source files, …)
-
getting access to the
ApplicationContext -
sharing state
-
getting access to target runtime
For example, a simple code generator which generates a resource file may be declared as:
@AOTModule(
id = MyResourceGenerator.ID,
options = {
@Option(key = "greeter.message", sampleValue = "Hello, world!", description = "The message to write")
}
)
public class MyResourceGenerator implements AOTCodeGenerator {
public static final String ID = "my.resource.generator";
@Override
public void generate(AOTContext context) {
context.registerGeneratedResource("hello.txt", file -> {
String message = context.getConfiguration()
.mandatoryValue("greeter.message");
try {
java.nio.file.Files.writeString(file.toPath(), message);
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
});
}
}
Then in a configuration file, the code generator would be configured this way:
my.resource.generator.enabled=true
greeter.message=Hello, world!
| Different code generators may share the same option values: it is legal, but often simply required (for example if there’s a different implementation of a specific optimization based on the target runtime). |
4 Standard Optimizers
Available Optimizers
Micronaut AOT includes standard optimizers that can be enabled or disabled in the Micronaut AOT configuration.
Each optimizer is identified by its module id.
To enable an optimizer, set <module id>.enabled=true.
| These are not configuration properties to add in your regular Micronaut configuration files, but properties to be added to the Micronaut AOT configuration, via your build plugin. Please refer to the appropriate Maven or Gradle plugin for more details. |
Module cached.environment
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Caches environment property values: environment properties will be deemed immutable after application startup |
|
Module deduce.environment
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Deduces the environment at build time instead of runtime |
|
Module known.missing.types
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Checks for the existence of some types at build time instead of runtime |
|
|
A list of types that the AOT analyzer needs to check for existence (comma separated) |
|
Module logback.xml.to.java
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Replaces logback.xml with a pure Java configuration (Experimental) |
|
Module netty.properties
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Defines some Netty system properties when starting the application which optimize startup times |
|
|
The machine id used by Netty. By default, generates a random value at runtime. Set it to a fixed MAC address to override, or use the value 'netty' to disable the optimization and get it at runtime. |
|
|
The process id to use for Netty. Defaults to a random PID at runtime. Set it to a fixed value (not recommended) or use the value 'netty' to disable the optimization and get it at runtime. |
|
Module precompute.environment.properties
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Precomputes Micronaut configuration property keys from the current environment variables |
|
Module property-source-loader.generate
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Converts configuration files supplied by property source loaders to Java configuration |
|
|
The PropertySourceLoader classnames to use for generating property sources |
|
|
The base order to use for the generated property sources. Positive value will be added to base order for specific environments |
|
|
The resource names to generate property sources for. By default, it is 'application,bootstrap'. |
|
|
Whether the property source loaders specified by types should be excluded in service loading |
|
|
Deprecated option to enable the yaml property source generation. Use property-source-loader.types=io.micronaut.context.env.yaml.YamlPropertySourceLoader instead |
|
Module scan.reactive.types
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Scans reactive types at build time instead of runtime |
|
Module sealed.property.source
This module is available in JIT and native modes.
Property |
Description |
Example value |
|
Enables this optimization: Precomputes property sources at build time |
|
Module graalvm.config
This module is available in native mode.
Property |
Description |
Example value |
|
Enables this optimization: Generates GraalVM configuration files required to load the AOT optimizations |
|
|
The list of service types to be scanned (comma separated) |
|
5 Precompiling logback.xml
The micronaut-aot-logback module compiles an application’s logback.xml into a Logback Configurator at build time.
Without a Configurator service, Logback parses logback.xml with its Joran framework on the main thread at every start.
An application that carries the generated configurator does neither.
| This module is internal to the Micronaut build plugins: its API, the names it generates and its rules may change in any release. |
Unlike the logback.xml.to.java optimizer, this module is not an AOT optimizer.
It does not start the application, uses no AOTContext, and needs no AOT run: the Micronaut Gradle and Maven plugins and Micronaut Runner call it in-process with plain paths and get class files back.
Its artifact, io.micronaut.aot:micronaut-aot-logback, has no dependencies.
An application does not depend on it: its build plugin calls it.
The engine fails closed.
Whenever it cannot prove that the generated code builds exactly the LoggerContext Joran builds, it generates nothing and says why, and Logback reads logback.xml at startup as it always did.
It also stands down when a Configurator is already registered on the class path it is given, which is the case for an application that logback.xml.to.java has already optimized.
It cannot see an optimizer that runs later, on an application that already carries its output: a build that applies both has to decide which one configures Logback.
How the build plugins call it
This section describes what the Micronaut build plugins do, for those who maintain them.
LogbackPrecompiler is @Internal: it is not an API for applications or for other build tools.
LogbackPrecompiler.Result result = LogbackPrecompiler.precompile(
LogbackPrecompiler.Request.builder()
.applicationOutput(List.of(processedResources, classesDirectory))
.runtimeClasspath(runtimeClasspath)
.targetRelease(25)
.build());
log.accept(result.message());
result.warnings().forEach(warn);
for (Map.Entry<String, byte[]> entry : result.entries().entrySet()) {
Path file = outputDirectory.resolve(entry.getKey());
Files.createDirectories(file.getParent());
Files.write(file, entry.getValue());
}
The inputs are:
| Input | Meaning |
|---|---|
|
The application’s own output in class path order: its resource roots and its class directories, or a jar. Pass the class directories as well as the resources: the engine stands down when the application carries a Logback or SLF4J class, or a |
|
The runtime class path after the application, in order: jars or directories. The engine takes logback-classic, logback-core and slf4j-api from here, never from the caller’s own class path. |
|
The lowest Java release the application runs on. The generated classes are class files of Java 25, so the engine stands down below 25. |
|
|
The Result has a status(), one message() for the caller to log, the generated entries(), and warnings():
-
GENERATED: the message says what was compiled. The plugin writes the entries below a directory, which has to be on the application’s class path next to itslogback.xml, or a packager adds them to an archive. All of them go to the same place: the configurator calls the classes copied next to it, which are package-private, so they have to be on the same class loader. -
STOOD_DOWN: nothing was generated, and the message names the reason. This is expected for many applications. -
FAILED: nothing was generated because a generated name is already taken, the output of an earlier call is still in the application output, the class path could not be read, or the engine failed. The message is worth a warning.
warnings() holds lines that the plugin logs at warning level, each on its own, after the message.
Only a GENERATED result can have any, and most have none: the one case today is a packaged file that may enable distributed configuration (see Locations only Micronaut sees).
The list is never null.
precompile never throws for anything about its inputs, so a build that calls it does not fail because of Logback.
Removing the output of an earlier build is the caller’s job.
A caller that writes into one of the application roots it passes, such as the class directory, has to remove that output before it calls precompile again, not after. While it is there the result is FAILED and the earlier output stays in use, whatever has changed in the application since.
The output is the service file and the classes of the package io.micronaut.aot.logback.generated.
logback.xml has to stay in the application, because the fallbacks below read it.
LogbackPrecompiler.applicationInputs() returns, as Ant-style patterns, the entries of the application output that can change the result.
A build plugin may declare its inputs with them, so that the step stays up to date when ordinary classes and resources change.
Of everything else in the application output the engine reads the name and nothing more.
On the runtime class path it reads the names of every entry, the manifests of logback-classic and logback-core, and the packaged application* and bootstrap* configuration files of every entry.
The engine never loads application classes.
It reads logback.xml with Logback’s own parser and model builder, taken from the application’s Logback jars, in an isolated class loader.
It instantiates only the ch.qos.logback classes the file names, and never starts an appender.
What it generates
Three classes in the package io.micronaut.aot.logback.generated and one service file:
-
LogbackConfigurator, the configurator, as a class file of Java 25; -
JoranFallback, which every path that hands over to Joran goes through, so that the literal path never loads Joran; -
ClassPathGuard, unless the class path is closed; -
META-INF/services/ch.qos.logback.classic.spi.Configurator.
The subset
The engine compiles a logback.xml made of:
-
appenders of public
ch.qos.logbackclasses withString,booleanorintproperties and at most onePatternLayoutEncoderwith a pattern; -
loggers, the root logger and appender references.
Everything else stands the engine down, and the message names the element: variables, <property>, <include>, <if>, <conversionRule>, filters, rolling policies, custom appenders and a <configuration> with debug, scan or packagingData on.
So does what Joran itself corrects: two appenders with the same <file>, a property element without text, a <name> element inside an appender.
The logback.xml that Micronaut Launch generates is inside the subset.
When it stands down
Besides a file outside the subset, the engine generates nothing when:
-
targetReleaseis below 25; -
the application output carries a Logback or SLF4J class of its own;
-
logback-classic, logback-core or slf4j-api is missing, a Logback jar declares no
Implementation-Version, the two versions differ, or the version is outside the tested range, which is[1.5.37, 1.6); -
there is no
logback.xml, or more than one class path entry or more than one application root has one, which Logback reports at startup; -
any class path entry has a
logback-test.xml, alogback.groovyor a versioned Logback file underMETA-INF/versions/; -
any entry of the class path it is given already registers a
Configurator; -
a packaged
application*orbootstrap*configuration file, at the root or underconfig/of an application root or of any entry of the runtime class path, may setlogger.configorlogback.configurationFile, or may import configuration withmicronaut.config.import. Micronaut applies such a location only without aConfiguratorservice, and the generated code cannot see it (see Locations only Micronaut sees). An import can load a class path resource of any name, a file, an environment variable or what a custom provider supplies, and what it loads can set the location or import more. The engine does not follow imports, so it stands down on any import, whatever it names. Every such file is read, also one that an earlier entry has under the same name: Micronaut reads the file of a dependency when the application has none of that name, and it can merge the files of every entry, depending on its configuration loading strategy. The names are wider than what Micronaut reads by default, which isapplicationandbootstrap, with an optional environment suffix, at the root of the class path:config/is read only whenoverrideConfigLocationsadds a location such asclasspath:config/. A false positive only costs the optimisation.
One case of these same files does not stand the engine down but adds a warning to a GENERATED result: a file that may enable Micronaut’s distributed configuration client (see Locations only Micronaut sees).
What the configurator does at run time
On every call, in this order:
-
If the
logback.configurationFilesystem property is set, it runs Logback’s own default lookup. -
On a second or later call, which is Micronaut’s logging refresh, it configures from the first of these that is set: an environment variable named
logback.configurationFile, thelogger.configsystem property, theLOGGER_CONFIGenvironment variable, an environment variable namedlogger.config. -
If
logback.debugorlogback.statusListenerClassis set, or-Dmicronaut.logback.precompiled=false, it runs Logback’s default lookup. -
On an open class path, if a guard fails, it runs Logback’s default lookup.
-
Otherwise it applies the content of
logback.xmlliterally, in Joran’s order.
-Dmicronaut.logback.precompiled=false is the opt-out: with it every call hands over to Joran. It does not bring back a location that only Micronaut sees, described below.
Locations only Micronaut sees
Micronaut applies logger.config, or a logback.configurationFile of its own configuration, on its logging refresh. When any logger.* property is set, that refresh runs at startup and again on every refresh event.
Without a Configurator service it configures Logback from that location.
With one, it calls the service’s configure(LoggerContext) and passes it nothing else: the location stays in Micronaut’s LogbackLoggingSystem, and Micronaut copies it neither into a system property nor into the LoggerContext.
So the generated configurator applies a location only where it can read it itself, as the first two rules above list: the logback.configurationFile and logger.config system properties, and the logback.configurationFile, LOGGER_CONFIG and logger.config environment variables.
A location that comes from any other property source is not applied, and the compiled configuration stays in use:
-
a program argument, such as
java -jar app.jar --logger.config=custom.xml. A-Dlogger.config=custom.xmlwritten after the jar or the main class is a program argument too, not a system property: Micronaut keeps it in the property source of its command line. The configurator does not read the command line of the process instead, from thesun.java.commandproperty or fromProcessHandle.current().info().arguments(): the command line does not say which arguments the application passed to Micronaut, and it is only one of the sources listed here; -
a configuration file that no packaged file names:
config/application.ymlin the working directory; a file named by themicronaut.config.filessystem property or theMICRONAUT_CONFIG_FILESenvironment variable, on the file system or, with aclasspath:prefix, on the class path; a bootstrap configuration file named by themicronaut.bootstrap.namesystem property; -
configuration that
micronaut.config.importimports when one of the sources of this list declares the import, whatever it imports, a class path resource included; -
any other property source, such as distributed configuration or properties passed to
ApplicationContext.builder().
Pass the location as a JVM system property, java -Dlogger.config=custom.xml -jar app.jar, or as the LOGGER_CONFIG environment variable instead.
The opt-out does not help: the configurator then runs Logback’s default lookup, which reads logback.xml, because Micronaut still calls the configurator instead of reading the location.
From Micronaut 5.3, the refresh applies such a location itself, whatever Configurator is registered: see micronaut-core#13400.
The build can read the packaged application* and bootstrap* files, and none of the sources above.
So it stands down when one of those files might set the location, or might import configuration with micronaut.config.import, which the build does not follow, wherever the import points.
Distributed configuration is one of the sources the build cannot read: it cannot know what a configuration server will supply.
It can see, though, a packaged application* or bootstrap* file, of an application root or of a dependency, that may enable Micronaut’s distributed configuration client: one that sets micronaut.config-client.enabled to true or to any placeholder.
The engine then still generates the configurator, and the GENERATED result carries one warning for each such file.
The warning names the file and its root or jar, says that the file may enable the client, that a logger.config or logback.configurationFile that distributed configuration supplies is not applied while the generated configurator is registered, and gives the workarounds: pass the location as -Dlogger.config or LOGGER_CONFIG, as above, or turn precompilation off in the build plugin, so that no configurator is generated.
It also says that -Dmicronaut.logback.precompiled=false does not help, for the reason above.
A .properties file is parsed. In any other format the engine checks the text, ignoring case: the word config-client (or configClient) and an enabled key set to true, yes or on anywhere in the file give the warning.
In every format, a placeholder counts as well, quoted or not (in a .properties file quotes are part of the value, so only an unquoted one counts), whatever its default and also without one: ${CONFIG_CLIENT_ENABLED}, ${CONFIG_CLIENT_ENABLED:false} and ${CONFIG_CLIENT_ENABLED:true} all give the warning, and so does the escaped "\${CONFIG_CLIENT_ENABLED}" of a double-quoted Groovy string. Micronaut resolves a placeholder at run time, from sources the build cannot see, so it may resolve to true.
A false positive only costs a warning line. enabled: false, or a mention of config-client without an enabled key, gives none, unless another enabled key of the same file is set to true or to a placeholder: the text rule does not know which section a key belongs to.
A client that is enabled in any other way is not seen, and gives no warning: by an environment variable such as MICRONAUT_CONFIG_CLIENT_ENABLED, a system property or properties passed to ApplicationContext.builder(); by a file outside the application and its dependencies, such as one that micronaut.config.files names; by a bootstrap configuration file whose name starts with neither application nor bootstrap, which the micronaut.bootstrap.name system property selects; or by a value that a packaged Groovy file computes, such as enabled = System.getenv('CONFIG_CLIENT_ENABLED') ?: false.
Guards for open class paths
Outside a packaging that fixes the class path, the class path can change after the build: a later test run sees the same output directory, an IDE leaves stale output, a file is mounted into a container image.
So unless the caller sets closedClassPath(true), the configurator hands over to Joran when:
-
a
logback-test.xmlis visible, or -
the first visible
logback.xmlis missing, or differs from the compiled file in size or CRC-32.
It looks where Joran would, through the class loader that defines logback-classic, and it looks again on every call.
So a logback.xml that is edited while the application runs is read by Joran on the next logging refresh, as it would be without the generated configurator.
Each call costs two class loader lookups, one read of logback.xml and its CRC-32.
Logback calls the configurator once when it initialises, and Micronaut once per logging refresh, which it runs only when a logger.* property is set; a log call never runs the guards.
The first call costs a few milliseconds, most of it the class loader’s first lookups, which the application makes later anyway; every later call takes well under a millisecond.
The guards have limits. After any of these changes the generated configurator is still applied, although Joran would give another result:
-
A second
logback.xmlis added to the class path after the build. Logback would report it at startup. -
The class path changes in another way that the build checked. Nothing but the two guards is checked again at run time: not the Logback version, not a
Configuratorthat another entry registers, not a packagedlogger.configormicronaut.config.import. With a Logback outside the tested range, the generated classes are untested and may not even link.
Build again after such a change: the build then stands down, or compiles the class path as it now is.
Until then, -Dmicronaut.logback.precompiled=false gives Joran’s result back in two of these cases only: a second logback.xml, and another Logback version as long as the generated classes still load. Every call then runs Logback’s default lookup, which reads logback.xml.
So the opt-out does not help in the other cases. Where the generated configurator is called instead of a Configurator that another entry registers, it runs that lookup, not the other Configurator. A packaged logger.config, or one that a packaged import loads, stays unapplied, as a location only Micronaut sees (see Locations only Micronaut sees): pass it as a JVM system property or as LOGGER_CONFIG until the next build.
closedClassPath(true) is for a packager whose archive is the whole class path. The build-time checks then guarantee what the guards check, and ClassPathGuard is not generated.
6 Diagnostics Report
Micronaut AOT can write a structured diagnostics report for build tools and CI systems. The report is disabled by default and does not change generated sources, generated resources, or log files unless it is enabled.
Enable the report with the following configuration property:
micronaut.aot.report.enabled=true
When the CLI is used for an AOT run, the same behavior can be enabled with:
micronaut-aot --classpath app.jar --runtime jit --package example.aot --config aot.properties --output build/aot --report
The default output file is:
<output.directory>/reports/micronaut-aot-report.json
Use micronaut.aot.report.output or --report-output to select a different report directory.
Micronaut AOT writes fixed file names inside that directory.
The default report format is JSON. The default value is:
micronaut.aot.report.format=json
Set micronaut.aot.report.format=html or micronaut.aot.report.format=json,html to write an HTML report.
The CLI accepts the same values with --report-format.
The fixed output file names are:
-
micronaut-aot-report.json -
micronaut-aot-report.html
Unsupported formats fail the AOT run with a configuration error.
The JSON report uses a versioned schema. Version 1 includes:
-
schemaVersion -
generatedAt -
micronautVersion -
runtime -
packageName -
activeEnvironments -
optimizers, including optimizer id, implementation class, description, enabled state, disabled reason, option keys, dependency ids, and supported runtimes -
generatedSources -
generatedResources -
filteredResources -
excludedServiceImplementations -
buildTimeInitClasses -
diagnostics
Example:
{
"schemaVersion": 1,
"runtime": "JIT",
"packageName": "example.aot",
"optimizers": [
{
"id": "netty.properties",
"enabled": true,
"options": [
{
"key": "netty.machine.id",
"configured": true,
"redacted": true
}
]
}
],
"generatedSources": [
{
"className": "example.aot.AOTApplicationContextConfigurer",
"path": "example/aot/AOTApplicationContextConfigurer.java"
}
],
"generatedResources": [
"META-INF/services/io.micronaut.context.ApplicationContextConfigurer"
]
}
Configuration values are not written to the report. Optimizer options only show the option key, whether the key was configured, and whether the value was redacted. This prevents common secrets such as passwords, tokens, credentials, and datasource values from being copied into diagnostics artifacts by default.
Build-tool integrations can enable the report property for an AOT invocation and publish the printed report path or the JSON file as a build artifact.
When HTML is enabled, integrations can also publish micronaut-aot-report.html as a human-readable build artifact.
Existing log files remain available for detailed diagnostics.
7 Rewriting the Runtime Class Path
The micronaut-aot-bytecode module rewrites the class files of an application’s runtime class path at build time.
A build plugin gives it the class path and gets back a class path to package: each entry as it was, or a rewritten copy of it.
It has two steps, which a request enables separately and which run in one pass: desugaring lambdas (Desugaring lambdas), then stripping the local-variable tables of the third-party jars that the plugin names (Stripping local-variable tables).
| This module is internal to the Micronaut build plugins and Micronaut Runner, and its steps are experimental: its API, its rules and its output may change in any release. |
Unlike the optimizers of Micronaut AOT, this module does not start the application, uses no AOTContext, and needs no AOT run.
It reads class files and writes copies, and never loads a class of the class path.
Its artifact, io.micronaut.aot:micronaut-aot-bytecode, has no dependencies, and needs Java 25 to run, for the ClassFile API.
An application does not depend on it: its build plugin calls it.
How the build plugins call it
This section describes what a build plugin does, for those who maintain one.
ClassPathTransform is @Internal: it is not an API for applications.
ClassPathTransform.Result result = ClassPathTransform.run(
ClassPathTransform.Request.builder()
.classPath(runtimeClasspath)
.outputDirectory(outputDirectory)
.desugarLambdas(true)
.stripLocalVariables(thirdPartyJars)
.build());
result.summary().lines().forEach(log);
result.warnings().forEach(warn);
List<String> report = new ArrayList<>();
for (ClassPathTransform.Result.Entry entry : result.entries()) {
report.add("%s\t%d\t%d\t%d\t%d\t%d\t%d\t%s\t%s".formatted(entry.path(), entry.classesStripped(),
entry.classesUnchanged(), entry.fallbacks(), entry.bytesSaved(), entry.sitesRewritten(),
entry.classesGenerated(), entry.sitesLeft(), entry.kept().orElse("")));
report.addAll(entry.notes());
}
Files.write(reportFile, report);
List<Path> packaged = result.classPath();
The request takes:
| Input | Meaning |
|---|---|
|
The runtime class path, in the order the application’s class loader searches it, the application’s own output first: jars or directories. Required. Every class on it is part of the model that a rewritten class is verified against, so pass the whole class path, not only the jars to rewrite. |
|
Where the rewritten copies go. Required. A copy, of a jar or of a directory, is written under a subdirectory named by the index of its entry in |
|
Whether to desugar the lambda call sites of every entry, jars and directories, the application’s own classes included. Default |
|
Packages, with their subpackages, whose classes the runtime may resolve from somewhere other than |
|
The jars whose classes are stripped: third-party jars only, each an entry of |
|
The number of worker threads, by default the available processors. The output does not depend on it. |
The Result has:
-
classPath(): the class path to package, in the order of the request. An entry with a rewritten class is replaced by its copy, which keeps the entry’s file name, so theClass-Pathattribute of a manifest that names it stays valid. Every other entry is returned as it was given, and keeps its bytes and its hash. -
summary(): one line per step to log, desugaring first, such asDesugared 2960 lambda call sites into 1978 generated classes in 37 class path entries (815 classes rewritten, 360 bridges, 33 sites left as invokedynamic, 0 nest fallbacks)andStripped local-variable tables from 7102 of 8428 dependency classes in 49 jars (4868965 bytes saved, 0 fallbacks). -
warnings(): lines to log as warnings, each on its own. Usually empty, nevernull. -
entries(): what the steps did to each jar named bystripLocalVariables, and to each entry that holds a lambda call site when lambdas are desugared, in class-path order, for the plugin to write under the build’s reports in its own format. The counts are numbers, so nothing needs parsing. EachEntryhas:-
path(): the jar or directory, asclassPathgave it; -
classesStripped(),classesUnchanged()andfallbacks(): the jar’s classes that lost their tables, that were left as they were, and that were written as they were because the step failed on them or their rewrite verified worse. Each class of a jar named for stripping counts once; -
bytesSaved(): how many bytes smaller the stripped classes became, uncompressed; -
sitesRewritten(),classesDesugared(),classesGenerated(),bridges()andnestFallbacks(): the lambda call sites rewritten, the existing classes rewritten, the classes generated, the bridges added and the nests left as they were; -
sitesLeft(): the lambda call sites left as they were, by reason, such asaltMetafactoryorshadowedOrUncertain, for display; -
kept(): why no class of the entry went through the steps, such asthe jar is signed, or empty; -
notes(): one line per fallback, tab-separated: the entry, the class or the nest, the step and the first verification error or failure.
-
run throws only when it cannot read a jar it rewrites, or cannot write a copy.
A class that it cannot parse, rewrite or verify is written as it was, and a note of its jar’s entry names it.
The module logs nothing.
A copy holds every entry of the original jar, in the same order, with the same name, comment, time and compression method, and the manifest as it was.
Only the classes that a step rewrote change, and each generated class follows its host, with its host’s time and compression method; every other entry keeps its content.
DEFLATED entries are compressed again, so the copy’s compressed bytes depend on the JDK that writes it.
A signed jar, a jar that holds an entry name twice, and a directory that holds a symbolic link are left as they are.
A directory’s copy holds every file and directory of the original, with its modification time.
The rewritten bytes depend on the JDK build that runs the transform, for example through the order of the constant pool. The output is reproducible, whatever the parallelism, only with the same JDK build. A plugin therefore runs the transform on the project’s Java toolchain and makes the toolchain’s version an input of its task.
The transform belongs to packaging, not to development. A plugin applies it to fat JARs and Docker images, before Shadow or Shade merge the class path and before anything that records the jars' sizes or modification times, such as the training of a JDK AOT cache. It does not apply it to run tasks, tests, IDE class paths or native images, which do not package the class path or do not load class files at run time.
Desugaring lambdas
Every lambda and method reference compiles to an invokedynamic call site.
The first time one runs, LambdaMetafactory spins a hidden class for it, defines it and links the site.
Only a trained JDK AOT cache spares a start that work, so a fat JAR started with java -jar, or an image without a cache, pays it for every site that startup reaches, most of them in the dependencies.
The step generates those classes at build time, next to the class that holds the site, and points the site at them.
With a JDK AOT cache, the step makes about no difference.
The sites of one class that share the functional interface, the interface method and the captured types share generated classes, <Host>$$Lambda$R<n>, each serving as many of them as keep its interface method within HotSpot’s default inlining size (FreqInlineSize, 325 bytes), dispatching on a small tag.
A class whose version is below Java 11 has no nestmates: its private lambda bodies are reached through static synthetic bridges, $desugared$lambda$<n>, added to it.
A site is rewritten only when the generated class provably resolves what the site resolved, and each rewritten class, and each generated one, is verified as stripped classes are. These stay as they are:
-
serializable and marker-interface lambdas (
altMetafactory), and every other bootstrap, such as string concatenation; -
the classes of a signed jar, the
META-INF/versions/variants of a multi-release jar, andmodule-info; -
a site whose class, nest host, implementation, functional interface or captured receiver has a name that two entries hold, or that has a
META-INF/versions/copy anywhere, because a fat JAR or an image does not tell which copy loads; or a name in aforeignPackagespackage, or in a package of the JDK that the class path also holds; -
a private implementation in a Java 8 interface, or in a serializable Java 8 class other than an enum without a
static final serialVersionUID, whose bridge would be public or would change the defaultserialVersionUID; -
a site whose functional interface, or one of its superinterfaces, has a default method and a static initializer that does more than create its own lambdas and instances of classes whose constructors do nothing: the JVM runs that initializer before it initializes the generated class, and the initializer may reach the site again, on the same thread or another, before the class is ready;
-
a site whose functional interface is serializable without
altMetafactory, whose lambda refuses to be serialized.
What changes for the application, and what to document for users:
-
The lambda classes are no longer hidden: they have stable names,
Class.forNamefinds them,Class.getNestMembers()lists them, and class path scanners and Java agents see them as ordinary, synthetic, package-private classes. Their names keep$$Lambda, which agents such as OpenTelemetry’s and Datadog’s recognize. Reflection sees ordinary members: a canonical name, fieldsf0… instead ofarg$1…, a staticcreatemethod, and, for a Java 8 class, no nest host. -
Two lambdas of one class with the same functional interface, method and captured types may have the same
getClass(). The Java Language Specification allows it. -
Each call through a lambda adds one frame to stack traces and to
StackWalker, two in a Java 8 class whose lambda body is reached through a bridge, and neither has a line number, so a method reached through a method reference that looks up its caller sees the generated class. -
Java 8 classes gain bridges, which
getDeclaredMethods()returns, and jars gain.classentries. -
A rewritten jar is a modified third-party binary: it no longer has the published artifact’s hashes, which scanners such as Snyk Container use to identify it, and licence-compliance processes may need to know. A jar without a lambda call site keeps its bytes.
-
The JDK rules are judged against the JDK that runs the transform: run it on the feature release the application runs on, with a runtime that has the JDK modules the application uses.
Stripping local-variable tables
Compilers write the names and types of local variables into class files, for debuggers.
At run time, only debuggers, the variable names in helpful NullPointerException messages and a few libraries that parse class files read them.
Without a JDK AOT cache, the JVM still parses them, and the constant-pool entries that only they use, for every class it loads.
The step removes them from the classes of the jars named by stripLocalVariables.
The jars get somewhat smaller, and a start without a JDK AOT cache slightly faster.
With a JDK AOT cache, which holds classes already parsed, the step makes no difference worth having, and it is off unless a plugin turns it on.
The step drops:
-
LocalVariableTable,LocalVariableTypeTableandCharacterRangeTable; -
the type annotations of method code, which hold bytecode offsets that a rebuilt constant pool can move;
-
RuntimeInvisibleTypeAnnotationson classes, fields and methods, which nothing reads at run time.
It then rebuilds the constant pool, which removes the names and signatures of the locals: dropping the attributes alone would save almost nothing.
It keeps LineNumberTable, SourceFile, SourceDebugExtension, MethodParameters, Signature and every other annotation.
Stack traces keep their File.java:N frames, and reflection sees the same parameter names, generic signatures and annotations.
It leaves a class as it was:
-
when the class has an attribute that the JDK does not know, because such an attribute may hold constant-pool indexes that a rebuilt pool would break;
-
when the class is a
module-info; -
when the jar is signed, because its manifest holds a digest of every entry;
-
when the rewritten class is not smaller;
-
when the step fails on the class, or when the rewritten class verifies worse than the original.
Each rewritten class is verified with ClassFile.verify against a model of the whole class path, never against the class path of the build tool.
A class that already failed to verify, for example because it references an optional dependency that is absent, is accepted when the rewrite fails in the same way.
Costs
All of them fall on the code of the stripped jars only:
-
A helpful
NullPointerExceptionmessage names a local variable by position,because "<local1>" is null. The line number is still there. -
A debugger shows no local-variable names.
-
getDeclaredMethods()can return a class’s methods in another order. That order is unspecified, but code that relies on it can notice, such as a JSON mapper that orders the properties of a dependency’s type by method. -
A rewritten class has new bytes, and software composition analysis tools may report the rewritten jars as modified.
-
The output is reproducible only with the same JDK build. A Docker image layer that holds the rewritten jars changes when the JDK build that runs the transform changes.
Libraries that read local-variable tables
Paranamer’s BytecodeReadingParanamer, AspectJ’s weaver and Spring’s LocalVariableTableParameterNameDiscoverer (before Spring 6.1) read local-variable tables at run time.
When any entry of classPath contains one of them, by entry name, the step strips nothing, and warnings() holds one line that names the entry and the class.
A plugin documents how to silence the warning: turn its option off.
8 Generating the Service Index
The micronaut-aot-service-index module generates micronaut-core’s service index for a packaged application at build time.
It generates a StaticOptimizations.Loader that registers the names of the application’s services, so that service loading at startup does not scan META-INF/services and META-INF/micronaut.
It needs micronaut-core 5.3.0 or later in the application.
| This module is internal to the Micronaut build plugins and Micronaut Runner: its API and the classes it generates may change in any release. |
Unlike the optimizers of Micronaut AOT, this module does not start the application, uses no AOTContext, and needs no AOT run.
Its artifact, io.micronaut.aot:micronaut-aot-service-index, has no dependencies, and needs Java 25 to run.
An application does not depend on it: its build plugin calls it.
How the build plugins call it
ServiceIndexGenerator is @Internal: it is not an API for applications.
For a JVM Docker image, whose application.jar names its libraries in the Class-Path of its manifest, a plugin calls it like this:
ServiceIndexGenerator.Request.Builder request = ServiceIndexGenerator.Request.builder()
.entry(runnerJar, "application.jar")
.holder(runnerJar);
// then each other Class-Path entry that exists at run time, in Class-Path order,
// under its file name at run time: resources/ (or its JAR), then each JAR of libs/
request.entry(resourcesDirectory, "resources");
libraries.forEach(library -> request.entry(library, library.getFileName().toString()));
ServiceIndexGenerator.Result result = ServiceIndexGenerator.generate(request.build());
// whatever the status: write result.entries() into the holder, delete result.removals() from it,
// and log result.message(), as a warning when the status is FAILED
-
The entries are the class path in run-time order, as files of the machine that builds, under the file names they have when the application runs. A fat JAR is one entry.
-
The holder is the entry that receives the generated classes and the service file that registers them.
-
An entry given without a run-time name gives an index that lists no class path, which micronaut-core does not compare at run time. That is for a packaging that cannot know those names, such as Micronaut Runner’s.
The index is built by the application’s own micronaut-core, in an isolated class loader, and the generated loader is checked against it before it is returned.
When the index cannot describe the class path, for example because its micronaut-core is older than 5.3.0 or another entry already registers an index, the status is STOOD_DOWN; when something fails, it is FAILED.
In both cases nothing is generated, an index that an earlier run left in the holder is removed, and the application scans its class path as it does without this module.
generate does not throw for anything about its inputs.
Where it applies
An index describes a class path that cannot change, so a plugin generates it last, on the class path as it ships: after Micronaut AOT, the merging of service files, Shadow or Shade, and any other rewrite of the jars, and before a JDK AOT cache is trained.
It applies to fat JARs and to JVM Docker images.
It never applies to run tasks, tests, IDE class paths, native images, or build/classes and target/classes: micronaut-core does not compare the content of a directory, so a bean compiled after the index was generated would not be found.
At run time
-
-Dmicronaut.service.index.enabled=falseswitches the index off: the application scans its class path. -
-Dmicronaut.service.index.validate=truealso scans the class path when the index is first used, and makes service lookups fail if the names or the class path differ from the index. It costs more than having no index, so it is for diagnosis and tests. -
micronaut-core compares the file names and sizes of the class path with those the index lists. A JAR renamed after packaging, for example with
COPY build/libs/app-0.1-all.jar /app.jar, or replaced by one of another size, makes it set the index aside with aWARNon every start. Nothing is missed, but the gain is lost.
9 Repository
You can find the source code of this project in this repository: