Reflection is the part of the Java platform that lets a running program look at its own types and act on them: list a class's fields, find the constructor marked with an annotation, call a method whose name arrived in a configuration file. Almost every framework you use depends on it. Spring and Jakarta dependency injection, JPA entity mapping, JSON binders, JUnit test discovery and mocking libraries all read metadata at runtime and call code they were never compiled against.

That power comes with costs that are easy to ignore until they hurt: access rules that changed sharply between JDK 9 and JDK 17, invocation overhead in hot paths, exceptions that hide the real failure, and incompatibility with ahead-of-time compilation. This article explains the model from first principles, shows the API with working code, builds a small dependency-injection container, and ends with a checklist for using reflection deliberately rather than by accident.

Advertisement

Where the metadata comes from

When a class loader defines a class from its bytes, the JVM creates exactly one java.lang.Class object for that class in that loader. The class file already carries everything reflection exposes: names and descriptors of fields and methods, access flags, generic signatures, annotations with runtime retention, record components and permitted subclasses. Reflection is a typed view over that parsed data. It does not inspect source code, and anything the compiler erased or never wrote into the class file is invisible to it.

Two consequences follow. First, identity of a type is the pair of name and defining loader, so Class.forName("com.example.Order") can return different objects in different plugin loaders; the class loading article covers why. Second, some information only exists if you asked for it at compile time. Method parameter names, for example, are only available through Parameter.getName() when the code was compiled with javac -parameters; otherwise you get arg0, arg1. Generic type arguments of a field declaration survive as a signature, but the type argument of a particular List<String> object at runtime does not.

Note that one-argument Class.forName(name) runs static initialisers; code that only inspects a class should call the three-argument form with initialize=false.

Order.class fileconstant pool, attributesloadClassLoaderdefines the classjava.lang.Classone per class per loadergetDeclared*()Fieldtype, modifiersMethodparams, returnConstructornewInstanceRecordComponentaccessoraccess checkAccess controlJava language rules + module exports / opensallowedInvocation (JDK 18+)method handle under the hoodWhat your code usually does1. find the Class (forName, .class, getClass)2. look up members once, at startup3. read annotations to decide what to do4. setAccessible / trySetAccessible if needed5. cache Method or MethodHandle, invoke often
From class file to invocation. Member lookup returns fresh copies of Field, Method and Constructor objects; access control applies before use; since JDK 18 the invocation itself is carried out by method handles.

The object model and lookup rules

The core types live in java.lang.reflect: Field, Method, Constructor, Parameter and RecordComponent, plus the generic type hierarchy rooted at Type (ParameterizedType, TypeVariable, WildcardType, GenericArrayType). Lookup comes in two families, and mixing them up causes most beginner bugs.

CallReturnsIncludes inherited?Includes non-public?
getMethods()Public methods of the class, superclasses and interfacesYesNo
getDeclaredMethods()Methods declared in this class onlyNoYes
getMethod(name, types...)One public method, searched up the hierarchyYesNo
getDeclaredField(name)One field declared here, any accessNoYes
getDeclaredConstructors()All constructors of this classNot applicableYes

To find a private field declared in a superclass you walk getSuperclass() and call getDeclaredField at each level. Order of the returned arrays is not specified, so never rely on declaration order for fields or methods; records are the exception, because getRecordComponents() is specified to return components in declaration order, which is what makes generic record serialisers possible. Each lookup call also returns new copies of the member objects, so lookups are comparatively expensive and should be done once and cached.

Class<?> c = Class.forName("com.example.Order");          // runs static initialisers
for (Field f : c.getDeclaredFields()) {                     // all fields declared here, any access
    System.out.println(Modifier.toString(f.getModifiers()) + " "
        + f.getGenericType().getTypeName() + " " + f.getName());
}
for (Method m : c.getMethods()) {                           // public only, including inherited
    System.out.println(m.getName() + Arrays.toString(m.getParameterTypes()));
}
if (c.isRecord()) {
    for (RecordComponent rc : c.getRecordComponents()) {
        System.out.println(rc.getName() + " read by " + rc.getAccessor().getName());
    }
}

Records get first-class support: isRecord(), getRecordComponents() and each component's accessor method. The canonical constructor can be found by passing the component types, in order, to getDeclaredConstructor. The records article explains why serialisation frameworks prefer that path to writing fields directly.

Advertisement

Access checks, modules and strong encapsulation

Holding a Method does not mean you may call it. By default reflection enforces the same rules as the language: a private method of another class throws IllegalAccessException on invoke. setAccessible(true) suppresses that check, which is how frameworks reach private fields and constructors. Since JDK 9 there is also trySetAccessible(), which returns false instead of throwing, and canAccess(obj), which reports without changing anything.

