Programming Languages · Metaprogramming
Runtime Reflection: Metadata Layout and Dispatch Cost
What a class descriptor actually contains, why reflective invocation cannot be inlined, and how annotation lookup and dynamic proxies turn a static call into a table walk.
A program that inspects its own types needs those types to exist as data. The compiler must therefore emit, alongside the code, a description of every class: its name, its fields and their types, its methods and their signatures, its superclass, the interfaces it implements, and whatever annotations were attached to each of those. That description is a data structure with a cost, and every reflective operation is a traversal of it.
The descriptor is a data structure, not a convenience
In the Java class file format, a class’s structure is written as a constant pool plus a set of
tables. The constant pool holds the literal constants the class needs — strings, numbers, class and
member references — each as a tagged entry with an index. Fields and methods are described by
field_info and method_info structures, each carrying an access flag word, a name index, a
descriptor index, and a list of attribute structures. The descriptor is a string in a small grammar:
Ljava/lang/String; for a reference type, [I for an array of integers, (IJ)V for a method taking
an int and a long and returning nothing.
// entries in the constant pool of a class file, as javap -v prints them
#8 = Class #12 // java/lang/StringBuilder
#12 = Utf8 java/lang/StringBuilder
#13 = Utf8 append
#14 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#15 = NameAndType #13:#14 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#16 = Methodref #8:#15 // java/lang/StringBuilder.append:(...)...
That string form is why reflective invocation is expensive before any method runs. A call through
Method.invoke receives its arguments as an Object[], and each one must be converted from its boxed
representation to the primitive or reference type the descriptor names. A long argument arrives as
a Long object and must be unboxed to its 64-bit primitive form; a double likewise. The array
itself must be allocated by the caller, and the varargs convention at the source level means the
compiler allocates one even when the reflective call has a fixed argument count.
Annotations add a third table. Each annotation is stored as an attribute on the element it annotates,
with the annotation type’s descriptor and a list of name-value pairs. The values are encoded in a
typed format, so reading one requires knowing the annotation’s declared element types — which means
the reflective lookup has to load the annotation class as well. The retention policy decides whether
the attribute is present at all: SOURCE annotations are discarded by the compiler, CLASS
annotations are written to the class file but not exposed by reflection, and RUNTIME annotations are
decoded from the stored attribute when a reflective query first asks for them. The decoding is
memoised on the class: the Class object keeps a lazily filled cache of parsed annotations, rebuilt
only when the class is redefined by an instrumentation agent. What is not cached across calls is the
result array itself — getAnnotations allocates and copies a fresh array on every call, and the
@Inherited superclass merge performed by getAnnotation and getAnnotationsByType repeats its
walk each time. Only RUNTIME is visible to reflection, and the distinction exists precisely
because the metadata is not free.
Why the call cannot be optimized away
A direct call to a non-virtual method compiles to a branch to a known address, and the callee’s body can be inlined, its parameters kept in registers, and its result forwarded. A reflective call has none of those properties, and the reasons are structural rather than a matter of optimizer effort.
The target is a value, not a symbol. Method.invoke receives an object whose identity is determined
at run time, so the compiler cannot know which method body will run, and the JIT cannot inline a body
it has not seen. A JIT that observes the same Method object being invoked with the same receiver
type repeatedly can inline the dispatch — the lookup that maps the Method to a compiled entry
point — but the arguments still arrive as an Object[] and must be unpacked into the callee’s
calling convention.
The exception behaviour differs too. A reflective invocation wraps any exception thrown by the target
in a new exception type, because the static signature of invoke cannot declare the target’s checked
exceptions. The wrapping is not free — it allocates, captures a stack trace, and destroys the original
exception’s type for any handler that wanted to catch it specifically.
Access checks are re-performed. A reflective call checks that the caller has access to the member at the point of invocation, and that check is a walk over the caller’s class and its class loader. It can be suppressed by an explicit access override, which is exactly why such an override is a security boundary and why module systems restrict it.
The cumulative effect is that a reflective call does strictly more work than a direct one: allocate the argument array, box every primitive, re-check access, dispatch through a value, and unwrap any exception. None of that work is removable by warm-up, because none of it is dispatch overhead — the boxing and the array are in the interface’s signature. The standard mitigation is to resolve the member once and cache it, then invoke through a generated accessor — a class synthesised at run time that performs the call with the correct static types. That moves the cost to class generation, which happens once per target rather than once per call.
Lookup is a search, and its shape matters
Class.getMethod(name, parameterTypes) performs a search over the class’s declared methods, then over
its superclass’s, and so on up the hierarchy, with interface methods reachable through a separate
path. The search compares names first and then parameter types, and the parameter types must be
compared structurally because two class loaders can define distinct classes with the same name — a
Class object’s identity is (name, defining loader), and two same-named classes from different
loaders are not equal.
The cost of the search is proportional to the number of methods in the hierarchy, which is why
reflection-heavy code caches Method and Field objects rather than looking them up per call. A
class with a few hundred methods and a deep hierarchy makes each uncached lookup a linear scan with
string comparisons, and the whole scan is one traversal of a metadata structure that the virtual
machine has already materialised by the time any reflective call can reach it.
Annotation lookup has a worse shape. getAnnotations() on a class returns all annotations directly
present on that class; getAnnotationsByType and getAnnotation also consult the superclass chain
when the annotation type is marked @Inherited, and merge. The parse of the raw attribute is cached,
but each call still allocates the result array, and the inherited-annotation path walks the
superclass chain on every call, so a framework that scans every class in a package pays for the array
allocation per class per query, plus the cost of enumerating the package — which, without an index,
means reading the classpath and opening each entry.
Proxies, interception, and the cost of indirection
Reflection is the substrate for a second layer: dynamic proxies. A proxy class is generated at run
time, implements a set of interfaces, and forwards every method call to a single handler that receives
the Method object and the arguments. The generation itself is the expensive part — the class must be
defined in a loader, verified, and linked — but it happens once per interface set, and the resulting
class is an ordinary class afterwards.
The steady-state cost is the handler dispatch. Every call through the proxy is a virtual call to the
handler’s invoke, which then typically performs a reflective invocation on the target, so a single
proxied call pays both the proxy’s indirection and the reflective call’s overhead. Frameworks that
care reduce this by generating a class that calls the target directly rather than reflectively, at
the price of generating a class per target type.
The alternative to proxies is a call site that the runtime can optimise: a method handle, or a
first-class function value. A method handle is a typed reference to a method with its calling
convention baked in, so invoking it does not require unpacking an Object[], and a JIT can inline
through it once the handle is a constant. The design trade is explicit: a handle is resolved eagerly
and typed, a Method is resolved by name and untyped. The handle is faster and less flexible; the
Method is flexible and pays for it on every call.
Where the metadata is not available
Reflection only works on types the runtime can describe. Three situations break it.
Types that were erased. A generic type argument does not exist at run time, so List<String> and
List<Integer> are the same Class object, and no amount of reflection recovers the argument. That
cap is what limits a reflective framework’s ability to infer a collection’s element type, and it
cannot be recovered by inspecting the object: the argument was never written into the class file’s
descriptor for the field or the local variable.
Types that were never loaded. A class is described only after its class file is read and its constant pool resolved, and resolution is lazy. A reflective scan of a package can therefore trigger loading of classes the program would otherwise never touch, and the failure mode is an exception during scanning rather than during use.
Types that were removed. Trimming and ahead-of-time compilation discard metadata that nothing
apparently references, and reflection’s references are by name, which the analysis cannot see. A
program that reflects on a class by string name may find it absent after trimming unless the build
declares the dependency. The declaration exists because the analysis genuinely cannot derive it:
Class.forName("com.example.Foo") and Class.forName(prefix + suffix) are indistinguishable to a
static analyser, and only the second is common enough that blanket retention of everything is
sometimes the only sound answer.
The general position of reflection in a language is therefore a trade between dynamism and every static technique. The metadata enables late binding, framework-driven configuration, and serialisation without per-type code; it disables inlining across the reflective boundary, defeats dead-code elimination, and forces the runtime to keep type descriptions that a fully static program would not need. Languages that omit reflection, or restrict it to a compile-time-only form, are buying back those techniques. The compile-time alternative to a runtime annotation scan is described in derive macros and structural recursion, which performs the same traversal over the type definition during translation, where the type information is still present as syntax rather than as a loaded descriptor.
References
- The Java Virtual Machine Specification, chapter 4 — The class File Format — Oracle
- The Java Language Specification, section on annotations — Oracle
- ECMA-335: Common Language Infrastructure (CLI), Partition II — Metadata — Ecma International
- .NET API browser — System.Reflection — Microsoft