The question before the tool
A program normally knows the types it uses while being compiled: it calls a method because the compiler can verify its name, parameters and accessibility. In some systems, however, the concrete type is chosen later. A framework may look for annotated classes, a serializer may examine a record's structure, a diagnostic tool may show the available methods. The Reflection API allows us to observe information about types and, within the permitted access boundaries, construct objects or invoke members at runtime. It is not intended to make every Java program “dynamic”; it serves when the structure itself is part of the problem's data.
In the classloader chapter we saw how a definition reaches the JVM. Reflection starts from an already available Class<?> object and asks what that definition exposes. We can obtain the object with Book.class, with object.getClass() or with a name lookup such as Class.forName(name). The three forms are not always interchangeable: the first has a type known to the compiler, the second starts from an existing instance, the third can receive a name chosen at runtime and must handle ClassNotFoundException.
In chapter 20A we encountered VarHandle as an advanced tool for accessing variables with specific operations and memory orders. A VarHandle is not the same as a reflective Field: it can also be obtained through a lookup with MethodHandles.Lookup, but its main task is to perform typed accesses to the variable according to the handle's contract. Reflection in this chapter primarily answers the question “which members does this type have?”. We do not use merely discovering a field to declare a concurrent modification safe.
Definition – Metadata. Metadata describes another element. A class name, its public methods, a parameter's type and an annotation available at runtime are metadata. Reflection reads or uses these descriptions; it does not retroactively change compiled source. The value of a field of a specific instance, on the other hand, is object state, which can be accessed through a reflective member when the rules permit it.
A program that lets itself be inspected
In the complete program we define a Book record with two components, title and pages, and a description() method. The record class carries the annotation @Label("catalog"). The program obtains Book.class, calls isRecord(), iterates through getRecordComponents() and reads the annotation with getAnnotation(Label.class). The printed sequence starts with the binary name ExploreReflection$Book, followed by record: true, the two component names and the label.
The $ symbol in the binary name comes from Book being declared as a member record of ExploreReflection, to keep the example in one file. A record declared as a top-level type would have a different name. This observation is useful when comparing the name used in the source with the one required by Class.forName or shown by a classloader.
The annotation defines @Retention(RetentionPolicy.RUNTIME). Without this policy, getAnnotation could not retrieve it during execution. getAnnotation returns the requested annotation or null: the program knows its own type is annotated and uses value() directly, but a generic reader should check the result. Annotations alone execute no logic; the code reading them decides what they mean. Here the string “catalog” is merely descriptive data.
Constructing and invoking
getConstructor(String.class, int.class) looks for a public constructor with exactly those parameter types and returns a Constructor<Book>. Calling newInstance("Java", 320) creates a Book; the compiler has not written new Book("Java", 320) directly at the point of use, but parameter checking and invocation still occur according to the reflective constructor's contract. Then getMethod("description") looks for a public method without parameters and invoke(book) executes it on that instance. The result is printed as Java (320 pages).
Constructor<Book> constructor = type.getConstructor(String.class, int.class);
Book book = constructor.newInstance("Java", 320);
Method method = type.getMethod("description");
System.out.println(method.invoke(book));
The reflective form requires more checks than an ordinary call. The method might not exist, the signature might differ, the caller might lack access, the constructor might throw an exception. In the example, main declares ReflectiveOperationException because this is a demonstration program; an application should translate or propagate errors with a message explaining which component did not respect the contract. If the invoked method throws an exception, Method.invoke wraps it in InvocationTargetException, whose cause must be examined to understand the original problem.
Diagram – Where checking moves.
book.description()is checked by the compiler against the static type ofbook.method.invoke(book)checks at runtime thatmethodcan be invoked on that object. Flexibility comes at the cost of later checks and makes code harder to read, refactor and verify automatically. For a call whose type is already known, the direct form remains clearer.
Public methods, declared methods and accessibility
getMethod looks for a public method, taking inheritance into account. getDeclaredMethod looks for a method declared directly by the type, even if it is not public, but merely obtaining its Method object does not grant the right to invoke it. The same distinction applies to getFields and getDeclaredFields, and to public and declared constructors. When building an inspection tool, we must choose whether we are interested in the public API seen by callers or the examined class's internal structure. Mixing the two views can produce a misleading report.
In Java 25, modules also matter for reflection. Exporting a package makes public types available under modular rules; opening a package with opens enables deep reflective access within the declared limits. setAccessible(true) is not a master key: if access cannot be enabled, InaccessibleObjectException may be thrown. A framework requiring reflection on non-public members must document its module configuration. We do not suggest opening all packages indiscriminately or using JVM bypass options as a general solution: the encapsulation boundary is part of the program's architecture.
Further reading – Why
trySetAccessibleexists. The method attempts to enable access and returns a Boolean. This allows refusal to be handled explicitly when the application provides an alternative path. If the operation is indispensable, the correct solution is to define a module and API contract compatible with that need. Reflection on a present class does not automatically justify access to every private detail either.
Generic types and class identity
A Class<List> object does not retain as part of its identity every type argument used in a List<String> or List<Integer> variable. Generic erasure, already studied, does not disappear with reflection. Some declarations do retain generic metadata, however: getGenericReturnType() can describe a method's declared signature, and the java.lang.reflect.Type APIs represent forms such as parameterized types and type variables. This does not mean that, by observing an arbitrary ArrayList instance, we can always retrieve the String argument with which a variable was declared. For a framework that needs to know the element type, the contract must provide sufficient information.
The type identity explained in the classloader chapter also applies. Two Class<?> objects with the same getName() do not necessarily represent the same type if they come from different defining loaders. A plugin registry using only the textual name as its key can confuse isolated definitions. When the program passes an object to Method.invoke or attempts a cast, the JVM checks the true identity of the type, rather than merely the letters in its name.
Before invoking: building a reliable description
Imagine extending the inspector to types we did not write ourselves. Printing only getName() is not enough: an int parameter, an Integer class and an int[] array require different treatment. Class<?> also represents primitives, arrays and void; it does not always mean “class to instantiate”. We use isPrimitive(), isArray() and getComponentType() to recognize these forms. For a closed hierarchy, isSealed() and getPermittedSubclasses() describe directly permitted subclasses; they do not enumerate all objects that may exist. This information helps validate a contract before attempting operations that make no sense.
For names, we distinguish the binary name, suitable for looking up ordinary classes, from the canonical name, close to the source form. getCanonicalName() may return null for local or anonymous classes. To show a type to a reader we can use getTypeName(), but a readable string does not replace the identity of the Class object. Arrays also have special binary names: we should not construct a loading protocol by manually concatenating square brackets to a class name.
The inspector must also state which member view it is showing. An inherited method is useful to someone wanting to know what they can call; a private field declared in the superclass belongs to a different view. To examine the latter, we must work upward with getSuperclass() and query each type. Constructors are not inherited: looking for a subclass's constructor does not look for its parent's constructor. The order returned for fields and methods is not a stable presentation to use in a comparison file. Sort explicitly by name and signature; for record components, on the other hand, preserve declaration order, which is also significant for the canonical constructor.
Further reading – The compiler leaves traces. A bridge method is a bridge generated to preserve polymorphism after generic type erasure. A synthetic method is a structure introduced during translation, rather than a method ordinarily written in the source.
isBridge()andisSynthetic()help distinguish them. A source browser may filter them out; a JVM diagnostic tool may need to show them. Filtering everything we do not recognize produces an incomplete report, while showing everything without explanation makes readers think they wrote methods they cannot see in the source.
Modifier.isStatic(member.getModifiers()) lets us decide whether a receiver is needed. A Field identifies a declaration, rather than an already-read value: get(object) observes the value in that instance, while for a static field we can pass null. A primitive read with get is returned through its corresponding wrapper. Writing with set requires access and permitted conversions; it does not automatically perform a setter's validation. In a catalog inspector we therefore choose read-only access. If the requirement changes to “modify the number of pages”, we prefer an application method that protects the invariant rather than turning every discovered field into a write entry point.
The requested signature and supplied arguments are two different problems
Suppose we add description(int limit) to the record. The lookup must use int.class, even if we pass an Integer during invocation: getMethod does not apply the overload resolution the compiler applies to a Java call. A signature with Number must be looked up using Number.class, not the argument's concrete class. A configurable system must therefore know the signature types or define its own selection rule, with an explicit answer to ambiguities.
The following fragment uses Service from the ReflectionContracts program. The pages method receives an int; count receives an array of strings. The first lookup fixes a primitive signature, the second an array signature.
var pages = Service.class.getMethod("pages", int.class);
Object result = pages.invoke(service, Integer.valueOf(7));
var count = Service.class.getMethod("count", String[].class);
Object total = count.invoke(service, (Object) new String[]{"Java", "I/O"});
We obtain wrappers for the numbers seven and two, respectively. Looking for pages with Integer.class instead produces NoSuchMethodException, before any execution of the body.
After lookup, invoke permits unboxing and some widening conversions to primitives. It does not perform arbitrary conversions from strings or numerical narrowing. null cannot become an int. A static method can be invoked with a null receiver; an instance method requires a compatible object. The result of a void method is null, while a primitive result is a wrapper. A call to an instance method preserves dynamic dispatch: finding a method on the parent does not force the parent's body to execute when the child overrides it.
Important note – Two levels of varargs.
Method.invokereceives a variable number of arguments, but the method being sought may itself have an array parameter. To call a method declared withString... values, look forString[].classand pass the array as a single argument, for examplemethod.invoke(object, (Object) values). The cast prevents that array from being interpreted as the list of arguments toinvoke. Reflection does not automatically package the target method's arguments as a varargs call written in source does.
To test the distinction, add two signatures with int and Integer, then look them up separately. A successful call does not prove that the intended signature was selected: check getParameterTypes() and the result. If the contract requires the primitive version, a silent fallback to the wrapper conceals a configuration error.
Parameters and annotations: reading what was retained
A mapper might associate parameter names with configuration fields. Method and Constructor share the Executable base, from which we obtain Parameter[]. Source names are not automatically available for every method, however: to retain them with javac, use -parameters. Parameter.isNamePresent() lets us check the contract. If the name is missing, a string such as arg0 is not a reliable domain key. A robust variant requires an explicit annotation or uses a documented position.
For annotations too, we must choose which question we are asking. getDeclaredAnnotations() observes those directly present. @Inherited concerns inheritance of class annotations through superclasses; it does not automatically propagate annotations from overridden methods or from interfaces. For repeatable annotations, getAnnotationsByType considers the container specified by the contract: looking for just one annotation may lose occurrences. Finally, an annotation on the type used by a parameter differs from an annotation on the parameter declaration: the AnnotatedType model serves the first case.
The reusable criterion is to establish where the meaning lives. If a validator wants a constraint on every element of List<String>, merely reading the annotations on the list as a parameter does not necessarily describe its type argument. Return to chapter 14 and declare retention and target before writing the reader. Then verify an annotated declaration and an unannotated one: absence must be an anticipated situation, rather than an accidental NullPointerException.
Traversing a generic type without pretending it is a class
Consider a field declared as List<? extends Number> values. getType() gives the raw List class, useful for some runtime operations. getGenericType() instead describes the declaration. The result is a Type, not necessarily a Class<?>: a direct cast to Class would fail precisely on the case we wanted to understand.
| Observed form | Information to examine | Question in the catalog |
|---|---|---|
Class<?> |
Type identity; any array component | Is it String, int or a concrete array? |
ParameterizedType |
Raw type, arguments and owner type | Which arguments does this List declare? |
TypeVariable<?> |
Original declaration and bounds | What does parameter T permit? |
WildcardType |
Upper and lower bounds | What can we assume about ? extends Number? |
GenericArrayType |
Generic component type | How is a T[] array declared? |
For the values field of Schema in the test program, we can work through the first level as follows:
var list = (ParameterizedType) Schema.class
.getDeclaredField("values").getGenericType();
var bound = (WildcardType) list.getActualTypeArguments()[0];
Type boundary = bound.getUpperBounds()[0];
Here Type must be imported from java.lang.reflect. The casts are valid because we know this example's declaration: boundary is Number.class. A general inspector must instead check the form with instanceof before converting it. If the field became simply Number, the first cast would no longer be valid.
The table is a map for building a visitor: recognize the form, read its parts and repeat the reasoning on those parts. List<List<String>> requires two levels. A bound such as T extends Comparable<T> can take us back to the initial variable: a recursive descriptor must remember types already visited to avoid proceeding forever. This caution arises from the concrete problem, rather than the number of classes in the API.
In the verification program we check a parameterized list, a wildcard, a variable and a generic array. The limitation is intentional: we are describing signatures, rather than validating every element of a received list. If an importer must accept only numbers, it must validate the values. A generic signature useful for documenting the schema does not undo erasure or prevent legacy code from handing us incompatible data.
Deep access: a test must really cross the module
canAccess(receiver) answers whether the member is accessible to the caller under current conditions. For static members and constructors the required receiver is null; for instance members it is a compatible object. trySetAccessible() asks a different question: can we suppress access checks under the platform's rules? A false must be handled; after a successful attempt we have not transformed a private declaration into a stable public API.
A test in which inspector and object are nested classes in the same outer class may already have access allowed by their nestmate relationship, meaning membership of the same group of nested classes. It does not demonstrate that a separate framework could do the same. We therefore test two named modules: the catalog module exports the public package, but initially does not open it; the inspector module requires the catalog. The public accessor remains invocable, while the private field does not become accessible merely because the package is exported. Adding a qualified opening opens library.model to library.inspector changes precisely the contract of the second operation.
Key concept – Targeted opening.
exportsserves the public contract;opensauthorizes deep reflection toward the intended recipients. An opening is not equivalent to making every private member importable from source. Configuration belongs to the module owning the package. If the framework can work with public accessors, that alternative reduces dependence on the internal representation.
The complete modular test uses the model module. The verification script first compiles the closed module, then prepares a temporary copy with a qualified opening. The two executions print deep access: false and deep access: true, while the public accessor succeeds in both. The temporary copy prevents the two contracts from being confused.
We do not use successful trySetAccessible to promise that any final field is modifiable. In particular, record final fields and static final fields do not become writable this way. To reconstruct a record, read the names and types of its components, look up the canonical constructor and pass validated values in the same order. The constructor preserves the place where invariants are enforced. The variant is a record with an additional component: the mapper must adapt the signature or reject the schema, rather than assume two parameters forever.
Arrays constructed when the type arrives later
If an importer discovers the component type only at runtime, it cannot write new T[n] for an arbitrary generic type. java.lang.reflect.Array.newInstance(componentType, length) constructs the concrete array; Array.getLength, Array.get and Array.set let us use it through an Object reference. With component int.class we obtain a real int[], not an Integer[]. Casting a primitive array to Object[] is invalid.
The test is to create three integers, assign one, and read its value and the length. Then try inserting a string: rejection demonstrates that the array retains its type. If the program merely needs to collect heterogeneous objects, a List<Object> may be clearer. The reflective array solves the requirement “I specifically need an array of this runtime type”, rather than the generic requirement “I need a collection”.
Dynamic proxies: intercepting an interface contract
The request now is to count calls to the catalog service without duplicating its work. A proxy is an object presenting itself to the caller through the same contract and deciding how to forward the request. Proxy.newProxyInstance creates a runtime implementation of the specified interfaces; an InvocationHandler receives the method and arguments. It does not create an arbitrary subclass of the concrete class, and the interfaces must respect the API's constraints, including the exclusion of sealed interfaces.
In the ReflectionContracts program, the service has just one operation: the handler increments a counter and calls the method on the real object. The decisive part is the receiver. Invoking the same method on the proxy from inside its handler would cause recursion; we invoke it on the service. If the service throws an exception, extract the cause from InvocationTargetException. Leaving the wrapper would add an artificial level and could alter the interface's checked-exception contract.
equals, hashCode and toString also pass through the handler. Our example assigns identity equality and descriptive text to the proxy, without counting them as catalog requests. Naively delegating equals to the service can even make the proxy unequal to itself. Default methods require an explicit policy; InvocationHandler.invokeDefault allows the default to execute on the proxy when that is the intended choice.
To transfer the idea, replace the counter with an authorization check while preserving the interface and error handling. If the service is unique and known, a handwritten decorator can offer the same behavior with more compiler checks. The proxy becomes useful when interfaces are discovered or treated uniformly by infrastructure. Convenient interception does not remove the need to define which calls to count and which identity to assign to the object.
Reflection, handles and class files: choosing the level
A MethodHandle represents an invocable operation with an explicit type and access checks tied to lookup through Lookup. It suits infrastructure that prepares and composes calls; it is not an automatic promise of greater speed. invokeExact requires an exact match of the call type, while adaptations must be designed. A VarHandle concerns variable accesses and their ordering modes, as we saw in C20A. Core Reflection remains more direct for discovering and describing members.
The Class-File API in java.lang.classfile, stable since Java 24, instead works on the representation of class files: it serves to analyze, generate or transform them. It is not a call to a method on an existing object. If we want to inventory an archive without loading classes, this level may be relevant; if we want to read a component from a live record, the level is different.
One final choice concerns reusing descriptions. Discovering the same signature on every call may be unnecessary: we can retain it for the already verified type. However, a global map with strong references to plugin classes may also retain their classloaders. ClassValue offers a per-class association useful for this kind of metadata; the value must be designed without unwanted links to other plugin generations. Before optimizing, measure the real workload: file access or parsing cost may dominate reflection cost. Chapter 29 provides the tools to make measurement repeatable.
Essential references – From the contract to checking. The Core Reflection specification clarifies the relationship with the JVM model. The specifications for Class, Method, AccessibleObject and Proxy are the references for lookup, invocation, access and interception. They describe platform guarantees; schema choice, validation and proxy policy remain application responsibilities.
When reflection is a good choice
A tool that explores records and annotations at runtime has a precise reason to use reflection. A dependency framework, test runner or serialization system may also need it. An ordinary application call between two classes known to the compiler, on the other hand, is simpler without reflection. Performance costs, error handling and name stability deserve consideration: if we rename a method, a string passed to getMethod may not be detected by the compiler. Tests covering reflective discovery therefore become part of the contract.
As an exercise, add a public method with one parameter to Book and modify the lookup with getMethod using the parameter's exact type. Then look for a nonexistent name and observe NoSuchMethodException, distinguishing it from ClassNotFoundException in the classloader chapter. Finally, move the record into a module that does not open the package and reason about which public operations remain legal and which requests for deep access fail. The goal is to learn to identify the boundary, rather than cross it automatically.
A reflective lookup that fails: interpreting the error
Suppose a method name comes from configuration and the program executes type.getMethod(configuredName). If the configuration contains a typo, the compiler performs no check on that string. At runtime, lookup throws NoSuchMethodException. The useful response is not merely to print a long trace: we must say which name was requested, on which class, with which parameter types and from which configuration it came. Parameter types are part of the lookup. getMethod("description") and getMethod("description", String.class) ask different questions even though the name is the same.
After finding the method, invocation may still fail. If the supplied object is not an instance compatible with the instance method, or the number and type of parameters do not match, the API reports the error to the caller. If instead the method body executes and throws its own exception, InvocationTargetException preserves that cause. For diagnosis, distinguish the error in finding and calling the method from the error produced by the called method. This is the same discipline used in the exceptions chapter: the point where an error is seen does not always coincide with where it originates.
A framework may conceal these details behind more understandable messages, but code using reflection must know them. If the framework's contract says “an annotated component must have a public no-argument constructor”, a NoSuchMethodException indicates a precise violation of that contract. If access is denied by the module, the error is different: changing the method name will not open the package. If the method itself throws a domain exception, opening the module will not solve it. Accurate diagnosis avoids random corrections such as adding JVM options until the program stops failing.
Reflection is therefore powerful when a program must work with metadata discovered at runtime, but it requires explicit contracts and tests that also cover error cases. The classloader chapter explains where the definition comes from; this chapter explains how to query and use it within its boundaries. The next step in a real application is deciding whether the flexibility gained justifies the added complexity. Often a common interface, a modular service or an ordinary typed call expresses the same need better. When the structure is not known in advance, however, reflection allows it to be discovered without pretending the compiler could have checked it earlier.
To verify
In this chapter's program, first change the name requested in getMethod, then the constructor parameter type, in two separate copies. Compare the exceptions and identify which lookup produces them. After restoring the code, make description() throw an exception and observe the cause reported by Method.invoke. Explain the three points in the path: member lookup, access checking and execution. Finally ask whether this lookup is necessary for a type already known during compilation: tool choice must follow the problem, as in the plugin case in chapter 21.