namalzi

← All posts

Digging into Spring's Source 13: ResolvableType and MergedAnnotations — How Spring Reads Types and Annotations

Spring · Published

How Spring finds the String in List<String> (ResolvableType), how it reads annotations stacked on annotations and @AliasFor as one merged view (MergedAnnotations), and how it reads a .class file without loading the class (MetadataReader) — checked against the real source with runnable examples, for someone new to Spring.

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.

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.

The three tools of spring-core and who uses them. DependencyDescriptor and the event multicaster use ResolvableType; AnnotationUtils and AnnotationMetadata use MergedAnnotations; component scanning and the Configuration check use MetadataReader. What MetadataReader reads becomes MergedAnnotations. 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).

Navigating Map<String, List> with ResolvableType as a tree. getGeneric(0) is String, getGeneric(1) is List, getGeneric(0) on that is Integer, and getGeneric(1, 0) takes both steps at once. An out-of-range getGeneric(5) returns NONE, and resolve() on NONE is null. 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 road from forField to forType. FieldTypeProvider remembers the field and SerializableTypeWrapper wraps the Type in a serializable proxy. If the Type is a plain Class it is wrapped and returned without caching; otherwise a key ResolvableType is built and the cache is checked. On a miss the type is resolved with resolveClass and stored; on a hit the resolved Class is copied into the new object. 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.

  1. A plain Class is not cached. There is nothing to resolve, so it is wrapped and returned at once. The comment says "not worth caching".
  2. The cache key is a ResolvableType itself. equals() compares the Type, the TypeProvider and the VariableResolver (lines 992–1015), so asking about the same field twice produces the same key. The map is a ConcurrentReferenceHashMap (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.
  3. What is returned is not the cache entry but a new object. The last two lines only copy resolved out 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

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 flow of a MergedAnnotations.from call. AnnotationsScanner collects directly declared annotations from the class, its interfaces and its superclass in that order; AnnotationFilter.PLAIN drops java.lang annotations. AnnotationTypeMappings walks each annotation's meta-annotations breadth first, producing mapping 0 (MyService, distance 0) and mapping 1 (Component, distance 1, whose aliasMappings links name to value). The TypeMappedAnnotation returned by get(Component.class) reads name() on the root for getString, giving greeter, and makes a JDK dynamic proxy for synthesize. The yellow box, AnnotationTypeMappings, is computed once per annotation type and cached.

  1. MergedAnnotations.from(element, strategy) checks its arguments and hands over to TypeMappedAnnotations.from() (MergedAnnotations lines 331–333, lines 373–383). MergedAnnotations is an interface; TypeMappedAnnotations is the implementation that from() returns (TypeMappedAnnotations lines 34–42). Nothing is read yet at this point. Only cases that cannot possibly have annotations (a class in a java. package, for example) are filtered up front and get the shared NONE object (lines 249–256, AnnotationsScanner lines 471–482).
  2. When get(Component.class) is called, scan() finally runs the AnnotationsScanner (lines 143–153, lines 236–246). The scanner decides, from the strategy, how far up the hierarchy to look (AnnotationsScanner lines 99–108) and, for each class, hands the array of directly declared annotations to a processor.
  3. The processor, MergedAnnotationFinder, takes each annotation, obtains the AnnotationTypeMappings of its type, and if one of the mappings matches the requested type (Component) builds a TypeMappedAnnotation and returns it (lines 404–430). Which of several candidates wins is decided by a MergedAnnotationSelector, 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);
		}
	}

spring-core/src/main/java/org/springframework/core/annotation/AnnotationTypeMappings.java, lines 81–91

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).

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;
		}
	}

spring-core/src/main/java/org/springframework/core/annotation/AnnotationTypeMapping.java, lines 224–249

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);
	}

spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotation.java, lines 395–417

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);
	}

spring-core/src/main/java/org/springframework/core/annotation/SynthesizedMergedAnnotationInvocationHandler.java, lines 286–291

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).

The four SearchStrategy values in widening order. DIRECT is only what is declared on the class itself; INHERITED_ANNOTATIONS adds superclass annotations marked Inherited; SUPERCLASS adds every superclass; TYPE_HIERARCHY adds implemented interfaces too. All four follow meta-annotations; they differ only in which classes are searched. 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

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

The two roads to class metadata. When the class is already loaded, AnnotationMetadata.introspect(Class) builds a StandardAnnotationMetadata that reads via reflection. Without loading, MetadataReaderFactory turns the class name into a classpath path and a Resource, the ASM ClassReader reads the bytes and calls visit, visitAnnotation and visitMethod on SimpleAnnotationMetadataReadingVisitor, and the result is a SimpleAnnotationMetadata. On Java 24 and later the ClassFile API is used instead of ASM. CachingMetadataReaderFactory wraps the factory so each Resource is read only once. Both roads end in the AnnotationMetadata 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.

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);
			}
		}
	}

spring-core/src/main/java/org/springframework/core/type/classreading/SimpleMetadataReader.java, lines 46–65

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);
	}

spring-core/src/main/java/org/springframework/core/type/classreading/SimpleAnnotationMetadataReadingVisitor.java, lines 111–135

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);
	}

spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java, lines 33–35

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

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 seven scenes from the table above, sorted by when they happen. While building definitions (Parts 8–9), MergedAnnotations and MetadataReader are used: @EnableAsync is followed recursively through annotations to collect @Import; @Service("greeter") becomes a bean name after checking that a meta-annotation is in the @Component family; telling @Configuration classes apart uses introspect if the class is loaded and MetadataReader otherwise; and the component scan treats @Service as @Component via MetadataReader → AnnotationMetadata → hasMetaAnnotation. While creating beans and sending events (Parts 5 and 12), ResolvableType is used: it gets the element type Service of @Autowired List, reads ApplicationListener with .as(ApplicationListener.class).getGeneric(), and uses isAssignableFrom down to the generics so that a Repository does not go into a Repository slot 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.

More posts