Modules added a second gate. setAccessible(true) on a member of a class in another named module only succeeds if that module opens the package to the caller; exports is enough for public members but not for private ones. When the gate refuses, you get InaccessibleObjectException, an unchecked exception that many older catch blocks do not expect. For years the JDK softened this for its own internals, but JEP 396 made strong encapsulation the default in JDK 16 and JEP 403 in JDK 17 removed the command-line switch that relaxed it. Code that pokes at java.lang or sun.* internals now fails unless the launcher adds an explicit --add-opens. Your own application code on the class path lives in the unnamed module, which is open, so the rule mostly bites when you modularise or when libraries reach into the JDK. The modules article shows how to write opens clauses that grant a framework exactly what it needs.

Final fields are a third gate. Field.set on a static final field fails even after setAccessible(true). On an instance final field of an ordinary class it has historically succeeded, but not on records or hidden classes, where the JDK refuses. JEP 500, targeted to JDK 26, starts issuing warnings when deep reflection mutates a final field, as preparation for a future release that restricts it. If a library you depend on sets final fields reflectively, treat it as a migration item now rather than when the warning becomes an error.

How invocation works and what it costs

Before JDK 18, Method.invoke started with a native accessor and, after a method had been called a number of times, generated bytecode for a faster one. JEP 416 in JDK 18 replaced that machinery: Method, Constructor and Field are now implemented on top of java.lang.invoke method handles. The visible behaviour stayed the same, but the performance profile changed, and old advice about warm-up thresholds no longer applies.

What still costs something on every call: packing arguments into an Object[], boxing primitives, casting the Object result, and wrapping any exception thrown by the target in InvocationTargetException. The JIT can remove much of this when the Method is a constant it can see, for example a static final field, and much less when the method is looked up dynamically from a map. When a call sits on a hot path, a MethodHandle stored in a static final field and called with invokeExact gives the JIT the best chance to inline it like a direct call.

final class NameReaders {
    private static final Method NAME_METHOD;
    private static final MethodHandle NAME_HANDLE;
    static {
        try {
            NAME_METHOD = Person.class.getMethod("name");            // look up once
            NAME_HANDLE = MethodHandles.lookup().unreflect(NAME_METHOD);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }
    static String viaReflection(Person p) throws ReflectiveOperationException {
        return (String) NAME_METHOD.invoke(p);       // Object[] args, boxing, wrapped exceptions
    }
    static String viaHandle(Person p) throws Throwable {
        return (String) NAME_HANDLE.invokeExact(p);  // exact type (Person)String, JIT can inline
    }
}

Measure both with JMH on your JDK rather than trusting rules of thumb. Usually lookup, not invocation, dominates, and caching members fixes most reflection performance problems.

Dynamic proxies

java.lang.reflect.Proxy creates, at runtime, a class that implements a list of interfaces and forwards every call to an InvocationHandler. It is the mechanism behind many client stubs, transaction wrappers and test doubles. It only works for interfaces; proxying concrete classes requires bytecode generation libraries.

@SuppressWarnings("unchecked")
static <T> T timed(Class<T> iface, T target) {
    InvocationHandler h = (proxy, method, args) -> {
        long start = System.nanoTime();
        try {
            return method.invoke(target, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();                   // rethrow the real exception, not the wrapper
        } finally {
            System.out.printf("%s took %d us%n", method.getName(),
                              (System.nanoTime() - start) / 1_000);
        }
    };
    return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[] { iface }, h);
}

Three details matter in real use. Calls to equals, hashCode and toString also arrive at the handler, so a handler that blindly forwards or logs everything must be ready for them. Unwrap InvocationTargetException as shown, or callers see a checked wrapper instead of the exception the interface declared. And if the interface has default methods you want to run rather than forward, JDK 16 added InvocationHandler.invokeDefault for exactly that.

Worked example: a dependency-injection container in 60 lines

The fastest way to understand what frameworks do with reflection is to build a small one. The container below resolves constructor dependencies recursively, honours an @Inject annotation with runtime retention, binds interfaces to implementations, caches singletons and detects cycles. The annotation itself is covered in the annotations article; here it is only a marker.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.CONSTRUCTOR)
@interface Inject {}

final class Container {                          // single-threaded, used at startup only
    private final Map<Class<?>, Object> singletons = new HashMap<>();
    private final Map<Class<?>, Class<?>> bindings = new HashMap<>();

    <T> void bind(Class<T> iface, Class<? extends T> impl) { bindings.put(iface, impl); }

    <T> T get(Class<T> type) { return type.cast(resolve(type, new ArrayDeque<>())); }

