Classpath scanning is a ubiquitous practice in modern Java frameworks. When your application starts, the framework scans JARs, reads directories, and looks for classes annotated with @Component, @Controller, or XML mapper files.
While this dynamic approach works wonderfully on standard JVMs, it poses a severe bottleneck for GraalVM Native Images. The closed-world assumption of GraalVM mandates that all classes, reflections, and resources must be known at compile time. Furthermore, traditional classpath scanning (ClassLoader.getResources(...)) is either highly restricted or incredibly slow in a compiled native binary.
In this article, we'll dive deep into Solon's AOT (Ahead-of-Time) compilation engine and see how it solves classpath scanning under GraalVM Native Image using a lightweight, elegant mechanism called IndexFiles.
The GraalVM Native Image Dilemma
To compile a Java application into a standalone executable, GraalVM needs to build a static dependency graph starting from the main entry point. Any class accessed via reflection, any dynamic proxy, and any resource file must be registered beforehand in JSON configuration files:
reflect-config.jsonresource-config.jsonserialization-config.jsonproxy-config.json
If your framework relies on scanning directory trees inside JAR files at runtime to discover components, it will fail under Native Image—there are no JAR files at runtime!
While some frameworks solve this by generating massive amounts of source code or bytecode during compilation, Solon takes a cleaner, more runtime-friendly approach: IndexFiles.
Introducing Solon's IndexFiles
Solon introduces org.noear.solon.core.runtime.IndexFiles, a internal helper class designed to record scans during build time and substitute them with static indexes at runtime.
The core idea is simple:
- At AOT Compilation Time: Run the Solon container in a special AOT-processing mode to intercept and record all classpath scans.
-
Write Flat Index Files: Save these scan results into
META-INF/solon-index/as simple.indextext files. -
At Runtime: Bypass expensive scanning. Read the
.indexfiles directly and load the recorded classes or resources instantly.
This mechanism applies to both class scanning (discovering beans) and resource scanning (finding templates, config files, or RPC descriptors).
How It Works: Step-by-Step
Let's look at the underlying implementation details.
1. The AOT Flag Interceptor
During Solon's AOT phase, the maven/gradle plugin triggers the main execution via org.noear.solon.aot.SolonAotProcessor. The processor sets a crucial system property:
System.setProperty(NativeDetector.AOT_PROCESSING, "true");
This tells the container that it is running in build-time AOT pre-processing mode.
2. Intercepting Class Scanning
When Solon scans for beans, it uses ClassUtil.scanClasses(clzExpr). Let's look at how it behaves under the hood:
public static void scanClasses(ClassLoader classLoader, String clzExpr, Consumer<Class<?>> clzConsumer) {
if (NativeDetector.isAotRuntime()) {
// Build-time AOT Processing
List<String> clzNames = new ArrayList<>();
doScanClasses0(classLoader, clzExpr, filter, (name, clz) -> {
clzNames.add(name);
clzConsumer.accept(clz);
});
// Write the recorded scan results to an index file
IndexFiles.writeIndexFile(clzExpr, "scan_clz", clzNames);
} else {
// Standard JVM or Native Image Runtime
Collection<String> clzNames = IndexFiles.loadIndexFile(clzExpr, "scan_clz");
if (clzNames != null) {
// Index exists! Directly load classes from the pre-recorded index
for (String clzName : clzNames) {
Class<?> clz = ClassUtil.loadClass(classLoader, clzName);
if (clz != null) {
clzConsumer.accept(clz);
}
}
return;
}
// Fallback to slow scan if no index exists
doScanClasses0(classLoader, clzExpr, filter, (name, clz) -> clzConsumer.accept(clz));
}
}
By substituting raw scanning with a pre-built index, Solon bypasses JAR scanning entirely!
3. Intercepting Resource Scanning
The exact same logic applies to resource scanning via ResourceUtil.scanResources:
public static Collection<String> scanResources(ClassLoader classLoader, String resExpr) {
if (NativeDetector.isAotRuntime()) {
List<String> resList = new ArrayList<>();
scanResources(classLoader, resExpr, resList::add);
// Write to an index file, e.g., mapping to a "scan_res" tag
IndexFiles.writeIndexFile(resExpr, "scan_res", resList);
return resList;
} else {
List<String> resList = IndexFiles.loadIndexFile(resExpr, "scan_res");
if (resList == null) {
resList = new ArrayList<>();
scanResources(classLoader, resExpr, resList::add);
}
return resList;
}
}
Anatomy of an Index File
How does Solon serialize these expressions into filenames? Filesystem paths have strict character limitations, whereas package patterns or scan expressions contain wildcard asterisks (*) and colons (:).
IndexFiles.getIndexFileName safely sanitizes expressions:
- Dots (
.), slashes (/), and backslashes (\) are replaced with hyphens (-). - Asterisks (
*) are replaced with at-signs (@). - Colons (
:) are replaced with exclamation marks (!). - The filename is post-fixed with
_{tag}.index.
For example, a scan expression like classpath:demo/**/*.json (mapped to tag scan_res) will generate an index file located at:
META-INF/solon-index/classpath!demo--@@-@.json_scan_res.index
Inside the file is a simple list of matching resources:
demo/config/db.json
demo/static/data.json
When running in GraalVM Native Image, ResourceUtil simply opens this text file, reads the lines, and returns the resources instantly.
Seamless GraalVM Integration
Of course, recording these files isn't enough; GraalVM needs to build these resources and reflected classes into the binary. solon-aot coordinates this.
During AOT compilation:
-
SolonAotProcessorinvokesaddResourceConfig(metadata). It automatically registers standard directories (static/.*,templates/.*,META-INF/.*) and saves the pre-scanned resource paths tosolon-resource.json. - It generates
resource-config.jsonlisting the generated.indexfiles. - It generates
reflect-config.jsonregistering the default constructors of all components recorded inscan_clz.indexso GraalVM doesn't strip them away.
Solon Native Customization: RuntimeNativeRegistrar
Sometimes, third-party libraries perform dynamic reflections or resources scans that Solon's automated AOT processor cannot intercept.
For these cases, Solon provides the RuntimeNativeRegistrar interface. You can declare a custom component to manually register resources or reflections:
@Component
public class MyNativeRegistrar implements RuntimeNativeRegistrar {
@Override
public void register(AppContext context, RuntimeNativeMetadata metadata) {
// Manually register some resources
metadata.registerResourceInclude("my-custom-config.xml");
// Manually register classes for serialization
metadata.registerSerialization(MyDataTransferObject.class);
// Register class for reflection (constructors + methods)
metadata.registerReflection(MyLegacyService.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS
);
}
}
This bean is automatically collected during AOT compilation and injected into the GraalVM metadata compilation suite.
Summary: Performance Benefits
By substituting runtime Classpath/JAR scanning with compile-time index generation, Solon achieves:
- Near-Zero Scanning Overhead: No CPU cycles are wasted traversing JAR directories during startup.
-
GraalVM native compatibility: Seamlessly translates classpath wildcard scans (
classpath:com/demo/**/*.class) into predictable, static lookups that satisfy GraalVM's closed-world restrictions. - Incredibly Fast Startup: Solon applications typically boot in 0.1 to 0.2 seconds on standard JVMs, and under 2 to 5 milliseconds as compiled GraalVM native binaries.
Solon's AOT compiler demonstrates that achieving GraalVM native support doesn't require rewriting your code or producing messy generated classes—sometimes, a simple index is all you need to keep things clean, simple, and blazing fast.
Top comments (0)