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

  1. The Java Virtual Machine Specification, chapter 4 — The class File Format — Oracle
  2. The Java Language Specification, section on annotations — Oracle
  3. ECMA-335: Common Language Infrastructure (CLI), Partition II — Metadata — Ecma International
  4. .NET API browser — System.Reflection — Microsoft