    private Object resolve(Class<?> type, Deque<Class<?>> path) {
        Class<?> impl = bindings.getOrDefault(type, type);
        Object existing = singletons.get(impl);
        if (existing != null) return existing;
        if (impl.isInterface() || Modifier.isAbstract(impl.getModifiers()))
            throw new IllegalStateException("No binding for " + type.getName());
        if (path.contains(impl))
            throw new IllegalStateException("Cycle: " + path + " -> " + impl.getName());
        path.push(impl);
        try {
            Constructor<?> ctor = pickConstructor(impl);
            Class<?>[] params = ctor.getParameterTypes();
            Object[] args = new Object[params.length];
            for (int i = 0; i < params.length; i++) args[i] = resolve(params[i], path);
            ctor.setAccessible(true);            // throws InaccessibleObjectException if not opened
            Object instance = ctor.newInstance(args);
            singletons.put(impl, instance);
            return instance;
        } catch (InvocationTargetException e) {
            throw new IllegalStateException(impl.getName() + " constructor failed", e.getCause());
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("Cannot create " + impl.getName(), e);
        } finally {
            path.pop();
        }
    }

    private static Constructor<?> pickConstructor(Class<?> impl) throws NoSuchMethodException {
        Constructor<?>[] marked = Arrays.stream(impl.getDeclaredConstructors())
            .filter(c -> c.isAnnotationPresent(Inject.class))
            .toArray(Constructor<?>[]::new);
        if (marked.length > 1)
            throw new IllegalStateException(impl.getName() + " has several @Inject constructors");
        return marked.length == 1 ? marked[0] : impl.getDeclaredConstructor();
    }
}

// usage
interface Clock { long now(); }
final class SystemClock implements Clock { public long now() { return System.currentTimeMillis(); } }
final class OrderRepo { @Inject OrderRepo(Clock clock) { /* ... */ } }
final class OrderService { @Inject OrderService(OrderRepo repo, Clock clock) { /* ... */ } }

Container c = new Container();
c.bind(Clock.class, SystemClock.class);
OrderService svc = c.get(OrderService.class);

Trace c.get(OrderService.class). OrderService has no binding, so it is its own implementation. pickConstructor finds the single @Inject constructor, whose parameter types are OrderRepo and Clock. Resolving OrderRepo recurses and needs a Clock; the binding maps it to SystemClock, which has an implicit no-argument constructor, so it is created and cached. Back in OrderService, the second Clock parameter hits the singleton cache, so both objects share one clock. The path deque means a class that eventually needs itself fails with a readable cycle message instead of a stack overflow.

Forget RetentionPolicy.RUNTIME and isAnnotationPresent silently returns false. Move OrderRepo into a named module without opening its package and setAccessible throws. Let a constructor throw and the cause arrives wrapped.

Reflection and ahead-of-time compilation

Reflection assumes that any class and member might be needed at runtime. Ahead-of-time compilers assume the opposite. GraalVM Native Image builds a closed world at build time and drops whatever static analysis cannot see reaching, so a class loaded only by Class.forName on a string from a config file will be missing from the binary unless it is declared in reachability metadata. The GraalVM article covers the tracing agent that records those accesses for you. Frameworks that target native images increasingly move work to build time: annotation processors generate the wiring code that a container like the one above discovers at startup, trading flexibility for fast startup and explicit, analysable code.

Choosing between reflection, handles and generated code

TechniqueBest forCosts
Core reflectionDiscovery at startup, tooling, tests, rarely called pathsPer-call boxing and wrapping; access rules; opaque to AOT
MethodHandle / VarHandleHot dynamic calls and field accessMore ceremony; exact types; still needs access via a Lookup
Annotation processingWiring, mappers, serialisers known at build timeBuild complexity; no runtime flexibility
Runtime bytecode generationProxies of concrete classes, high-performance mappersLibrary dependency; harder debugging

Failure modes

  • Swallowed causes. Logging InvocationTargetException without getCause() hides the real bug.
  • Unexpected InaccessibleObjectException. Appears after modularising or upgrading the JDK; fix with targeted opens, not blanket flags.
  • Missing metadata. Wrong retention, no -parameters, or a class stripped by an AOT build.
  • Lookup in loops. Repeated getMethod calls dominate the profile; cache members.
  • Order assumptions. Relying on getDeclaredFields order breaks across JDKs.
  • Final-field writes. Fail on records and statics, and warn from JDK 26 elsewhere.

What to do next

  1. Search your code for setAccessible, getDeclared and Class.forName, and list which calls run per request rather than at startup.
  2. Cache every per-request lookup in a static field or a ClassValue, and convert hot ones to method handles.
  3. Run your tests on the newest JDK you plan to adopt and treat every reflective-access or final-field warning as a ticket.
  4. Replace blanket --add-opens flags with narrow opens clauses in your module descriptors.
  5. Rebuild the 60-line container above, then add a qualifier annotation; you will understand your DI framework's error messages far better.
  6. If you target native images, run the tracing agent and review the generated metadata instead of hand-writing it.
Key takeaway: Reflection is a typed view over class-file metadata, gated by language access rules, module opens and final-field rules that have tightened since JDK 9. Look members up once, cache them, and hand hot paths to method handles; unwrap InvocationTargetException; and prefer build-time code generation when your types are known in advance. Used that way, reflection stays a discovery tool rather than a runtime tax.