What this post covers
This is the thirteenth post in the "Digging into Spring's Source" series. Up to Part 12 we read the container (Phase 1) and AOP (Phase 2). From this post on we go one floor down, to the foundation technology (Phase 3) that everything else borrows from. On the map in Part 1 this is U11, the unit I said we would "pull out only when we get stuck".
Three places in earlier posts were passed over without explanation. In Part 5, DependencyDescriptor somehow "knew" that the element type of a List<Service> field is Service. In Part 8, component scanning read annotations "without loading" the class. In Part 9, @EnableXxx carried @Import "as a meta-annotation". How those three things are possible is the subject of this post.
There are three questions to answer.
- Java erases generic types at runtime, so how does Spring read the
StringinList<String>? (ResolvableType) - How does Spring read the attributes of an annotation stacked on top of another annotation (a meta-annotation) as one merged view, and what does
@AliasFordo? (MergedAnnotations) - How does Spring find out which annotations a class has by reading only its
.classfile, without loading it into memory? (MetadataReader, ASM)
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Module read most | spring-core |
| Classes read most | ResolvableType, MergedAnnotations, AnnotationTypeMapping, MetadataReader, SimpleAnnotationMetadataReadingVisitor |
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 much deeper, line by line. This post keeps only the main thread that someone seeing Spring for the first time can follow. What was left out is named at the end, under "If you want to go deeper".
Words to know first
A generic is the Java syntax for writing one type inside another, as in List<String>. The <String> part is called a type argument. The Java compiler only checks it and then erases it from the compiled code (type erasure), so at runtime there is no way to hold a List object and ask "what is your element type?". However, generics written in a field or method declaration survive in the .class file and can be read through reflection. ResolvableType in this post is the tool that reads those declarations.
Reflection is the Java API for inspecting a class's fields, methods and annotations at runtime. Field.getGenericType() and Class.getAnnotation() are reflection. To use it, the class has to be in memory.
A meta-annotation is an annotation placed on another annotation. Open the declaration of @Service and you find @Component on top of it: @Component is a meta-annotation of @Service. Java reflection shows you only the @Service placed directly on the class, not the @Component above it. Spring climbs up and reads it.
Class loading and initialisation are different things. Loading is the JVM reading a .class file and building a Class object; initialisation is filling the class's static fields and running its static {} blocks. Class.forName("...") does both. The third tool in this post does neither: it only reads the file.
Bytecode is what is inside a .class file: not Java code, but the instructions and tables the JVM reads. The best-known library for reading and writing bytecode is ASM, and Spring keeps a copy of ASM inside its own source and uses that.
A JDK dynamic proxy is a Java feature that creates, at runtime, a class implementing a given interface. In Java an annotation is a kind of interface, so an annotation object can also be made as a dynamic proxy. It is the same mechanism as the JDK proxy in Part 10.
1. Where the three tools are used
First the big picture. All three tools live in the spring-core module, and the container and the code around it borrow them.
Blue boxes are the three tools of this post; grey boxes are the places that use them.
| Tool | Package | Question it answers | Used by (examples) |
|---|---|---|---|
ResolvableType |
org.springframework.core |
What is the generic type of this field or parameter? | Choosing a bean to inject (Part 5), choosing event listeners (Part 12) |
MergedAnnotations |
org.springframework.core.annotation |
Which annotations, with which values, are on this class — meta-annotations included? | AnnotationUtils, AnnotatedElementUtils, bean naming, finding @Import |
MetadataReader |
org.springframework.core.type.classreading |
Can the question above be answered without loading the class? | Component scanning, the @Configuration check (Part 8) |
The three lean on each other. The annotations that MetadataReader reads from bytecode are turned into MergedAnnotations, so they can be handled through the same API as annotations read by reflection. Section 4 shows that connection.
2. ResolvableType — reading generics
Plain reflection works, but it is awkward
Let us first try without Spring. A field's generic type can be read with Field.getGenericType(), but what comes back is a Type, not a Class. Type is the common parent of Class, ParameterizedType (List<String>), TypeVariable (T), WildcardType (?) and GenericArrayType (T[]), so to get at what you want you have to branch on instanceof every time.
// An example written for this post (not Spring's code)
Field names = ResolvableTypeExample.class.getDeclaredField("names"); // List<String> names;
Type generic = names.getGenericType();
if (generic instanceof ParameterizedType pt) {
Type first = pt.getActualTypeArguments()[0]; // String — but typed as Type
}
To get the Integer out of Map<String, List<Integer>> you need two of those instanceof checks nested, and if a T or a ? turns up you need yet another branch. ResolvableType does that work for you.
Example: the same thing with ResolvableType
// An example written for this post (not Spring's code)
List<String> names;
Map<String, List<Integer>> scores;
ResolvableType namesType = ResolvableType.forField(names);
namesType.getGeneric(0).resolve(); // String.class
ResolvableType scoresType = ResolvableType.forField(scores);
scoresType.getGeneric(1); // List<Integer> (a ResolvableType)
scoresType.getGeneric(1, 0).resolve(); // Integer.class
scoresType.getGeneric(5); // ResolvableType.NONE (neither an exception nor null)
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. The output (full code in ex/u11/ResolvableTypeExample.java):
getType() = interface java.util.List
getGenericType() = java.util.List<java.lang.String>
element type (reflection) = class java.lang.String / is it a Class? true
ResolvableType = java.util.List<java.lang.String>
getGeneric(0) = class java.lang.String
scores = java.util.Map<java.lang.String, java.util.List<java.lang.Integer>>
getGeneric(1) = java.util.List<java.lang.Integer>
getGeneric(1, 0) = class java.lang.Integer
getGeneric(5) = ? (is it NONE? true)
asMap() = java.util.Map<java.lang.String, java.util.List<java.lang.Integer>>
List<String> <- List<String> : true
List<String> <- List<Integer> : false
List.class.isAssignableFrom(List.class) : true
equals? true / same object? false
This example was compiled and run against the released Spring Framework 7.0.9 (not against a self-built 7.1.0-SNAPSHOT, which is what this series reads).
getGeneric() goes one level in; resolve() gives a Class. Green is where a Class came out, red is asking for something that is not there.
The design: wrap a Type, and have every method return another ResolvableType
The class Javadoc of ResolvableType says it "encapsulates a Java Type, providing access to supertypes, interfaces and generic parameters along with the ability to ultimately resolve to a Class", and stresses that most methods, such as getGeneric, as and getSuperType, return another ResolvableType, so calls can be chained (ResolvableType lines 49–88). So that a chain never breaks in the middle, "nothing" is represented not by null but by a special object, NONE (line 95). That is the getGeneric(5) of the example.
getGeneric() is short.
public ResolvableType getGeneric(int @Nullable ... indexes) {
ResolvableType[] generics = getGenerics();
if (indexes == null || indexes.length == 0) {
return (generics.length == 0 ? NONE : generics[0]);
}
ResolvableType generic = this;
for (int index : indexes) {
generics = generic.getGenerics();
if (index < 0 || index >= generics.length) {
return NONE;
}
generic = generics[index];
}
return generic;
}
spring-core/src/main/java/org/springframework/core/ResolvableType.java, lines 754–768
Several indexes walk one level deeper each; an index out of range gives NONE. getGenerics() takes getActualTypeArguments() if the wrapped Type is a ParameterizedType, or getTypeParameters() if it is a plain Class, wraps each one in a ResolvableType, and caches the array in a field (lines 783–819).
resolve() just returns the precomputed resolved field (lines 882–884). The work starts in resolveClass(): a Class is returned as it is, an array (GenericArrayType) is resolved through its component type, and everything else is handed to resolveType() (lines 901–913). resolveType() is where the branching on the kind of Type happens: a ParameterizedType by its raw type (List for List<String>), wildcards and type variables by following their bounds, and for a type variable it asks a VariableResolver "what is this T really?" (lines 920–943). When you climb to a supertype with ResolvableType.forClass(MyList.class).as(List.class) (lines 485–500), the outer type acts as the inner type's VariableResolver and resolves the E of class MyList extends ArrayList<String> to String. That is as far as this post goes; the details of type-variable resolution are left for "If you want to go deeper".
Where they come from: forField, forMethodParameter, forClass, forType
The static factory methods come in four flavours, by starting point.
| Method | Starting point | Location |
|---|---|---|
forClass(Class) |
A Class object |
lines 1104–1106 |
forField(Field) |
A field declaration | lines 1222–1225 |
forMethodParameter(Method, int) |
A method parameter; builds a MethodParameter internally |
lines 1364–1366 |
forType(Type) |
A Java Type you already hold |
lines 1481–1483 |
MethodParameter is a small specification object meaning "parameter number N of this method (or constructor)" (MethodParameter lines 47–64). It carries a nestingLevel saying which layer of a nested type such as List<List<String>> it is looking at (lines 298–300); the DependencyDescriptor of Part 5 uses it for Optional<Service> and ObjectProvider<Service>.
All four end up in one forType(Type, TypeProvider, VariableResolver). This is where the cache is.
static ResolvableType forType(
@Nullable Type type, @Nullable TypeProvider typeProvider, @Nullable VariableResolver variableResolver) {
if (type == null && typeProvider != null) {
type = SerializableTypeWrapper.forTypeProvider(typeProvider);
}
if (type == null) {
return NONE;
}
// For simple Class references, build the wrapper right away -
// no expensive resolution necessary, so not worth caching...
if (type instanceof Class) {
return new ResolvableType(type, null, typeProvider, variableResolver);
}
// Check the cache - we may have a ResolvableType which has been resolved before...
ResolvableType resultType = new ResolvableType(type, typeProvider, variableResolver);
ResolvableType cachedType = cache.get(resultType);
if (cachedType == null) {
cachedType = new ResolvableType(type, typeProvider, variableResolver, resultType.hash);
cache.put(cachedType, cachedType);
}
resultType.resolved = cachedType.resolved;
return resultType;
}
spring-core/src/main/java/org/springframework/core/ResolvableType.java, lines 1533–1558
The yellow box is the static cache. What stays in the cache is the "resolved result"; what the caller gets is a fresh object every time.
Three things to notice.
- A plain
Classis not cached. There is nothing to resolve, so it is wrapped and returned at once. The comment says "not worth caching". - The cache key is a
ResolvableTypeitself.equals()compares theType, theTypeProviderand theVariableResolver(lines 992–1015), so asking about the same field twice produces the same key. The map is aConcurrentReferenceHashMap(lines 99–100), which holds its entries through soft references so that they can be dropped under memory pressure. That choice keeps the cache from growing without bound in environments that generate many classes dynamically. - What is returned is not the cache entry but a new object. The last two lines only copy
resolvedout of the cached entry. That is the example's last line,equals? true / same object? false.
Why SerializableTypeWrapper exists
forField does not use Field.getGenericType() directly; it goes through a FieldTypeProvider and hands it to SerializableTypeWrapper.forTypeProvider(). That class's Javadoc calls it an "internal utility class that can be used to obtain wrapped Serializable variants of java.lang.reflect.Type" (SerializableTypeWrapper lines 38–56). The problem is that the JDK's ParameterizedType implementation is not Serializable. ResolvableType is Serializable (line 89), but that is useless if the Type inside cannot be serialised. So the Type is wrapped in a dynamic proxy, and the proxy records only "which field of which class this was". On deserialisation the field is looked up again by name (lines 229–262). Four kinds of Type can be wrapped — GenericArrayType, ParameterizedType, TypeVariable, WildcardType — and Class is not, because it is already Serializable (lines 59–60, lines 103–131). In environments where serialisation is not available at all, such as a GraalVM native image, nothing is wrapped and the original Type is used (lines 109–113).
Easy to get wrong
isAssignableFromlooks at generics too. In the exampleList.class.isAssignableFrom(List.class)istrue, but whether aList<Integer>can go into aList<String>came outfalse. The Javadoc ofResolvableType.isAssignableFrom(ResolvableType)says it "attempts to follow the same rules as the Java compiler", checking not only the resolvedClassbut that all generics are assignable (lines 266–278). That is why Part 5 could tellRepository<User>fromRepository<Order>when injecting.resolve()can benull. Not only forNONE: aTor?whose only bound isObjectalso resolves tonull. The Javadoc says "bounds ofObject.classwill be ignored" (lines 870–881). If you need a safe default, useresolve(Object.class)ortoClass().hasUnresolvableGenerics()means "are there generics that cannot be resolved". That is different from having no generics. It istrueonly when an unresolvable type variable remains or a generic interface is implemented in a raw fashion (lines 589–601). The container asks this when deciding whether to match a factory method's return type down to its generics.GenericTypeResolveris the old front door. Marked@since 2.5.2, it predatesResolvableType(4.0) and is now a thin wrapper that callsResolvableTypeinternally. For exampleresolveTypeArgument(clazz, genericType)callsResolvableType.forClass(clazz).as(genericType)(GenericTypeResolverlines 111–117).
3. MergedAnnotations — annotations on annotations
Example: a home-made stereotype
Let us make an annotation that has @Component as its meta-annotation and compare what Java and Spring each see.
// An example written for this post (not Spring's code)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component // the meta-annotation
@interface MyService {
@AliasFor(annotation = Component.class, attribute = "value")
String name() default ""; // overrides the value of @Component
}
@MyService(name = "greeter")
static class Greeter {
}
// Java reflection
clazz.getAnnotation(Component.class); // null — it is not placed directly
// Spring
MergedAnnotations annotations = MergedAnnotations.from(clazz, SearchStrategy.TYPE_HIERARCHY);
MergedAnnotation<Component> component = annotations.get(Component.class);
component.getString("value"); // "greeter"
component.synthesize().value(); // "greeter" — a real Component object
The output (full code in ex/u11/MergedAnnotationExample.java; labels translated, as above):
getAnnotation(Component) = null
getAnnotation(MyService) = @ex.u11.MergedAnnotationExample.MyService(name="greeter")
isPresent = true
isDirectlyPresent = false
isMetaPresent = true
getDistance = 1
getString(value) = greeter
getMetaSource = MyService
synthesized.value() = greeter
is it a proxy? = true
findMergedAnnotation(...).value() = greeter
bean names = [greeter]
Java cannot see @Component; Spring can, and the value came not from @Component's own value but from @MyService's name. The last line says that when Greeter was registered with the container, the bean name became greeter, because the AnnotationBeanNameGenerator from Part 8 reads the value through the same road (AnnotationBeanNameGenerator lines 135–148). Spring's own @Service works the same way; its attribute is called value rather than name, so it carries @AliasFor(annotation = Component.class) on value() with attribute left out (Service lines 53–54).
The whole flow
One call to MergedAnnotations.from(class, strategy) flows like this inside.
The yellow box, AnnotationTypeMappings, is computed once per annotation type and cached.
MergedAnnotations.from(element, strategy)checks its arguments and hands over toTypeMappedAnnotations.from()(MergedAnnotationslines 331–333, lines 373–383).MergedAnnotationsis an interface;TypeMappedAnnotationsis the implementation thatfrom()returns (TypeMappedAnnotationslines 34–42). Nothing is read yet at this point. Only cases that cannot possibly have annotations (a class in ajava.package, for example) are filtered up front and get the sharedNONEobject (lines 249–256,AnnotationsScannerlines 471–482).- When
get(Component.class)is called,scan()finally runs theAnnotationsScanner(lines 143–153, lines 236–246). The scanner decides, from the strategy, how far up the hierarchy to look (AnnotationsScannerlines 99–108) and, for each class, hands the array of directly declared annotations to a processor. - The processor,
MergedAnnotationFinder, takes each annotation, obtains theAnnotationTypeMappingsof its type, and if one of the mappings matches the requested type (Component) builds aTypeMappedAnnotationand returns it (lines 404–430). Which of several candidates wins is decided by aMergedAnnotationSelector, and the default is "the nearest one" (nearest).
The heart of it is the AnnotationTypeMappings of step 3.
AnnotationTypeMappings: the table that climbs the meta-annotations
The Javadoc of AnnotationTypeMappings says it "performs a recursive breadth first crawl of all meta-annotations" of one annotation type "to ultimately provide a quick way to map the attributes of a root annotation" (lines 34–52). The code is exactly that.
private void addAllMappings(Class<? extends Annotation> annotationType,
Set<Class<? extends Annotation>> visitedAnnotationTypes) {
Deque<AnnotationTypeMapping> queue = new ArrayDeque<>();
addIfPossible(queue, null, annotationType, null, false, visitedAnnotationTypes);
while (!queue.isEmpty()) {
AnnotationTypeMapping mapping = queue.removeFirst();
this.mappings.add(mapping);
addMetaAnnotationsToQueue(queue, mapping);
}
}
The root (@MyService) goes into the queue; each time one is taken out, the meta-annotations on its declaration are appended. Index 0 of the resulting list is always the root, and the further along, the more distant the meta-annotation (lines 162–173). The example's getDistance = 1 means @Component is one step from the root.
This table is about an annotation type, not an annotation instance, so it is computed once per type and kept in a static cache (lines 57–59, lines 231–253). A thousand classes marked @Service share one mapping of @Service.
isMappable(), which decides whether something goes into the queue, has two safeguards (lines 136–140).
- Anything matched by
AnnotationFilter.PLAINis skipped.PLAINmeans annotations in thejava.langandorg.springframework.langpackages (AnnotationFilterline 43).@Retention,@Targetand@Documentedsit on every annotation but mean nothing to Spring, so they are dropped from the start. This filter cannot be switched off; the interface Javadoc says that "theMergedAnnotationsmodel always ignores lang annotations according to thePLAINfilter (for efficiency reasons)" (lines 21–27). - A type already seen on the way up is skipped (
isAlreadyMapped, lines 142–152). That is what keeps a cycle — annotation A carrying B, B carrying A — from looping forever.
AnnotationTypeMapping: @AliasFor links attributes
One entry of the table is an AnnotationTypeMapping. Its constructor fetches the attribute methods of its annotation (AttributeMethods), reads the @AliasFor on each attribute to work out "who is an alias of whom", and then runs processAliases() (AnnotationTypeMapping lines 85–102, lines 115–127).
@AliasFor has three attributes. value and attribute mean the same thing (the name of the attribute to point at), and annotation says which annotation that attribute lives in (AliasFor line 191, line 200, line 207). Leave annotation empty and it means two attributes of the same annotation are aliases of each other (value and basePackages of @ComponentScan, ComponentScan line 80, line 98); name another annotation and it means override an attribute of a meta-annotation (the example's name → @Component.value). The Javadoc of AliasFor lists these two plus a third, "implicit aliases", where attributes pointing at the same meta-attribute automatically become aliases of each other (lines 26–49).
The core of the alias processing is this.
private void processAliases(int attributeIndex, List<Method> aliases) {
int rootAttributeIndex = getFirstRootAttributeIndex(aliases);
AnnotationTypeMapping mapping = this;
while (mapping != null) {
if (rootAttributeIndex != -1 && mapping != this.root) {
for (int i = 0; i < mapping.attributes.size(); i++) {
if (aliases.contains(mapping.attributes.get(i))) {
mapping.aliasMappings[i] = rootAttributeIndex;
}
}
}
mapping.mirrorSets.updateFrom(aliases);
mapping.claimedAliases.addAll(aliases);
// ...
mapping = mapping.source;
}
}
If the group of aliases contains an attribute of the root annotation, then for every meta-annotation attribute in that group it writes aliasMappings[i] = index of the root attribute. In the example, slot value (index 0) of the @Component mapping gets the note "read name (index 0) of the root @MyService". TypeMappedAnnotation.getValue(), which actually reads values, consults this table first.
private @Nullable Object getValue(int attributeIndex, boolean forMirrorResolution) {
AnnotationTypeMapping mapping = this.mapping;
if (this.useMergedValues) {
int mappedIndex = this.mapping.getAliasMapping(attributeIndex);
if (mappedIndex != -1) {
mapping = mapping.getRoot();
attributeIndex = mappedIndex;
}
}
// ...
if (mapping.getDistance() == 0) {
Method attribute = mapping.getAttributes().get(attributeIndex);
Object result = this.valueExtractor.extract(attribute, this.rootAttributes);
return (result != null ? result : attribute.getDefaultValue());
}
return getValueFromMetaAnnotation(attributeIndex, forMirrorResolution);
}
Call getString("value") and, since value (index 0) of @Component has a mapping, it goes up to the root and reads name of @MyService(name = "greeter"). An attribute with no mapping reads the value written in its own meta-annotation declaration (or the default). That is what "merged view" means: the real annotation objects are not modified; where a value is read from is redirected at read time.
synthesize(): a real annotation object with the merged values
Sometimes you need an object of type Component rather than pulling values out one by one with getString. synthesize() makes one. Its Javadoc says the result "is synthesized using a JDK Proxy and as a result may incur a computational cost when first invoked" (MergedAnnotation lines 503–526). The creating code is short.
static <A extends Annotation> A createProxy(MergedAnnotation<A> annotation, Class<A> type) {
ClassLoader classLoader = type.getClassLoader();
Class<?>[] interfaces = new Class<?>[] {type};
InvocationHandler handler = new SynthesizedMergedAnnotationInvocationHandler<>(annotation, type);
return (A) Proxy.newProxyInstance(classLoader, interfaces, handler);
}
The proxy's invoke() returns the value from the MergedAnnotation when the method called is an attribute name, and handles annotationType, equals, hashCode and toString separately (lines 74–90). The example's "is it a proxy? = true" is this object. Once made, the proxy is remembered inside the MergedAnnotation so it is not built twice (AbstractMergedAnnotation lines 202–213). An annotation with nothing to merge (no @AliasFor anywhere) gets no proxy; the original annotation object is returned as it is (TypeMappedAnnotation lines 330–343).
SearchStrategy: how far up the hierarchy
The second argument of from(), SearchStrategy, has four values (MergedAnnotations lines 644–684).
Further down means a wider search. Following meta-annotations happens regardless of the strategy.
| Strategy | Scope | Source |
|---|---|---|
DIRECT |
Only what is declared on the element itself; @Inherited is ignored |
line 651 |
INHERITED_ANNOTATIONS |
+ superclass annotations marked @Inherited (the same as Java's rule) |
line 661 |
SUPERCLASS |
+ every superclass, @Inherited or not; interfaces are not searched |
line 670 |
TYPE_HIERARCHY |
+ all implemented interfaces as well | line 682 |
With TYPE_HIERARCHY the scanner goes class itself → its interfaces (recursively) → its superclass (recursively). If it finds a directly declared match (distance 0) it stops there; if it has only found one through a meta-annotation, it keeps going to the end in case a nearer one turns up (AnnotationsScanner lines 173–233, MergedAnnotationSelectors lines 65–70). Searching enclosing (outer) classes is not one of the four; it is switched on with MergedAnnotations.search(TYPE_HIERARCHY).withEnclosingClasses(predicate) (lines 578–585).
The front doors: AnnotationUtils and AnnotatedElementUtils
Most of Spring's code does not call MergedAnnotations directly; it uses two utility classes, both of which call MergedAnnotations.from(...) inside.
| Method | What it does inside | Source |
|---|---|---|
AnnotationUtils.findAnnotation(clazz, type) |
from(clazz, TYPE_HIERARCHY).get(type).withNonMergedAttributes().synthesize(...) — follows meta-annotations but does not apply @AliasFor merging |
lines 570–595 |
AnnotatedElementUtils.findMergedAnnotation(element, type) |
from(element, TYPE_HIERARCHY)...synthesize() — merging included |
lines 631–641, lines 800–801 |
The one with Merged in its name gives you values with @AliasFor overrides applied. Both have a shortcut at the top: if the requested annotation is a java.lang one, or the class is in a java. package, they answer with plain Java reflection and never touch MergedAnnotations.
Easy to get wrong
getAnnotation()returningnulldoes not mean the annotation is absent. That is the first line of the example. What is attached through a meta-annotation is visible only through Spring's front doors. Conversely,MergedAnnotation.isPresent()answers for directly present and meta-present together; to tell them apart useisDirectlyPresent()andisMetaPresent()(MergedAnnotationlines 83–101).- Attributes that merely share a name are no longer merged. Older Spring had "convention-based overriding": an attribute with the same name as one in the meta-annotation overrode it even without
@AliasFor. In this commitAnnotationTypeMappinghas no such code (addConventionMappings); the name is in my study notes, butgrepon the source finds nothing. The Javadoc of@Componentalso says that "as of Spring Framework 6.1, support for configuring the name of a stereotype component by convention (i.e., via aString value()attribute without@AliasFor) is deprecated" and custom stereotypes must use@AliasFor(Componentlines 49–56). Remove the@AliasForfrom the example and the bean name is no longergreeter. @AliasFordoes nothing by itself. TheAliasForJavadoc says that "for alias semantics to be enforced, annotations must be loaded viaMergedAnnotations" (lines 51–54). Read through Java reflection,nameandvalueare just two unrelated attributes.- A badly written
@AliasForis an exception at read time. If the target attribute does not exist, the return types differ, or it points at itself, anAnnotationConfigurationExceptionis thrown (AnnotationTypeMappinglines 132–188) — not when the annotation is declared, but the first timeMergedAnnotationsmeets that type.
4. MetadataReader — reading without loading the class
Why read without loading
Component scanning in Part 8 opens every .class file in a package and checks for @Component. If it called Class.forName on each one, two things would go wrong. Every class, including those that will never become beans, would be loaded into memory, and the static {} blocks and the chain of superclass and interface loading would come along with it. If a superclass is missing from the classpath you get a NoClassDefFoundError and the scan itself stops. So Spring only reads the file.
Example: the static block does not run
// An example written for this post (not Spring's code)
@Service("heavy")
public class Heavy {
static {
System.out.println(" >>> Heavy's static block ran (the class was initialised)");
}
}
MetadataReaderFactory factory = new SimpleMetadataReaderFactory();
MetadataReader reader = factory.getMetadataReader("ex.u11.Heavy"); // only a name is given
AnnotationMetadata metadata = reader.getAnnotationMetadata();
metadata.getClassName(); // "ex.u11.Heavy"
metadata.hasMetaAnnotation(Component.class.getName()); // true
metadata.getAnnotations().get(Component.class).getString("value"); // "heavy"
Class.forName("ex.u11.Heavy"); // only now the static block
The output (full code in ex/u11/MetadataReaderExample.java and Heavy.java; labels translated, as above):
Java 21 / default factory = SimpleMetadataReaderFactory
1. Reading the metadata
class name = ex.u11.Heavy
directly present annotations = [org.springframework.stereotype.Service]
is @Component meta-present = true
value of @Component = heavy
(the static block has not run yet)
2. Loading and initialising with Class.forName
>>> Heavy's static block ran (the class was initialised)
3. Done
The class name, the annotations on it, the meta-annotation, even the value merged through @AliasFor were all read, yet the static block ran only at the Class.forName line. The "default factory" on the first line is explained below.
Two roads, one interface
Bytecode on the left, reflection on the right. Whichever built it, the caller only sees AnnotationMetadata.
ClassMetadata is the interface that "defines abstract metadata of a specific class, in a form that does not require that class to be loaded yet" (ClassMetadata lines 21–31). Its methods — getClassName(), isInterface(), isAbstract(), getSuperClassName(), getInterfaceNames() — return either a boolean or names (Strings); not one of them returns a Class, because returning a Class would require loading it. AnnotationMetadata adds the annotation side (AnnotationMetadata lines 27–48); methods such as hasMetaAnnotation() are default methods, and their bodies simply ask the MergedAnnotations returned by getAnnotations() (lines 97–100). The tool of section 3 appears again here.
There are two implementations.
StandardAnnotationMetadata— reflection. Built byAnnotationMetadata.introspect(Class)(lines 141–143); its constructor callsMergedAnnotations.from(introspectedClass, INHERITED_ANNOTATIONS, ...)(StandardAnnotationMetadatalines 86–87). Used when you already hold theClass.SimpleAnnotationMetadata— bytecode. Produced by aMetadataReader.
The bytecode road: SimpleMetadataReader and the ASM visitor
MetadataReader is an interface with only three methods: getResource(), getClassMetadata(), getAnnotationMetadata() (MetadataReader lines 23–49). In the ASM implementation, SimpleMetadataReader, the constructor does all the work.
SimpleMetadataReader(Resource resource, @Nullable ClassLoader classLoader) throws IOException {
SimpleAnnotationMetadataReadingVisitor visitor = new SimpleAnnotationMetadataReadingVisitor(classLoader);
getClassReader(resource).accept(visitor, PARSING_OPTIONS);
this.resource = resource;
this.annotationMetadata = visitor.getMetadata();
}
private static ClassReader getClassReader(Resource resource) throws IOException {
try (InputStream is = resource.getInputStream()) {
try {
return new ClassReader(is);
}
catch (IllegalArgumentException ex) {
throw new ClassFormatException("ASM ClassReader failed to parse class file - " +
"probably due to a new Java class file version that is not supported yet. " +
"Consider compiling with a lower '-target' or upgrade your framework version. " +
"Affected class: " + resource, ex);
}
}
}
It opens an input stream on the Resource (Part 12), hands it to ASM's ClassReader, attaches a visitor and calls accept. PARSING_OPTIONS is SKIP_DEBUG | SKIP_CODE | SKIP_FRAMES: debug information, method bodies and stack frames are not read (lines 38–39), because only annotations and signatures are needed. ClassFormatException is thrown when ASM meets a class-file version it does not know; it arrived in 6.1.2 and extends IOException, so existing catch (IOException) blocks in callers catch it as before (ClassFormatException lines 23–33).
ASM reads the file from the front and reports "saw the class declaration", "saw an annotation", "saw a method" by calling methods on the visitor. SimpleAnnotationMetadataReadingVisitor is that visitor (lines 37–44). In visit() it stores the class name, superclass and interface names (lines 77–89); annotations and the end are handled like this.
@Override
public @Nullable AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
return MergedAnnotationReadingVisitor.get(this.classLoader, getSource(),
descriptor, visible, this.annotations::add);
}
// ...
@Override
public void visitEnd() {
MergedAnnotations annotations = MergedAnnotations.of(this.annotations);
this.metadata = new SimpleAnnotationMetadata(this.className, this.access,
this.enclosingClassName, this.superClassName, this.independentInnerClass,
this.interfaceNames, this.memberClassNames, this.declaredMethods, annotations);
}
For each annotation, a MergedAnnotationReadingVisitor collects attribute names and values into a map and then builds a MergedAnnotation with MergedAnnotation.of(classLoader, source, type, attributeMap) (MergedAnnotationReadingVisitor lines 93–99). At the end of the file the list is bundled with MergedAnnotations.of(...) (MergedAnnotations lines 465–467) and becomes the same MergedAnnotations interface we saw in section 3. The implementing class is MergedAnnotationsCollection rather than TypeMappedAnnotations, but it uses the same AnnotationTypeMappings inside (MergedAnnotationsCollection lines 48–60), so meta-annotation mapping and @AliasFor merging work the same way. That is why, in the example, the value of @Service("heavy") came out as @Component's value even though it was read from bytecode.
Factories, caching, and Java 24
A MetadataReader is created by a MetadataReaderFactory. Given a class name it builds a Resource from classpath: + the package with dots turned into / + .class, and if the file is not found it tries once more with the last . replaced by $, in case an inner class was written with dot notation (AbstractMetadataReaderFactory lines 56–80). There are three implementations.
| Implementation | What it does | Source |
|---|---|---|
SimpleMetadataReaderFactory |
A new ASM ClassReader for every request |
lines 26–64 |
ClassFileMetadataReaderFactory |
Reads with the JDK's standard java.lang.classfile API, available from Java 24. Lives in a separate src/main/java24 folder |
java24/.../ClassFileMetadataReaderFactory lines 26–63 |
CachingMetadataReaderFactory |
Wraps one of the two and caches the result per Resource |
lines 30–48, lines 122–145 |
Whether ASM or the ClassFile API is used is decided by MetadataReaderFactory.create(...) (lines 63–81). The MetadataReaderFactoryDelegate it calls exists twice under the same name.
static MetadataReaderFactory create(@Nullable ResourceLoader resourceLoader) {
return new ClassFileMetadataReaderFactory(resourceLoader);
}
The same method under src/main/java returns new SimpleMetadataReaderFactory(resourceLoader) (java/.../MetadataReaderFactoryDelegate lines 33–35). spring-core is built as a multi-release jar that packs Java 21 and Java 24 classes together (spring-core.gradle lines 13–15), so on a JVM of 24 or later the java24 class is picked. That is why the example, on Java 21, printed SimpleMetadataReaderFactory. The container's component scan uses new CachingMetadataReaderFactory(resourceLoader) (ClassPathScanningCandidateComponentProvider line 267), whose constructor picks the inner factory with MetadataReaderFactory.create() (CachingMetadataReaderFactory lines 64–88).
org.springframework.asm: a copy of ASM inside Spring
The ClassReader that SimpleMetadataReader imports is org.springframework.asm, not org.objectweb.asm. The package Javadoc calls it "Spring's repackaging of ASM 9.x (with Spring-specific patches; for internal use only)", done so as to avoid "any potential conflicts with dependencies on ASM at the application level or from third-party libraries and frameworks" (asm/package-info.java lines 1–14). Counting the folder gives 33 Java files besides package-info.java, and the first line of each is ASM's copyright notice, not Spring's (ClassReader lines 1–2). The one file written on Spring's side is SpringAsmInfo, which holds the ASM API version constant (SpringAsmInfo line 36). The internals of ASM are not covered in this post.
Easy to get wrong
- The class being read is not loaded, but the annotation classes are.
MergedAnnotationReadingVisitor.get()loads the annotation's type name withClassUtils.forName()and silently skips the annotation if it cannot (lines 134–153), because climbing meta-annotations requires looking at the annotation declaration through reflection. In the example,Heavywas never loaded, but@Serviceand@Componentwere. The Javadoc ofAnnotationMetadatawarns likewise: "if an annotation cannot be loaded because one of its attributes references aClassorEnumthat is not present in the classpath, that annotation will not be accessible via theAnnotationMetadataAPI" (AnnotationMetadatalines 31–37). - The
.classfile is found on the classpath.getMetadataReader("name")looks up aclasspath:resource through aResourceLoader, so if the class is not on the classpath you get aFileNotFoundException(anIOException), not aClassNotFoundException. - Method bodies are not read. That is
SKIP_CODE. You can learn that a@Beanmethod exists, but not what it does inside — and that is not the purpose of this tool.
5. Where the container uses them
Let us pair the places skipped in earlier posts with the three tools.
| Scene from an earlier post | Tool used | Source |
|---|---|---|
All Service beans are collected into an @Autowired List<Service> (Part 5) |
The element type comes from descriptor.getResolvableType().asCollection().resolveGeneric() |
DefaultListableBeanFactory line 1866, DependencyDescriptor lines 267–275 |
A Repository<Order> does not go into a Repository<User> (Part 5) |
The candidate bean's type is matched down to generics with ResolvableType.isAssignableFrom |
GenericTypeAwareAutowireCandidateResolver lines 76–80 |
An ApplicationListener<OrderEvent> receives only OrderEvent (Part 12) |
The bean definition's type is lifted with .as(ApplicationListener.class).getGeneric() and compared with the event type |
AbstractApplicationEventMulticaster lines 356–357 |
Component scanning treats @Service as @Component (Part 8) |
AnnotationTypeFilter(Component.class) asks hasMetaAnnotation on the AnnotationMetadata read by a MetadataReader |
ClassPathScanningCandidateComponentProvider line 217, lines 464–466, AnnotationTypeFilter lines 99–103 |
Deciding which bean definitions are @Configuration classes (Part 8) |
AnnotationMetadata.introspect if the class is already loaded, otherwise a MetadataReaderFactory |
ConfigurationClassUtils lines 114–143 |
Adding @EnableAsync brings @Import with it (Part 9) |
The configuration class's annotations are followed recursively and the value of each @Import collected |
ConfigurationClassParser lines 568–578, EnableAsync line 167 |
The name in @Service("greeter") becomes the bean name (Part 8) |
For each directly present annotation, its meta-annotation types are checked and value is read if it is @Component-based |
AnnotationBeanNameGenerator lines 135–148 |
The left side is when the class may not even be loaded yet; the right side is when the beans and their types already exist. The source location for each box is in the table above.
In one line: when the container talks about a "type" it is ResolvableType, when it talks about an "annotation" it is MergedAnnotations, and when it talks about a class it has not loaded yet, MetadataReader builds those two on its behalf.
If you want to go deeper
A lot was left out to keep the main thread visible. Here they are, named with their locations. VariableResolver, which resolves type variables against an outer type, and the two implementations inside ResolvableType (ResolvableType lines 1573–1637); WildcardBounds, which handles upper and lower bounds of wildcards (line 1701); forClassWithGenerics, which assembles a generic type in code (lines 1162–1193), and the SyntheticParameterizedType it uses (line 1640). On the annotation side: MirrorSets, which checks that attributes aliased to each other within one annotation do not carry different values (AnnotationTypeMapping line 557); RepeatableContainers, which unpacks the container of a @Repeatable annotation; MergedAnnotationSelectors, which picks among candidates of the same type; AnnotationsScanner.processMethodHierarchy, which looks for a method annotation on the same method in superclasses and interfaces (line 261); and SynthesizingMethodParameter, which the web layer uses to read parameter annotations synthesized. On the bytecode side: SimpleMethodMetadataReadingVisitor and MethodMetadata, which read method metadata; the actual contents of the Java 24 implementation (ClassFileMetadataReader, ClassFileAnnotationDelegate); and the difference between CachingMetadataReaderFactory using the shared cache of a DefaultResourceLoader and using its own LRU cache (256 entries by default) (CachingMetadataReaderFactory line 43, lines 164–187).
Recap
| Concept | In one line |
|---|---|
| Type erasure | A running object knows nothing of its generics, but generics written in field and method declarations survive in the .class file |
ResolvableType |
Wraps a Java Type; navigate with getGeneric() and as(), get a Class with resolve(). Nothing there means NONE, not null |
forType cache |
Only non-Class types are cached, in a static ConcurrentReferenceHashMap, as resolved results. The caller always gets a new object |
SerializableTypeWrapper |
The JDK's Type implementations are not serializable, so they are wrapped in a proxy that remembers "which field this was" |
| Meta-annotation | An annotation on an annotation. Java reflection does not see it; Spring climbs up to it |
MergedAnnotations |
The merged view of a set of annotations. Built with from(element, strategy), queried with get(type) |
AnnotationTypeMappings |
The breadth-first table of one annotation type's meta-annotations. Cached per type |
@AliasFor |
Links two attributes of one annotation, or an attribute of a meta-annotation. Stored in aliasMappings as "read attribute N of the root" |
synthesize() |
A JDK dynamic-proxy annotation object that returns the merged values |
SearchStrategy |
DIRECT → INHERITED_ANNOTATIONS → SUPERCLASS → TYPE_HIERARCHY. They differ only in which classes are searched |
AnnotationFilter.PLAIN |
java.lang and org.springframework.lang annotations are always skipped |
AnnotationMetadata |
Class and annotation information in a form that does not need the class loaded. Two implementations: reflection (Standard…) and bytecode (Simple…) |
MetadataReader |
Reads a .class file with ASM (or the ClassFile API on Java 24+) and builds an AnnotationMetadata. No static {} runs |
CachingMetadataReaderFactory |
Reads each Resource only once. What component scanning uses |
org.springframework.asm |
ASM 9.x copied into Spring to avoid conflicts |
What to look at next
The ResolvableType of this post only answered "what is the type". The next post is about converting values to that type: how the string "8080" from a configuration file ends up in an int field and "2026-10-10" becomes a LocalDate — that is, ConversionService, PropertyEditor, and BeanWrapper, which writes values into a bean's properties. TypeDescriptor appears there with this post's ResolvableType tucked inside it.
- Next: Digging into Spring's Source 14: Type Conversion and BeanWrapper — How a String Becomes an Object's Property
- Where
ResolvableTypeis used for injection: Part 5: How @Autowired Picks the Bean to Inject - Where
MetadataReaderis used for scanning: Part 8: How @Configuration and @ComponentScan Become Blueprints @Importarriving as a meta-annotation: Part 9: @Conditional, @Profile, @Import- The map of the series: Part 1: How I Read It with AI, and the Map