What this post covers
This is the sixteenth post in the "Digging into Spring's Source" series. Part 15 looked at SpEL, Spring's small expression language, and how it parses and evaluates a string. This post is unit U14, the last one in Phase 3 (foundation technology) of the map from Part 1, and it covers two things: the utility classes we have been brushing past in fifteen posts, and AOT (Ahead-of-Time, "computing ahead"), which arrived in Spring 6.
There are four questions to answer.
- Why does Spring carry its own
Assert,ClassUtilsandReflectionUtils, and where did they appear in the earlier posts? - How do
LinkedCaseInsensitiveMap,MultiValueMapandConcurrentReferenceHashMapdiffer from an ordinaryMap, and where do they bite? - When
@OrderandOrderedare mixed, what rule decides the order? - What does AOT compute "ahead", what does its output look like, and what decides which road is taken at run time?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Modules read most | spring-core (org.springframework.util, org.springframework.core, org.springframework.aot), beans.factory.aot in spring-beans, context.aot in spring-context |
| Classes read most | Assert, ClassUtils, ReflectionUtils, LinkedCaseInsensitiveMap, ConcurrentReferenceHashMap, OrderComparator, AotDetector, RuntimeHints, GenerationContext, ApplicationContextAotGenerator, BeanInstanceSupplier |
Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.
My study notes go through each utility line by line. This post keeps only the main thread someone new to Spring can follow: for the utilities, a handful of representatives and "why does it exist, where is it used, where does it bite"; for AOT, the skeleton of the processing flow. What was left out is named at the end, under "If you want to go deeper".
Words to know first
A utility class is a class with no state, just static methods. Almost all of Spring's live in the org.springframework.util package of the spring-core module, and since every other module depends on spring-core (Part 2), the same tools are used everywhere.
Reflection is the Java feature that lets a program inspect and call its own classes' methods, fields and annotations while it runs. Finding a field marked @Autowired and putting a value into it (Part 5) is all reflection.
CGLIB is a library that generates a subclass of a class as bytecode at run time. The CGLIB proxies of Part 10 and the @Configuration classes of Part 8 are made with it, and their class names contain $$.
A native image is a Java program that GraalVM has compiled ahead of time into an executable that runs without a JVM. The build has to know every class that will be used, so reflection that looks classes up by name at run time, and proxies that create classes at run time, do not work as they are.
AOT (Ahead-of-Time) means "do at build time the computation you used to do at run time". Spring's AOT performs the scanning, parsing and decisions the container made while starting once, at build time, and emits the result as Java source code and hints.
A hint is a piece of information for the native image builder, such as "reflection will be used on this class at run time" or "this file will be read". Spring collects them in an object called RuntimeHints.
1. Why Spring has utilities of its own
The class Javadoc of Assert, ClassUtils, StringUtils, ObjectUtils and CollectionUtils carries the same sentence: "Mainly for internal use within the framework" (Assert line 50, ClassUtils line 62, StringUtils line 46, ObjectUtils line 38, CollectionUtils line 43). ReflectionUtils is stronger: "Only intended for internal use" (line 38). Assert even tells you to use Apache Commons Lang or Guava if you want a broader toolkit (lines 51–55).
So these utilities are not inside the framework because the functionality does not exist elsewhere. They are there for three reasons.
- No outside dependencies.
spring-coreis the bottom module; it cannot lean on another library. - Framework-specific chores. Unwrapping CGLIB class names, choosing a class loader, caching reflection results: things a general-purpose utility library does not do.
- One tool for every module. The shape of an argument-check message, or the rule for "is this string blank", is the same across all 22 modules.
The tools in this post and where the earlier posts used them. Grey boxes are earlier posts; the yellow box is a cache.
| Utility | What it does | Where it appeared before |
|---|---|---|
Assert |
Checks arguments and state; throws if wrong | Assert.notNull(event, "Event must not be null") in Part 12 |
ClassUtils |
Class loading, presence checks, unwrapping CGLIB names | isPresent in Part 2, getUserClass in Part 9 and Part 11 |
ReflectionUtils |
Walking methods and fields, opening access, caching reflection results | makeAccessible in Part 5, doWithMethods in Part 11 |
LinkedCaseInsensitiveMap, MultiValueMap, ConcurrentReferenceHashMap |
Three special maps | The annotation caches of Part 13 |
OrderComparator, AnnotationAwareOrderComparator |
Sorting by Ordered and @Order |
Candidate sorting in Part 5, advisor sorting in Part 11 |
Assert — IllegalArgumentException for arguments, IllegalStateException for state
@Contract("null, _ -> fail")
public static void notNull(@Nullable Object object, String message) {
if (object == null) {
throw new IllegalArgumentException(message);
}
}
spring-core/src/main/java/org/springframework/util/Assert.java, lines 179–184
Three lines, but they carry a rule. A bad method argument throws IllegalArgumentException (notNull, isTrue, hasText); a bad object state throws IllegalStateException (state, line 78). The class Javadoc says these catch "usually programmer errors rather than configuration errors" (lines 37–40). So when you meet Assert.state(...) while reading Spring, read it as "by this point, this must already be true". @Contract("null, _ -> fail") tells null-analysis tools that the method never returns normally when its first argument is null.
ClassUtils — the small chores of handling class names
ClassUtils.forName(name, classLoader) (lines 283–325) wraps Class.forName and adds four things. Primitive names like "int" and frequently used classes come out of pre-filled maps (lines 288–294; the maps are at line 126 and line 132); array notation like "java.lang.String[]" is unwrapped (lines 297–301); a null class loader is replaced by the default one (lines 303–306); and if the class is not found but the name looks like a.b.Outer.Inner, the last dot is swapped for $ and a nested class is tried (lines 310–323). The isPresent we saw in Part 2 just calls this forName and returns false when it throws (with one exception: a module readability mismatch, IllegalAccessError, is rethrown as IllegalStateException) (lines 377–390).
The method to watch in this post is getUserClass.
public static Class<?> getUserClass(Class<?> clazz) {
if (clazz.getName().contains(CGLIB_CLASS_SEPARATOR)) {
Class<?> superclass = clazz.getSuperclass();
if (superclass != null && superclass != Object.class) {
return superclass;
}
}
return clazz;
}
spring-core/src/main/java/org/springframework/util/ClassUtils.java, lines 1008–1016
CGLIB_CLASS_SEPARATOR is "$$" (line 92). The CGLIB proxies of Part 10 and the @Configuration classes of Part 8 are subclasses of the original class with $$ in their names, so one rule — "if the name contains $$, the superclass is the user's class" — recovers the original. That recovery is essential whenever annotations are read, because the CGLIB subclass does not carry the annotations the user wrote. So the post-processor that looks for @Autowired (AutowiredAnnotationBeanPostProcessor line 553) and the AopUtils that finds methods matching a pointcut (line 213, line 258) both call getUserClass first.
ReflectionUtils — walk, open, cache
This is the doWithMethods that Part 11 used to collect an aspect's advice methods. Only the exception handling is cut with // ....
public static void doWithMethods(Class<?> clazz, MethodCallback mc, @Nullable MethodFilter mf) {
if (mf == USER_DECLARED_METHODS && clazz == Object.class) {
// nothing to introspect
return;
}
Method[] methods = getDeclaredMethods(clazz, false);
for (Method method : methods) {
if (mf != null && !mf.matches(method)) {
continue;
}
try {
mc.doWith(method);
}
// ...
}
// Keep backing up the inheritance hierarchy.
if (clazz.getSuperclass() != null && (mf != USER_DECLARED_METHODS || clazz.getSuperclass() != Object.class)) {
doWithMethods(clazz.getSuperclass(), mc, mf);
}
else if (clazz.isInterface()) {
for (Class<?> superIfc : clazz.getInterfaces()) {
doWithMethods(superIfc, mc, mf);
}
}
}
spring-core/src/main/java/org/springframework/util/ReflectionUtils.java, lines 355–381
Java's Class.getDeclaredMethods() returns only the methods declared on that class. doWithMethods keeps climbing to the superclass (clazz.getSuperclass()) and repeats, or climbs to the super-interfaces for an interface, so inherited methods are covered in one sweep. The filter USER_DECLARED_METHODS (lines 55–56) drops compiler-generated bridge and synthetic methods and the methods of Object; it is the filter Spring uses most.
Three more things.
- A cache. The
getDeclaredMethodscalled inside stores its result indeclaredMethodsCache(lines 460–487), which is aConcurrentReferenceHashMap(line 84). Section 2 explains that map. Default methods of interfaces are added to the cached array too (lines 465–478). - Opening access. The
makeAccessibleof Part 5 callssetAccessible(true)only when the member is notpublicor its declaring class is notpublic(for a field, also when it isfinal) (Fieldlines 799–805,Methodlines 566–571). The comment says why: "only called when actually necessary, to avoid unnecessary conflicts". - The same name can appear twice. If a subclass and its superclass both declare a method of the same name, both reach the callback. The Javadoc says so and tells you to filter (lines 348–349).
Checking with an example ①
The part of ex/u14/UtilitiesExample.java that belongs to this section. It takes a @Configuration class out of the container and prints its actual class name. The examples print their headings and labels in Korean. In the output blocks of this English version those labels are translated; the values, the order, and anything printed by Spring are the actual output.
// An example written for this post (not Spring's code)
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) {
Class<?> actual = context.getBean(AppConfig.class).getClass();
System.out.println("getClass() = " + actual.getName());
System.out.println("getUserClass() = " + ClassUtils.getUserClass(actual).getName());
}
== ClassUtils.getUserClass / isPresent
getClass() = ex.u14.UtilitiesExample$AppConfig$$SpringCGLIB$$0
getUserClass() = ex.u14.UtilitiesExample$AppConfig
isPresent("java.util.List") = true
isPresent("com.example.Nope") = false
== ReflectionUtils.doWithMethods
without a filter = 14 (climbs up to Object's methods)
USER_DECLARED_METHODS = [hello, secret]
== Assert.notNull
IllegalArgumentException: Event must not be null
The AppConfig taken from the container is a subclass whose name contains $$SpringCGLIB$$, and getUserClass returns the original. Walking a class with two methods and no filter yields 14, down to Object's methods; with USER_DECLARED_METHODS only the two remain. This example was run with the release version Spring Framework 7.0.9 to confirm the output above (not with a self-built 7.1.0-SNAPSHOT, which is what this series reads).
2. Three maps that are not ordinary
LinkedCaseInsensitiveMap — ignore the key's case, remember its spelling
HTTP header names and database column names are case-insensitive. You must be able to put Content-Type and get content-type, but if you lower-cased everything you would lose the original spelling when handing it back. So this map keeps two maps inside.
private final LinkedHashMap<String, V> targetMap;
private final HashMap<String, String> caseInsensitiveKeys;
private final Locale locale;
spring-core/src/main/java/org/springframework/util/LinkedCaseInsensitiveMap.java, lines 57–61
A lookup passes through two maps in turn. The red box is the "easy to get wrong" spot.
caseInsensitiveKeys maps "lower-cased key → spelling used when first put"; targetMap maps "that spelling → value". get lower-cases the key (convertKey, lines 337–339), finds the original spelling, and reads the value under it (lines 168–176). put does a little more.
@Override
public @Nullable V put(String key, @Nullable V value) {
String oldKey = this.caseInsensitiveKeys.put(convertKey(key), key);
V oldKeyValue = null;
if (oldKey != null && !oldKey.equals(key)) {
oldKeyValue = this.targetMap.remove(oldKey);
}
V oldValue = this.targetMap.put(key, value);
return (oldKeyValue != null ? oldKeyValue : oldValue);
}
spring-core/src/main/java/org/springframework/util/LinkedCaseInsensitiveMap.java, lines 189–198
Put the same key again with a different spelling and the old spelling's entry is removed and the new one inserted, so the spelling shown by keySet() changes. JDBC's ColumnMapRowMapper uses this map when it returns a row as a map (ColumnMapRowMapper line 70). HttpHeaders keeps one inside too, and pins the locale to Locale.ROOT (HttpHeaders line 455), because the default constructor uses Locale.getDefault() (line 131).
MultiValueMap — several values per key
HTTP headers and request parameters can repeat a name. MultiValueMap<K, V> extends Map<K, List<V>> and adds getFirst and add (lines 32–46). add creates the list if needed and appends (MultiValueMapAdapter lines 64–68). The everyday implementation, LinkedMultiValueMap, is just an adapter around a LinkedHashMap (line 38, lines 46–48), and its Javadoc says it is not thread-safe (lines 29–30). The attributes of @Profile collected in Part 9 came back as this type.
ConcurrentReferenceHashMap — a cache that may empty itself
Part 13 said the caches of ResolvableType and MergedAnnotations are all this map (ResolvableType lines 99–100, AnnotationsScanner lines 55–59, OrderUtils line 45). So is the declaredMethodsCache of section 1. The class Javadoc says it all (lines 46–62).
- Several threads may use it at once, like
ConcurrentHashMap, but keys and values are held by soft or weak references. Soft is the default (line 78; the kinds are at lines 600–607). When memory is short, the garbage collector takes entries away. - That is why the Javadoc warns that there is "no guarantee that items placed into the map will be subsequently available" and that it may look as if "an unknown thread is silently removing entries" (lines 55–58).
nullkeys andnullvalues are allowed (lines 52–53), unlikeConcurrentHashMap.
The reason Spring builds its caches on this map is clear. Holding reflection results for every class forever leaks memory in environments that load many classes, and above all stops classes from being collected when their class loader goes away. On the condition that only values that can be recomputed go in, a cache that may empty itself is the right tool.
Easy to get wrong
LinkedCaseInsensitiveMapdoes not acceptnullkeys (Javadoc line 44). Pass something that is not aStringtoget(Object)and you simply getnull(line 169).geton aMultiValueMapreturns a list. If you expect one value, usegetFirst. Mixing the two does not throw; you quietly receive aList.- Never put something that "must stay" into a
ConcurrentReferenceHashMap. It is for caches only.
Checking with an example ②
The first part of the same UtilitiesExample output.
== LinkedCaseInsensitiveMap
get("content-type") = text/html
get("CONTENT-TYPE") = text/html
keySet() = [Content-Type, Accept]
after put("CONTENT-TYPE"): size=2, keySet()=[Accept, CONTENT-TYPE], get("Content-Type")=application/json
== LinkedMultiValueMap
getFirst("tag") = spring
get("tag") = [spring, aot]
whole map = {tag=[spring, aot], page=[1]}
A value put under Content-Type can be read with any casing, and keySet() shows the first spelling. But after putting CONTENT-TYPE again (the line labelled "after put"), the size is still 2 while the spelling has changed to CONTENT-TYPE and moved to the end, because the put above removes the old entry and inserts a new one. The last line of the second block, labelled "whole map", prints the complete MultiValueMap.
3. How order is decided — OrderComparator
Part 5 injected several beans of one type as a List, Part 6 registered post-processors, and Part 11 lined up advisors, and each time the post said "they are sorted". All of that sorting is the OrderComparator family. The heart of the comparison is one fourteen-line method.
private int doCompare(@Nullable Object o1, @Nullable Object o2, @Nullable OrderSourceProvider sourceProvider) {
boolean p1 = (o1 instanceof PriorityOrdered);
boolean p2 = (o2 instanceof PriorityOrdered);
if (p1 && !p2) {
return -1;
}
else if (p2 && !p1) {
return 1;
}
int i1 = getOrder(o1, sourceProvider);
int i2 = getOrder(o2, sourceProvider);
return Integer.compare(i1, i2);
}
spring-core/src/main/java/org/springframework/core/OrderComparator.java, lines 77–90
The blue diamonds are the three decisions; the red box is where "objects that say nothing about order" end up.
PriorityOrderedcomes first. If only one side isPriorityOrdered, it goes first without even looking at the values. This is why Part 6 registered post-processors in three batches.- The value comes from
getOrder. The default implementation returnsgetOrder()if the object implementsOrdered, otherwiseOrdered.LOWEST_PRECEDENCE(lines 128–136, lines 145–147).LOWEST_PRECEDENCEisInteger.MAX_VALUE(Orderedline 55). Objects that say nothing go to the back. - Smaller goes first. The Javadoc of
Orderednails it down: "Higher values are interpreted as lower priority" (lines 60–62).
The subclass AnnotationAwareOrderComparator is the one that reads annotations. It overrides findOrder to ask the parent (the Ordered interface) first, and look at annotations only if that yields nothing.
@Override
protected @Nullable Integer findOrder(Object obj) {
Integer order = super.findOrder(obj);
if (order != null) {
return order;
}
return findOrderFromAnnotation(obj);
}
Annotations are read through the type hierarchy with the MergedAnnotations of Part 13 (lines 71–79): @Order first, then jakarta.annotation.Priority (OrderUtils lines 118–128). The result is cached in the ConcurrentReferenceHashMap of section 2 (line 45).
Where it is plugged in: an annotation-config container sets the bean factory's dependencyComparator to AnnotationAwareOrderComparator.INSTANCE at start-up (AnnotationConfigUtils lines 148–149); the auto-proxy creator sorts advisors with AnnotationAwareOrderComparator.sort (AbstractAdvisorAutoProxyCreator line 162); and the AOT machinery later in this post sorts its processors the same way (AotServices line 71).
Checking with an example ③
Four objects with different kinds of order information, mixed and sorted.
// An example written for this post (not Spring's code)
public static class Plain { } // no order information
@Order(1) public static class AnnotatedOne { } // 1 via annotation
public static class OrderedFive implements Ordered { // 5 via interface
@Override public int getOrder() { return 5; }
}
public static class PriorityHundred implements PriorityOrdered { // value 100, but PriorityOrdered
@Override public int getOrder() { return 100; }
}
== AnnotationAwareOrderComparator.sort
PriorityHundred order=100
AnnotatedOne order=1
OrderedFive order=5
Plain order=2147483647
PriorityHundred, with value 100, comes before AnnotatedOne with value 1, because PriorityOrdered wins regardless of value. Plain, which says nothing, gets 2147483647 — Integer.MAX_VALUE — and goes last.
Easy to get wrong
@Order(100)is notPriorityOrdered. An annotation only supplies a value. The only objects that pass decision 1 are those implementing thePriorityOrderedinterface.- If both
Orderedand@Orderare present,Orderedwins, becausefindOrderasks the parent first. The class Javadoc says the same: a value from anOrderedinstance overrides a statically defined annotation value (lines 34–35). - Equal values have no guaranteed order (
OrderComparatorlines 36–37). If the order matters, give different values.
4. Two more, briefly
AntPathMatcher matches Ant-style path patterns such as com/**/*.jsp (Javadoc lines 43–58). The PathMatchingResourcePatternResolver that expanded classpath*:**/*.xml in Part 12 uses it (line 259). The detail worth noticing: it caches tokenized patterns and compiled regexes (lines 96–98), but once 65,536 distinct patterns have piled up in the cache it decides "patterns are unlikely to be reoccurring" and switches the cache off by itself (line 79, lines 401–421), so the cache cannot be turned into an attack surface.
The cache only switches itself off in the default (null) state. If you set true or false yourself, that is what it does (AntPathMatcher lines 159–167 and 401–421).
StopWatch times named tasks and prints them as a table. Its Javadoc is explicit on two points: it is not thread-safe (lines 34–35), and it is meant for proof-of-concept work and development rather than production code (lines 37–38).
That ends part (A), the utilities. From here on it is part (B), AOT.
5. AOT — what is done "ahead"
Why it is needed
Part 7 through Part 9 were about what the container does while starting: scanning the classpath, parsing @Configuration, evaluating @Conditional, finding constructors and @Autowired fields by reflection, and generating CGLIB subclasses where needed. All of that leans on one JVM ability: looking up and inspecting classes by name at run time.
In a GraalVM native image that premise is gone. The reference documentation at the same commit puts it this way: a native image needs to know ahead of time whether a component uses reflection, and classpath resources are not included unless specified explicitly (framework-docs/.../core/aot.adoc lines 576–579). So Spring's AOT introduces itself as "inspect an ApplicationContext at build time and apply decisions and discovery logic that usually happens at runtime" (lines 11–12). Doing it ahead has conditions: the classpath must be fixed at build time, @Profile and @Conditional are decided at build time, and beans registered with lambdas or via registerSingleton cannot be transformed (lines 14–24). In return, three things are produced: Java source code, bytecode (mostly proxies), and RuntimeHints (lines 30–34). The docs add that the focus is currently on native images, with more JVM-based use cases intended later (lines 36–37), and that AOT can be switched on under the JVM with the spring.aot.enabled system property (lines 234–236).
Build time above, run time below. The two yellow boxes are AOT's output; the green box is "the road that uses what was computed ahead".
The switch that picks the road at run time
The fork at run time is one tiny class.
public static final String AOT_ENABLED = "spring.aot.enabled";
private static final boolean inNativeImage = NativeDetector.inNativeImage(Context.RUN, Context.BUILD);
// ...
public static boolean useGeneratedArtifacts() {
return (inNativeImage || SpringProperties.getFlag(AOT_ENABLED));
}
spring-core/src/main/java/org/springframework/aot/AotDetector.java, lines 39–52
Inside a native image (NativeDetector looks for the org.graalvm.nativeimage.imagecode system property that GraalVM plants, lines 30–32), or with spring.aot.enabled on, the answer is "use what was generated". SpringProperties.getFlag reads a spring.properties file on the classpath first and system properties next (lines 106–117). Who consults this switch, and where, is section 7.
RuntimeHints — notes left for the native image builder
RuntimeHints is a holder of five bundles of hints (lines 34–45).
| Method | Bundle | What it records |
|---|---|---|
reflection() |
ReflectionHints |
Which members (constructors, methods, fields) of which types will be used by reflection |
resources() |
ResourceHints |
Which resource path patterns will be read (registerPattern line 113) |
proxies() |
ProxyHints |
Which interface combinations will become JDK proxies (registerJdkProxy line 76) |
serialization() |
SerializationHints |
Java serialization targets. Deprecated since 7.0.6, being folded into reflection() (lines 67–71) |
jni() |
ReflectionHints |
What will be accessed through JNI |
A reflection hint is written with registerType(type, MemberCategory...) (lines 137–141). MemberCategory says "how much of this type to open up", with names shaped as "action (INVOKE/ACCESS) + scope (PUBLIC/DECLARED) + target (CONSTRUCTORS/METHODS/FIELDS)", such as INVOKE_PUBLIC_CONSTRUCTORS, INVOKE_DECLARED_METHODS or ACCESS_DECLARED_FIELDS (lines 34–180; the four INTROSPECT_* constants and the action-less PUBLIC_FIELDS, DECLARED_FIELDS, PUBLIC_CLASSES and DECLARED_CLASSES are deprecated since 7.0). A single method can be named too (registerMethod, line 242).
Who writes hints falls into three groups.
- Spring itself, automatically. The processing of section 6 records hints for bean classes, constructors, factory methods and proxy classes on its own. The example below shows most of them.
- Directly, with a
RuntimeHintsRegistrar. It is a one-method interface (lines 36–44). You register one by putting@ImportRuntimeHints(MyHints.class)on a bean or a@Beanmethod (lines 68–76), or, for a library, by listing it inMETA-INF/spring/aot.factories.spring-core,spring-beansandspring-contextthemselves list their own registrars there (spring-core/.../aot.factories,spring-context/.../aot.factories). - By marking with an annotation. Put
@Reflectiveon a type, constructor, field or method (lines 42–58) and the default processorSimpleReflectiveProcessorwrites the hint that fits that element (lines 35–51).@RegisterReflection(classes = ..., memberCategories = ...)is a declarative form built on top of it, available since 6.2 (lines 78–104).
6. The flow of computing ahead — processAheadOfTime
The entry point is a single class, ApplicationContextAotGenerator, and its method body is eight lines.
public ClassName processAheadOfTime(GenericApplicationContext applicationContext,
GenerationContext generationContext) {
return withCglibClassHandler(new CglibClassHandler(generationContext), () -> {
applicationContext.refreshForAotProcessing(generationContext.getRuntimeHints());
ApplicationContextInitializationCodeGenerator codeGenerator =
new ApplicationContextInitializationCodeGenerator(applicationContext, generationContext);
DefaultListableBeanFactory beanFactory = applicationContext.getDefaultListableBeanFactory();
new BeanFactoryInitializationAotContributions(beanFactory).applyTo(generationContext, codeGenerator);
return codeGenerator.getClassName();
});
}
Blue boxes are steps, green boxes are generated Java sources, the yellow box is the hints.
Start with the two arguments and the return value.
- The
GenericApplicationContextmust not have beenrefresh()ed yet (the Javadoc at line 46: "the non-refreshed application context"). Only the configuration classes are registered. GenerationContextis the container for the output. The interface promises management of generated classes (getGeneratedClasses), generated files (getGeneratedFiles) and hints (getRuntimeHints) (lines 43–81); 7.1 addedgetGeneratedResourcesfor resources (lines 52–58). The default implementation,DefaultGenerationContext, is built from a naming convention (NameGenerator) and a file store (GeneratedFiles) (lines 58–60). There are two file stores,InMemoryGeneratedFileswhich keeps everything in memory (lines 39–48) andFileSystemGeneratedFileswhich writes tosources,resourcesandclassesdirectories on disk (lines 78–84), and three kinds of file:SOURCE,RESOURCEandCLASS(GeneratedFiles.Kindlines 219–237).- The return value is the class name of the generated
ApplicationContextInitializer. Apply that class at run time and the context is restored to its "computed ahead" state.
The naming rule fits in one line: the target class name, then __, then the feature name. The Javadoc's example is com.example.Demo with the feature Initializer giving com.example.Demo__Initializer (NameGenerator lines 93–94; implementation at lines 111–117). The __ApplicationContextInitializer and __BeanDefinitions in the example output are all this rule.
Step one: refreshForAotProcessing — a refresh that stops at bean definitions
public void refreshForAotProcessing(RuntimeHints runtimeHints) {
if (logger.isDebugEnabled()) {
logger.debug("Preparing bean factory for AOT processing");
}
prepareRefresh();
obtainFreshBeanFactory();
prepareBeanFactory(this.beanFactory);
postProcessBeanFactory(this.beanFactory);
invokeBeanFactoryPostProcessors(this.beanFactory);
this.beanFactory.freezeConfiguration();
PostProcessorRegistrationDelegate.invokeMergedBeanDefinitionPostProcessors(this.beanFactory);
preDetermineBeanTypes(runtimeHints);
}
Set this beside the twelve steps of refresh() from Part 7 and the meaning shows. The first five lines are exactly steps 1–5 of refresh() (AbstractApplicationContext lines 590–604). So the configuration-class parsing and component scanning of Part 8 and the condition evaluation and @Import of Part 9 all run here, and the bean definitions are complete. What follows is different. Registering post-processors, the event multicaster and pre-instantiating singletons (lines 606–625) are skipped. Instead the configuration is frozen (freezeConfiguration), only the MergedBeanDefinitionPostProcessors of Part 6 run to attach metadata such as @Autowired and @PostConstruct to the definitions, and preDetermineBeanTypes works out each bean's final type (the proxy type, if it will become a proxy) in advance (lines 431–466). While doing so it records a proxy hint for a JDK proxy, or reflection hints for a CGLIB subclass and its original class (ClassHintUtils lines 62–76). Here getUserClass from section 1 turns up again (line 69).
The Javadoc summary is exact: "In this mode, only BeanDefinitionRegistryPostProcessor and MergedBeanDefinitionPostProcessor are invoked" (lines 404–405). Not a single bean object is created. It is a refresh that builds the blueprints and stops.
Step two: collect processors and receive contributions
private static List<BeanFactoryInitializationAotProcessor> getProcessors(AotServices.Loader loader) {
List<BeanFactoryInitializationAotProcessor> processors = new ArrayList<>(
loader.load(BeanFactoryInitializationAotProcessor.class).asList());
processors.add(new RuntimeHintsBeanFactoryInitializationAotProcessor());
return Collections.unmodifiableList(processors);
}
The loader is AotServices.factoriesAndBeans(beanFactory) (lines 46–48), which gathers everything listed in META-INF/spring/aot.factories plus everything registered as a bean, and sorts them with the AnnotationAwareOrderComparator of section 3 (AotServices line 52, line 71). There are two kinds of processor with similar names, so here they are side by side.
| Interface | Input | Returns | Typical implementations |
|---|---|---|---|
BeanFactoryInitializationAotProcessor (lines 47–62) |
The whole bean factory | A BeanFactoryInitializationAotContribution or null |
BeanRegistrationsAotProcessor (bean registration code), RuntimeHintsBeanFactoryInitializationAotProcessor (runs hint registrars) |
BeanRegistrationAotProcessor (lines 50–76) |
One bean (RegisteredBean) |
A BeanRegistrationAotContribution or null |
AutowiredAnnotationBeanPostProcessor, ConfigurationClassPostProcessor (line 342) |
A "contribution" is an object that adds generated code or hints to the GenerationContext. Both interfaces do it through a single applyTo(generationContext, code) method (BeanRegistrationAotContribution line 57). One rule is worth noticing: a bean that implements an AOT processor is itself left out of the generated code (BeanRegistrationAotProcessor lines 86–88; the check is at BeanDefinitionMethodGeneratorFactory lines 133–147). The work it used to do at run time (finding @Autowired fields and so on) is now done by generated code, so the post-processor is not needed at run time.
Step three: from one bean to code
The most important processor is BeanRegistrationsAotProcessor. It walks every bean definition name, creates a BeanDefinitionMethodGenerator per bean (lines 46–54), and returns the bundle as one contribution. That contribution's applyTo does three things (lines 71–84).
- It creates an
X__BeanFactoryRegistrationsclass (lines 120–126) withregisterBeanDefinitionsandregisterAliasesmethods. - It puts those two methods on the list of "things to call at initialisation" (
addInitializer). That list becomes the body ofinitialize()in__ApplicationContextInitializer(ApplicationContextInitializationCodeGeneratorlines 98–113). The line at the top of that body setting thedependencyComparatortoAnnotationAwareOrderComparator.INSTANCE(lines 105–106) is the run-time work ofAnnotationConfigUtilsfrom section 3, hard-coded. - It records reflection hints for every bean class (lines 142–149).
The code for one bean is made by BeanDefinitionMethodGenerator. It obtains an X__BeanDefinitions class named after the bean class's top-level class (for a nested class, a nested class of the same shape is created inside, lines 117–138) and adds one static method that returns a BeanDefinition (lines 169–177). The body starts with new RootBeanDefinition(class) (DefaultBeanRegistrationCodeFragments lines 131–132), fills in properties, and finally plugs in an instance supplier. The code for that supplier comes from InstanceSupplierCodeGenerator (lines 141–151).
- If the constructor is visible from the generated code (
public, or non-privatein the same package) and takes no arguments, it is simplyFoo::new(lines 183–191). No reflection at all. - For a factory method such as a
@Beanmethod, it isBeanInstanceSupplier.forFactoryMethod(class, "methodName", argumentTypes...)with.withGenerator(lambda)attached (lines 272–291). The lambda calls the method directly. - If the member is not visible (
privateand so on),forConstructor/forFactoryMethodis used without a lambda and a reflection invocation hint is recorded instead (lines 171–175).
At run time, all this BeanInstanceSupplier does is get(registeredBean) (lines 190–207). If a lambda is present, it resolves any arguments the way Part 5 did (resolveArguments) and calls the lambda. Without a lambda, it finds the constructor or method by reflection and invokes it. In other words, "deciding what to call" was finished at build time; only "filling in the arguments" is left for run time. Of the three steps in Part 4, instantiation is what this supplier replaces.
Checking with an example ④ — running it yourself
This is ex/u14/AotGenerateExample.java. It does not build a native image; it calls the generator directly at run time and collects the output in memory. The core is these three steps.
// An example written for this post (not Spring's code)
// 1. A context that has not been refreshed — passing classes to the constructor would refresh, so only register
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.register(AppConfig.class);
// 2. A GenerationContext that collects the output in memory
InMemoryGeneratedFiles files = new InMemoryGeneratedFiles();
DefaultGenerationContext generationContext = new DefaultGenerationContext(
new ClassNameGenerator(ClassName.get(AotGenerateExample.class)), files);
// 3. Process ahead of time — what comes back is the class name of the generated ApplicationContextInitializer
ClassName initializer = new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext);
generationContext.writeGeneratedContent();
AppConfig is a configuration class with just two methods: @Bean engine() returning an Engine, and @Bean car(Engine engine) taking one. The two-line INFO log record that Hibernate Validator prints first was removed from the output; the rest is as printed (labels translated, as above).
initializer = ex.u14.AotGenerateExample__ApplicationContextInitializer
== SOURCE (5 files)
ex/u14/AotGenerateExample__ApplicationContextInitializer.java
ex/u14/AotGenerateExample__BeanDefinitions.java
ex/u14/AotGenerateExample__BeanFactoryRegistrations.java
org/springframework/context/event/DefaultEventListenerFactory__BeanDefinitions.java
org/springframework/context/event/EventListenerMethodProcessor__BeanDefinitions.java
== RESOURCE (0 files)
== CLASS (3 files)
ex/u14/AotGenerateExample$AppConfig$$SpringCGLIB$$0.class
ex/u14/AotGenerateExample$AppConfig$$SpringCGLIB$$FastClass$$0.class
ex/u14/AotGenerateExample$AppConfig$$SpringCGLIB$$FastClass$$1.class
Five sources and three class files. The three CLASS entries are the bytecode CGLIB generated for the @Configuration class, intercepted and stored by the generator through CglibClassHandler (lines 63–67). At run time these classes can be loaded instead of generated. Bean definitions for EventListenerMethodProcessor and DefaultEventListenerFactory were generated, but ConfigurationClassPostProcessor and AutowiredAnnotationBeanPostProcessor, registered in the same place, are nowhere to be seen: both are beans implementing AOT processors and were dropped by the rule above.
The generated initializer class (package, imports and Javadoc left out):
@Generated
public class AotGenerateExample__ApplicationContextInitializer implements ApplicationContextInitializer<GenericApplicationContext> {
@Override
public void initialize(GenericApplicationContext applicationContext) {
DefaultListableBeanFactory beanFactory = applicationContext.getDefaultListableBeanFactory();
beanFactory.setAutowireCandidateResolver(new ContextAnnotationAutowireCandidateResolver());
beanFactory.setDependencyComparator(AnnotationAwareOrderComparator.INSTANCE);
new AotGenerateExample__BeanFactoryRegistrations().registerBeanDefinitions(beanFactory);
new AotGenerateExample__BeanFactoryRegistrations().registerAliases(beanFactory);
}
}
And what the three beans look like in __BeanDefinitions (part of the output; Javadoc comments are left out and omitted methods are marked // ...):
public class AotGenerateExample__BeanDefinitions {
@Generated
public static class AppConfig {
public static BeanDefinition getAppConfigBeanDefinition() {
RootBeanDefinition beanDefinition = new RootBeanDefinition(AotGenerateExample.AppConfig.class);
beanDefinition.setTargetType(AotGenerateExample.AppConfig.class);
ConfigurationClassUtils.initializeConfigurationClass(AotGenerateExample.AppConfig.class);
beanDefinition.setInstanceSupplier(AotGenerateExample$AppConfig$$SpringCGLIB$$0::new);
return beanDefinition;
}
private static BeanInstanceSupplier<AotGenerateExample.Engine> getEngineInstanceSupplier() {
return BeanInstanceSupplier.<AotGenerateExample.Engine>forFactoryMethod(AotGenerateExample$AppConfig$$SpringCGLIB$$0.class, "engine")
.withGenerator((registeredBean) -> registeredBean.getBeanFactory().getBean("aotGenerateExample.AppConfig", AotGenerateExample.AppConfig.class).engine());
}
// ...
private static BeanInstanceSupplier<AotGenerateExample.Car> getCarInstanceSupplier() {
return BeanInstanceSupplier.<AotGenerateExample.Car>forFactoryMethod(AotGenerateExample$AppConfig$$SpringCGLIB$$0.class, "car", AotGenerateExample.Engine.class)
.withGenerator((registeredBean, args) -> registeredBean.getBeanFactory().getBean("aotGenerateExample.AppConfig", AotGenerateExample.AppConfig.class).car(args.get(0)));
}
// ...
Three lines to point at. The supplier for appConfig is a constructor reference, ::new, on the CGLIB class. engine fetches the configuration bean and calls .engine() directly. car has an argument, so it takes a (registeredBean, args) lambda, and the Engine that goes into args.get(0) is resolved and passed by BeanInstanceSupplier at run time. Nowhere is an annotation read or a method looked up. This example too was run with Spring Framework 7.0.9.
Easy to get wrong
- One class has been renamed. The
ClassNameGeneratorthe example uses is the 7.0.9 name; in the 7.1 source this post reads it becameNameGenerator, which also generates resource names (NameGeneratorlines 40–42,@since 7.1; theDefaultGenerationContextconstructor at line 58 takes aNameGeneratortoo). Moving the example to 7.1 means changing that one line. - Do not pass a
refresh()ed context.new AnnotationConfigApplicationContext(AppConfig.class)with classes in the constructor runsrefresh()as well, as Part 1 showed. That is why the example uses the empty constructor and onlyregisters. - What is generated is source code. Compiling it onto the classpath and applying the initializer at run time is the job of the build tool and of whatever starts the container. Within this repository,
ContextAotProcessor.performAotProcessingshows the shape of that "command": it writes toFileSystemGeneratedFilesand exports the hints as a native image configuration file (lines 102–113; the file namereachability-metadata.jsonis atNativeConfigurationWriterline 41). The Spring Boot plugin that wires this into a build lives outside this repository and was not verified in the source for this post.
7. Run time: who uses what was made ahead
Searching this repository for who consults AotDetector.useGeneratedArtifacts() gives two main groups (the Kotlin bean DSL and the AOT support classes in spring-test consult the switch too).
Those that apply the generated initializer. AotApplicationContextInitializer.forInitializerClasses takes a list of class names, loads the initializers and calls initialize on each in turn (lines 57–62; the loading uses the ClassUtils of section 1, lines 77–88). The place in this repository that really uses it is spring-test: when a test context is loaded and useGeneratedArtifacts() is true, it looks for the generated initializer class for that test class and throws if there is none (DefaultCacheAwareContextLoaderDelegate lines 312–325). The Javadoc of AotDetector says the same: if the optimizations are not available "it is recommended to throw an exception rather than fall back to the regular runtime behavior" (lines 34–38). Applying the initializer in an ordinary application is Spring Boot's job, again outside what this post verified.
Those that load CGLIB classes instead of generating them. When wrapping a @Configuration class (ConfigurationClassEnhancer line 184), when subclassing a bean for method injection (CglibSubclassingInstantiationStrategy line 157), and when creating the CGLIB AOP proxy of Part 10 (CglibAopProxy line 204), all three pass the result of AotDetector.useGeneratedArtifacts() to enhancer.setAttemptLoad(...) (lines 184 and 204 put enhancer.getUseCache() && in front of it; the same call also appears for the FactoryBean proxy at ConfigurationClassEnhancer line 578 and in MvcUriComponentsBuilder line 798 of spring-webmvc). attemptLoad is the CGLIB option "try to load a class of that name before generating it" (AbstractClassGenerator lines 233–240). The three CLASS files from the example in section 6 are used at run time through this road.
If a class made ahead of time is there, it is loaded and used; if not, one is generated as usual. Inside a native image generating is impossible, so it throws (AbstractClassGenerator lines 351–370).
Checking with an example ⑤ — what ended up in the hints
ex/u14/AotHintsExample.java puts @ImportRuntimeHints(ReportHints.class) on the configuration class, registers a field-access hint for a Report class and one resource pattern in ReportHints, marks one method of a bean class with @Reflective, processes ahead of time in the same way, and prints the RuntimeHints.
// An example written for this post (not Spring's code)
public static class ReportHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection().registerType(Report.class, MemberCategory.ACCESS_DECLARED_FIELDS);
hints.resources().registerPattern("config/app.properties");
}
}
== reflection hints (ex.u14 package only)
ex.u14.AotHintsExample$AppConfig
categories = [INVOKE_PUBLIC_METHODS, INVOKE_DECLARED_METHODS]
ex.u14.AotHintsExample$AppConfig$$SpringCGLIB$$0
categories = [INVOKE_DECLARED_CONSTRUCTORS]
ex.u14.AotHintsExample$AppConfig$$SpringCGLIB$$0
categories = [INVOKE_DECLARED_METHODS, ACCESS_DECLARED_FIELDS, INVOKE_DECLARED_CONSTRUCTORS]
ex.u14.AotHintsExample$AppConfig$$SpringCGLIB$$FastClass$$0
categories = [INVOKE_DECLARED_CONSTRUCTORS]
ex.u14.AotHintsExample$AppConfig$$SpringCGLIB$$FastClass$$1
categories = [INVOKE_DECLARED_CONSTRUCTORS]
ex.u14.AotHintsExample$Report
categories = [ACCESS_DECLARED_FIELDS]
ex.u14.AotHintsExample$ReportService
method = render[] INVOKE
== all reflection hints = 66 types
== resource patterns = [/, org/springframework/mail/javamail/mime.types, org, org/springframework, org/springframework/mail, org/springframework/mail/javamail, /, org/springframework/http/mime.types, org, org/springframework, org/springframework/http, /, org/springframework/http/codec/CodecConfigurer.properties, org, org/springframework, org/springframework/http, org/springframework/http/codec, /, org/springframework/web/util/HtmlCharacterEntityReferences.properties, org, org/springframework, org/springframework/web, org/springframework/web/util, /, META-INF/spring.factories, META-INF, /, spring.properties, /, config/app.properties, config]
== jdk proxy hints = 4
== AotDetector.useGeneratedArtifacts()
default = false
after spring.aot.enabled=true = true
The order of the categories inside the brackets is a printed set and can change from run to run. Only three things were written by hand: ACCESS_DECLARED_FIELDS on Report, INVOKE on the render method, and the resource pattern config/app.properties. Yet the hints span 66 types. The rest Spring wrote automatically. The INVOKE_PUBLIC_METHODS and INVOKE_DECLARED_METHODS on AppConfig, and the three categories on the CGLIB class, are the two bundles in ClassHintUtils called from preDetermineBeanTypes in section 6 (lines 44–51). The resource patterns mention mail, http and web because those modules are on the example project's classpath, and the registrars listed in each module's aot.factories ran. Hints are collected for the whole classpath, not just "my code". The last two lines show the switch from section 5 flipping on a single system property.
Easy to get wrong: the same CGLIB class appears twice
In the output above, AppConfig$$SpringCGLIB$$0 is split into two entries. Following the source explains why. The hint key, TypeReference, decides equality by canonical name (AbstractTypeReference lines 86–89). But CglibClassHandler builds its reference from the string name when it receives the bytecode (lines 63–64), and the string-based implementation treats a single $ as a nested-class separator and splits the name (SimpleTypeReference lines 49–57). ClassHintUtils, on the other hand, builds its reference from the Class object, and that implementation uses Class.getCanonicalName() as is (ReflectionTypeReference lines 52–54). Because the example's AppConfig is a nested class, its name has one extra $, the two methods produce different canonical names, and there are two keys. Two entries in the final configuration file overlap in meaning rather than being wrong, but it is an easy place to wonder "why is one class listed twice?".
If you want to go deeper
On the utilities side, only names and locations of what was left out. StringUtils.hasText (false for whitespace-only strings, lines 203–205) and the uncapitalizeAsProperty that built bean names in Part 8 (line 593). ObjectUtils.nullSafeEquals, which compares arrays by content (lines 340–354). CollectionUtils.isEmpty, which handles null and empty in one call (lines 68–70). PropertyPlaceholderHelper, which resolves ${...} placeholders, and the bounded cache ConcurrentLruCache (same util package). The actual matching algorithm of AntPathMatcher, doMatch (from line 210).
On the AOT side: BindingReflectionHintsRegistrar and @RegisterReflectionForBinding, which record JSON-binding hints along a type hierarchy automatically. The spring-aop processors for scoped proxies and AspectJ aspects (spring-aop/.../aot.factories). The three contributions of ConfigurationClassPostProcessor (@PropertySource, ImportAware, BeanRegistrar, lines 353–367). @ReflectiveScan, which looks for @Reflective across whole packages rather than just beans. FileNativeConfigurationWriter, which writes the hints as JSON. How generated code deals with private members (AccessControl). And the AOT support in spring-test, which returns in Part 26.
Recap
| Concept | In one line |
|---|---|
| Spring's utilities | "Mainly for internal use within the framework". No outside dependencies, framework-specific chores (CGLIB names, class loaders, caches), one tool for every module |
Assert |
Bad argument → IllegalArgumentException; bad state → IllegalStateException |
ClassUtils.getUserClass |
If the name contains $$, the superclass is the user's class. Called before reading annotations |
ReflectionUtils.doWithMethods |
Walks up through superclasses and super-interfaces; results cached in a ConcurrentReferenceHashMap |
LinkedCaseInsensitiveMap |
Two maps: lower-cased key → original spelling → value. Putting under a different spelling changes the spelling |
MultiValueMap |
Map<K, List<V>> plus getFirst and add. LinkedMultiValueMap is not thread-safe |
ConcurrentReferenceHashMap |
A concurrent map on soft/weak references that may empty itself. Only for caches that can be recomputed |
OrderComparator |
PriorityOrdered first → Ordered value → (annotation version) @Order/@Priority → otherwise Integer.MAX_VALUE. Smaller goes first |
| AOT | Makes the container's start-up decisions at build time and emits Java source, bytecode and RuntimeHints |
RuntimeHints |
Bundles of reflection, resource and proxy hints. Written automatically, via RuntimeHintsRegistrar, or with @Reflective |
refreshForAotProcessing |
The first five steps of refresh() + freezing + MergedBeanDefinitionPostProcessor + pre-determining types. No bean objects are created |
| Processors and contributions | BeanFactoryInitializationAotProcessor (whole factory) and BeanRegistrationAotProcessor (one bean) return …Contributions, which add code and hints through applyTo |
| Generated code | X__ApplicationContextInitializer → X__BeanFactoryRegistrations.registerBeanDefinitions → X__BeanDefinitions.getXxxBeanDefinition() |
BeanInstanceSupplier |
"What to call" is baked into the code; only argument resolution happens at run time |
AotDetector.useGeneratedArtifacts() |
In a native image or with spring.aot.enabled, use the generated output. CGLIB loads instead of generating |
What to look at next
With this post, Phase 3 (foundation technology) of the map in Part 1 is complete. The type and annotation reading of Part 13, the type conversion of Part 14, the SpEL of Part 15, and the utilities and AOT of this post are the floor beneath the container and AOP.
Next comes Phase 4, transactions and data access. The first post is about @Transactional: one annotation on a method, yet who opens the transaction and who closes it, how TransactionInterceptor sits on top of the proxies and advice chains of Part 10 and Part 11, and which branches the propagation attribute really takes, all followed through spring-tx.
- Next: Digging into Spring's Source 17: @Transactional — Who Opens the Transaction and Who Closes It
- Part 1: How I Read It with AI, and the Map
- Part 2: The Big Map — Modules and Packages
- Part 5: How @Autowired Picks the Bean to Inject
- Part 7: The Twelve Steps of refresh()
- Part 10: How an AOP Proxy Is Made
- Part 13: ResolvableType and MergedAnnotations