13.2 Reflection: Lookup, Access, and Invocation
Complete the Annotations Topic and become familiar with access control, exceptions, and generic erasure before spending about 50 minutes working through this lesson. The goal is to be able to accurately locate, access, and invoke members, and to distinguish whether a reflection failure occurs during lookup, access, or within the target method itself.
1. Read Runtime Metadata from Declarations
Java’s reflection API allows runtime inspection of classes, fields, constructors, methods, and annotations, and can invoke members after access and argument checks.
Reflection does not grant programs true self-awareness. It merely shifts operations that were statically defined at compile time (such as type and member access) into dynamic data lookup and indirect invocation:
Direct invocation: new InspectableItem("Hammer").describe(2)
Reflection invocation: Class → Constructor → Object → Method → invokeWhen class and method signatures are known at compile time, direct invocation is clearer and more straightforward. Reflection is best suited for frameworks, plugins, serialization, testing tools, and a few dynamic configuration boundaries.
2. Class<?> is a type entry point
2.1 Three common ways to obtain it
String text = "hello";
Class<?> fromObject = text.getClass();
Class<String> fromLiteral = String.class;
Class<?> fromName = Class.forName("java.lang.String");
System.out.println(fromObject == fromLiteral); // true
System.out.println(fromLiteral == fromName); // trueobj.getClass()retrieves the actual runtime class from an existing object;String.classspecifies the type explicitly in source code, preservingClass<String>generic relationships;Class.forName(...)accepts a binary class name, making it suitable for validated configurations or plugin registration tables.
A one-parameter Class.forName defaults to initializing the class; static field initialization and static initializer blocks may execute. If you only want to load the class without initializing it:
ClassLoader loader = Thread.currentThread().getContextClassLoader();
Class<?> plugin = Class.forName("com.example.Plugin", false, loader);2.2 "Each class has only one Class" is missing half the sentence
The accurate statement is: For a given class loader, the same binary class definition results in exactly one corresponding Class object. When two different class loaders define the same class name, they produce two distinct type identities.
This boundary arises in plugin systems, application servers, hot-swapping, and test isolation scenarios. When caching reflection metadata, you cannot rely solely on class name as a key. At a minimum, you must use Class<?> or a key that includes the class loader identity.
2.3 Primitive types, arrays, and other types also have Class objects
System.out.println(int.class.getName()); // int
System.out.println(String[].class.isArray()); // true
System.out.println(Runnable.class.isInterface()); // true
System.out.println(Thread.State.class.isEnum()); // trueJava 16+ also supports isRecord() and getRecordComponents() to check record types. Class<?> describes the runtime class, it is not equivalent to merely describing a standard class declaration.
3. Viewing Structure: Public or Declared First?
3.1 API Comparison
| Target | Public View | This Class Declared View |
|---|---|---|
| Method | getMethods() | getDeclaredMethods() |
| Field | getFields() | getDeclaredFields() |
| Constructor | getConstructors() | getDeclaredConstructors() |
| Single Method | getMethod(name, types...) | getDeclaredMethod(name, types...) |
| Single Field | getField(name) | getDeclaredField(name) |
Both getMethods and getFields include inherited public members; constructors are never inherited, and getConstructors only returns the public constructors of the current class. The declared view includes all access-level members declared in the current class, excluding inherited members. "Discoverable" and "accessible" are two separate steps, finding a private Method does not guarantee successful invocation.
3.2 A Reusable Inspection Program
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
final class Person {
private String name;
public int age;
public String getName() {
return name;
}
private void secret() {}
}
public class InspectDemo {
public static void main(String[] args) {
Class<?> type = Person.class;
String[] fields = Arrays.stream(type.getDeclaredFields())
.map(Field::getName)
.sorted()
.toArray(String[]::new);
String[] methods = Arrays.stream(type.getDeclaredMethods())
.map(Method::getName)
.sorted()
.toArray(String[]::new);
System.out.println(Arrays.toString(fields));
System.out.println(Arrays.toString(methods));
}
}[age, name]
[getName, secret]Do not hardcode the full output of getMethods() into a fixed list. Object and JDK version differences may introduce additional public methods. A sorted declared result is more suitable for teaching assertions.
3.3 Method Signatures Must Be Exact
Assume Tool declares public void resize(int size). Run these lookups inside a method that declares NoSuchMethodException:
Method resize = Tool.class.getMethod("resize", int.class);
Method wrong = Tool.class.getMethod("resize", Integer.class); // Search failedCore reflection lookup does not perform Java compiler overload resolution. Special attention is required:
int.classandInteger.classare distinct;- Variable-length arguments appear in the signature as arrays, e.g.,
String[].class; getMethodonly searches for public methods, which may come from parent classes;getDeclaredMethodonly examines methods in the current class;- After generic erasure, method parameters may become upper bounds or Object;
- The compiler may generate bridge or synthetic methods.
Most scanning frameworks filter out method.isBridge() and method.isSynthetic(), then apply their own rules for overload resolution.
4. Constructing Objects and Invoking Methods
4.1 No Longer Using Class.newInstance()
Class<?> type = Class.forName("java.util.ArrayList");
Object list = type.getDeclaredConstructor().newInstance();
Method add = type.getMethod("add", Object.class);
add.invoke(list, "First rule");
add.invoke(list, "Second clause");
Method size = type.getMethod("size");
System.out.println(size.invoke(list)); // 2Class.newInstance() is deprecated: it can propagate a checked constructor exception directly, bypassing compile-time exception checks. Constructor.newInstance() wraps target constructor exceptions in InvocationTargetException and allows explicit selection of parameter types.
The direct approach is:
ArrayList<Object> list = new ArrayList<>();
list.add("First rule");
list.add("Second clause");
System.out.println(list.size());When comparing the two side by side, reflection merely breaks each compile-time operation into runtime objects describing it.
4.2 Four Key Points of Method.invoke
Object result = method.invoke(receiver, firstArg, secondArg);- Instance methods require a compatible receiver; static methods ignore it, so null is customary.
- The argument count must match. Invocation allows unboxing and primitive widening, but not narrowing or a new round of overload selection.
- Return types in the source code are Object; void methods return null.
- Exceptions thrown by the target method are wrapped in
InvocationTargetException.
For example, an int parameter accepts a boxed Byte, but rejects a Long. A varargs parameter is still an array. To pass a String[] as one argument, write invoke(receiver, (Object) values) so that invoke does not expand it through its own Object... parameter. The complete experiment checks this boundary.
Target exception handling:
try {
method.invoke(receiver, args);
} catch (java.lang.reflect.InvocationTargetException wrapped) {
Throwable cause = wrapped.getCause();
System.err.println(cause.getClass().getSimpleName() + ": " + cause.getMessage());
}The wrapper means the target method ran and threw an exception. An IllegalAccessException or IllegalArgumentException thrown directly by reflection means the target was not entered. If the target itself throws IllegalArgumentException, it appears in the wrapper’s cause.
5. Complete Experiment: Direct Invocation, Reflection Invocation, and Target Exception
chapter13_reflection_root=$(mktemp -d)
mkdir -p "$chapter13_reflection_root/out"Save the following code as $chapter13_reflection_root/ReflectionDemo.java:
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
final class InspectableItem {
private final String name;
InspectableItem(String name) {
this.name = java.util.Objects.requireNonNull(name, "name");
}
public String describe(int count) {
if (count <= 0) {
throw new IllegalArgumentException("Quantity must be greater than 0");
}
return name + " x" + count;
}
public String join(String... parts) {
return String.join("/", parts);
}
private String internalCode() {
return "TOOL-13";
}
}
public class ReflectionDemo {
public static void main(String[] args) throws ReflectiveOperationException {
Class<?> type = InspectableItem.class;
Field[] fields = type.getDeclaredFields();
String[] fieldNames = Arrays.stream(fields).map(Field::getName).sorted().toArray(String[]::new);
Constructor<?> constructor = type.getDeclaredConstructor(String.class);
Object reflectedTool = constructor.newInstance("Hammer");
InspectableItem directTool = new InspectableItem("Hammer");
Method describe = type.getMethod("describe", int.class);
try {
type.getMethod("describe", Integer.class);
throw new AssertionError("Parameter type must match exactly");
} catch (NoSuchMethodException expected) {
// int.class and Integer.class are different reflection parameter types.
}
System.out.println("Class: " + type.getSimpleName());
System.out.println("Field: " + Arrays.toString(fieldNames));
System.out.println("Direct invocation: " + directTool.describe(2));
System.out.println("Reflection invocation: " + describe.invoke(reflectedTool, 3));
try {
describe.invoke(reflectedTool, 0);
throw new AssertionError("Target exception not propagated");
} catch (InvocationTargetException wrapped) {
System.out.println("Target exception: " + wrapped.getCause().getMessage());
}
Method join = type.getMethod("join", String[].class);
Object joined = join.invoke(reflectedTool, (Object) new String[]{"one", "two"});
if (!"one/two".equals(joined)) {
throw new AssertionError("Varargs array was not passed as one argument");
}
Method internalCode = type.getDeclaredMethod("internalCode");
if (!internalCode.trySetAccessible()) {
throw new AssertionError("Currently unnamed module should allow access to example private method");
}
if (!"TOOL-13".equals(internalCode.invoke(reflectedTool))) {
throw new AssertionError("Private method return value error");
}
System.out.println("Reflection check passed");
}
}javac --release 17 -Xlint:all -Werror \
-d "$chapter13_reflection_root/out" \
"$chapter13_reflection_root/ReflectionDemo.java"
java -ea -cp "$chapter13_reflection_root/out" ReflectionDemoClass: InspectableItem
Field: [name]
Direct invocation: Hammer x2
Reflection invocation: Hammer x3
Target exception: Quantity must be greater than 0
Reflection check passedrm -rf -- "$chapter13_reflection_root"This example uses an unnamed module, so trySetAccessible() can access local private methods. If the caller is in a different named module and the target package is not open to it, the same private-access request may fail.
6. private Access and Module Boundaries
6.1 First, Observe the Failure
import java.lang.reflect.Method;
final class PrivateVault {
private String reveal() {
return "secret";
}
}
public class PrivateAccessFailure {
public static void main(String[] args) throws ReflectiveOperationException {
PrivateVault vault = new PrivateVault();
Method method = PrivateVault.class.getDeclaredMethod("reveal");
method.invoke(vault);
}
}Running results in IllegalAccessException failure. getDeclaredMethod can detect private methods, but does not automatically grant calling permissions.
6.2 trySetAccessible() Makes Access Failure Explicit
if (!method.trySetAccessible()) {
throw new IllegalStateException("The module is not exposing the target to the current caller package");
}
Object value = method.invoke(vault);When module rules prevent access, setAccessible(true) throws InaccessibleObjectException, while trySetAccessible() returns false. Both obey module rules. In Java 17 environments that still enable a SecurityManager, a permission check may also make either method throw SecurityException. Use a public API, adjust the module’s opens, or ask the target module to provide an adapter.
Do not treat broad --add-opens as a default product configuration. It shifts encapsulation concerns from compile time to deployment configuration.
7. Annotations and Reflection: The Boundary of Explicit Dispatchers
The previous topic defined @Loggable. Reflection can detect annotations and read their configuration:
for (Method method : service.getClass().getDeclaredMethods()) {
Loggable log = method.getAnnotation(Loggable.class);
if (log == null) {
continue;
}
System.out.println("[" + log.level() + "] " + method.getName());
}If the handler then explicitly calls invoke, it completes metadata-driven explicit dispatch. A direct call to a service method does not automatically pass through the handler, so this is still not transparent AOP.
Spring AOP intercepts calls through proxies; calls made directly to the target or through self-invocation do not automatically pass through that proxy. Spring MVC mapping annotations associate requests with controller methods. JUnit Jupiter uses test and lifecycle annotations to organize execution. Annotations, discovery, and invocation each have a separate job.
8. Generic Reflection: Reading Declarations, Not Guessing Object Contents
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;
final class Shelf {
List<String> titles;
}
Field field = Shelf.class.getDeclaredField("titles");
System.out.println(field.getType()); // interface java.util.List
Type generic = field.getGenericType();
if (generic instanceof ParameterizedType parameterized) {
System.out.println(parameterized.getActualTypeArguments()[0]); // class java.lang.String
}getGenericType() reads the Signature metadata from field declarations. It cannot prove that all elements in a runtime List instance are of type String. Complex declarations may include:
ParameterizedType:List<String>;TypeVariable: T of a class or method;WildcardType:? extends Number;GenericArrayType:T[].
When frameworks parse these types, they must handle recursive structures and unresolved variables. Regular business code is better suited to explicitly pass Class<T>, transformation functions, or schemas.
9. Performance and Caching
Reflection-based lookup and indirect invocation introduce overhead from metadata resolution, access checks, parameter array construction, and boxing. The exact performance impact varies significantly depending on the JDK version, call site, and workload, no fixed multiplier can adequately capture these differences.
Best practices in order:
- Prefer direct calls and interface-based access;
- Perform framework initialization scans only once during startup;
- Cache members after signature and permission validation;
- Profile hot paths using JMH;
- Only consider alternatives such as
MethodHandle, code generation, or interface redesign when a clear performance bottleneck is confirmed.
Do not cache by method name alone. Use Method itself as the key, or include the declaring Class<?>, name, parameter types, and return type: a generic bridge can share the original method’s name and parameter types while returning a different type. Class<?> already carries class-loader identity. During plugin unloading, remove static strong references that would keep its class loader alive.
10. Security Boundaries
All of the following values are considered external inputs: class names in configuration files, plugin packages, method names in network requests, and serialization type identifiers. Passing these directly to Class.forName, getDeclaredMethod, and invoke expands the attack surface.
Secure practices:
- Map external operation names to a fixed allowlist;
- Plugins must implement controlled interfaces and originate from trusted directories or signed artifacts;
- Restrict constructor and method signatures, avoiding scanning of any private members;
- Validate parameter count, type, and size;
- Enforce timeouts, authorization, and auditing on execution;
- Return controlled error messages without exposing stack traces or classpath details to clients.
A timeout often stops only the caller’s wait; it does not guarantee that target code has stopped. Untrusted plugins need process isolation and resource limits. A signature establishes provenance only when the signer is trusted.
11. Common Issues
| Exception or Phenomenon | Meaning | Next Step |
|---|---|---|
ClassNotFoundException | Incorrect class name or load path | Verify binary name, classpath/module path, and classloader |
NoSuchMethodException | Signature mismatch | Compare names, public/declared, primitive, and array parameters |
IllegalAccessException | Java access check failure | Use public APIs, or explicitly handle access opt-in |
InaccessibleObjectException | Module package not open | Adjust module contract; do not silently ignore |
IllegalArgumentException | Incompatible receiver object or parameter | Validate full signature before invocation |
InvocationTargetException | Target method throws an exception | Check and map getCause() |
| Two methods with the same name detected | Overload, bridge, or synthetic method | Use full signature and inspect member flags |
| Plugin type "appears identical" but fails to convert | Different classloader identities | Establish a unified API classloader boundary |
12. From Discovery to Invocation: Layered Validation
First, write describeClass(Class<?>) to output the declared fields, methods, and constructors in sorted order. Then implement a factory that accepts only public, parameterless constructors, explicitly rejecting interfaces, abstract classes, and types that fail to satisfy their construction contract. This ensures that "discovery of structure" and "allowance for instantiation" remain distinct and separable steps.
Enhance ReflectionDemo with double scale(double), using double.class and Double.class to perform lookups, retaining one successful match and one NoSuchMethodException. Then have the target method throw a domain-specific exception to verify that the outer layer receives InvocationTargetException, and that the actual business failure remains intact in the cause chain.
Finally, parse a nested Map<String, List<Integer>> field within a ParameterizedType, and design a registry that accepts only fixed Plugin interfaces and a predefined allowlist. Never directly pass class names or method names received from network traffic into Class.forName and invoke; dynamic invocations must still operate within a statically defined permission boundary.
Review Java 17's Official Contract
Class: Loading, initialization control, member discovery, and type identity.java.lang.reflect:Method,Field,Constructor, and genericType.Method.invokeandInvocationTargetException: Indirect invocation and target exception wrapping.AccessibleObject.trySetAccessible: Java language access control and module openness boundaries.
Continue Learning: Concurrent Execution
Reflection can now read annotations and locate members by metadata. When an application handles concurrent requests, the next class of failure comes from execution order: while 100 readers query concurrently, one thread can modify data as another observes an old value.