Back to Java Modules (JPMS)
Q2Question 2 of 2

Why does reflection hurt performance yet remain essential, and how is it constrained under JPMS?

Key Concepts
  • Reflection invokes methods through generic dispatch the JIT cannot inline, so it is slower than a direct call.
  • Each reflective access can pay access-control checks, and primitive arguments are boxed through an Object array.
  • Frameworks like Spring, Hibernate, Jackson, and JUnit depend on reflection to operate on classes they never saw at compile time.
  • Mature frameworks cache Method and Field handles and call setAccessible once to reduce the per-call overhead.
  • Under JPMS, deep reflection into another module fails with InaccessibleObjectException unless the package is opened.
  • The opens directive permits reflection into a package, and the add-opens flag is the command-line escape hatch for migration.
  • MethodHandle and invokedynamic offer faster alternatives to classic reflection for repeated dynamic invocation.

Definition

Reflection trades speed for run-time flexibility, slower because it bypasses JIT inlining and pays access checks, yet essential for frameworks, and under JPMS it needs a package to be opened before it can reach private members.
  • Reflection – the ability to inspect and invoke classes, methods, and fields by name at run time rather than through direct, compile-time-bound calls.
  • Performance cost – reflective calls go through generic dispatch the JIT cannot inline, pay access-control checks per call, and box primitive arguments through Object[].
  • Essential for frameworks – Spring, Hibernate, Jackson, and JUnit must operate on classes they did not write, wiring beans, mapping rows, and serializing fields by name.
  • Mitigation – cache the Method or Field once, call setAccessible(true) once, and the JVM generates specialized accessors for hot sites.
  • JPMS constraint – deep reflection into another module's non-opened package throws InaccessibleObjectException; the opens directive or --add-opens flag is required.

A normal method call at National Bank of India is like a teller who has memorized exactly which drawer holds which form and reaches straight for it. Reflection is like a new auditor who, for every single form, must look up its name in a directory, ask security whether he is allowed to open that drawer, then fetch it. The auditor is far slower, yet indispensable because he can work with any department's paperwork without prior training. The module system adds a stricter security policy: by default the auditor is not allowed into a department's private back room at all, and the department must post a notice (the opens directive) granting the auditor permission.

Understanding the Concepts

Why is reflection slower than a direct call?

A direct method call is bound at compile time to a specific method. The JIT compiler can inline it, meaning the called code is pasted into the caller to remove call overhead, and then optimize both together. Reflection breaks this because method.invoke(target, args) goes through generic dispatch that the JIT cannot inline as easily.

JAVA
package com.bank.service;

import java.lang.reflect.Method;
import java.math.BigDecimal;

public class ReflectionCostDemo {
    public static void main(String[] args) throws Exception {
        AccountService service = new AccountService();

        // Direct call: JIT can inline this
        BigDecimal directResult = service.getBalance("SAV2001");
        System.out.println("Direct: " + directResult);

        // Reflective call: JIT cannot inline easily
        Method method = AccountService.class.getMethod("getBalance", String.class);
        BigDecimal reflectResult = (BigDecimal) method.invoke(service, "SAV2001");
        System.out.println("Reflect: " + reflectResult);
    }
}

class AccountService {
    public BigDecimal getBalance(String accountId) {
        return new BigDecimal("150000.00"); // Rajesh's balance
    }
}
TEXT
Direct: 150000.00
Reflect: 150000.00

What are the specific performance costs of reflection?

Beyond lost inlining, reflection pays several run-time costs that add up on hot paths.

  • Access-control checks – each invocation verifies the caller is permitted to touch the member.
  • Boxing – primitive arguments are boxed into Object[], and primitive results are boxed on return, creating garbage.
  • Lookup cost – getMethod and getDeclaredField search the class hierarchy and can create new reflective objects each time.
  • No specialization – the generic Object invoke(Object, Object[]) signature prevents the JIT from type-specializing the call.
JAVA
package com.bank.service;

import java.lang.reflect.Method;
import java.math.BigDecimal;

public class BoxingCostDemo {
    public static void main(String[] args) throws Exception {
        LoanCalculator calc = new LoanCalculator();
        Method method = LoanCalculator.class.getMethod(
                "calculateEMI", double.class, int.class, double.class);

        // Each reflective call boxes the three primitives
        // and unboxes the double result
        Object result = method.invoke(calc, 1000000.0, 240, 8.5);
        System.out.println("EMI: " + result);
    }
}

class LoanCalculator {
    public double calculateEMI(double principal, int months, double annualRate) {
        double monthlyRate = annualRate / 12.0 / 100.0;
        return principal * monthlyRate / (1 - Math.pow(1 + monthlyRate, -months));
    }
}
TEXT
EMI: 8678.229277938498

How do you mitigate the performance cost?

The standard mitigation is to look up the Method or Field once, cache it, and call setAccessible(true) once so the per-call access check is suppressed. The JVM also generates specialized accessor classes for hot reflective sites.

JAVA
package com.bank.service;

import java.lang.reflect.Field;
import java.math.BigDecimal;

public class CachedReflectionDemo {
    // Cache the field handle once
    private static final Field BALANCE_FIELD;

    static {
        try {
            BALANCE_FIELD = Account.class.getDeclaredField("balance");
            BALANCE_FIELD.setAccessible(true); // access check happens once
        } catch (NoSuchFieldException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public static void main(String[] args) throws Exception {
        Account account = new Account("SAV2001", new BigDecimal("250000.00"));

        // Fast: no lookup, no access check on each call
        BigDecimal balance = (BigDecimal) BALANCE_FIELD.get(account);
        System.out.println("Rajesh's balance: " + balance);
    }
}

class Account {
    private final String id;
    private final BigDecimal balance;

    Account(String id, BigDecimal balance) {
        this.id = id;
        this.balance = balance;
    }
}
TEXT
Rajesh's balance: 250000.00

Why is reflection essential despite the cost?

Frameworks must operate on application classes they never saw at compile time. The alternative, generating or hand-writing per-class glue, is impractical for arbitrary user code.

  • Spring reads annotations and injects dependencies into private fields or constructors it discovers reflectively.
  • Hibernate reads and writes entity private fields to map database rows without boilerplate.
  • Jackson serializes and deserializes by reflecting over fields and accessors.
  • JUnit discovers and invokes test methods by scanning for annotations.
JAVA
package com.bank.framework;

import java.lang.reflect.Field;
import java.math.BigDecimal;

public class SimpleDIContainer {
    /**
     * A tiny dependency-injection container that shows why reflection is essential.
     * It injects a TransferService into any field annotated with @Inject.
     */
    public static void injectDependencies(Object target) throws Exception {
        for (Field field : target.getClass().getDeclaredFields()) {
            if (field.isAnnotationPresent(javax.inject.Inject.class)) {
                field.setAccessible(true);
                // Create and inject the dependency
                Object dependency = field.getType().getDeclaredConstructor().newInstance();
                field.set(target, dependency);
                System.out.println("Injected " + field.getType().getSimpleName()
                        + " into " + field.getName());
            }
        }
    }
}

How does JPMS constrain reflection?

Under the module system, deep reflective access into another module's package fails unless that package is opened. Calling setAccessible(true) on a member of a non-open package throws InaccessibleObjectException.

JAVA
package com.bank.reporting;

import java.lang.reflect.Field;

public class ModuleReflectionDemo {
    public static void main(String[] args) throws Exception {
        // Attempting reflection into com.bank.payments.internal
        // which is NOT opened in the module descriptor
        Class<?> clazz = Class.forName(
                "com.bank.payments.internal.TransferValidator");
        Field field = clazz.getDeclaredField("maxAmount");

        // This throws InaccessibleObjectException under JPMS
        field.setAccessible(true);
    }
}
TEXT
Exception in thread "main" java.lang.reflect.InaccessibleObjectException:
  Unable to make field private java.math.BigDecimal
  com.bank.payments.internal.TransferValidator.maxAmount accessible:
  module com.bank.payments does not "opens
  com.bank.payments.internal" to module com.bank.reporting

How do you fix reflection under JPMS?

The owning module must open the package with the opens directive, either to everyone, to specific modules, or by declaring the whole module open.

JAVA
// Option 1: open to a specific framework module
module com.bank.payments {
    exports com.bank.payments.api;
    opens com.bank.payments.model to com.fasterxml.jackson.databind;
}

// Option 2: open to everyone
module com.bank.payments {
    exports com.bank.payments.api;
    opens com.bank.payments.model;
}

// Option 3: open the entire module
open module com.bank.payments {
    exports com.bank.payments.api;
}
BASH
# Command-line escape hatch for migration
java --add-opens com.bank.payments/com.bank.payments.model=ALL-UNNAMED \
     -m com.bank.app/com.bank.app.Main

How does exports differ from opens?

The distinction is important and often asked in interviews.

| Directive | Compile-time access | Run-time public access | Deep reflection (setAccessible) |
|---|---|---|---|
| Not exported, not opened | No | No | No |
| exports only | Yes | Yes (public types) | No |
| opens only | No | No | Yes |
| exports + opens | Yes | Yes | Yes |

JAVA
// exports makes the public API accessible at compile and run time
// opens permits setAccessible on private members at run time
module com.bank.core {
    exports com.bank.core.api;       // other modules can compile against this
    opens com.bank.core.entity;      // Hibernate can reflect into this
}

What about MethodHandle as a faster alternative?

For repeated dynamic invocation, MethodHandle from java.lang.invoke is faster than classic reflection because the access check happens once at lookup, invocation is optimizable by the JIT like a direct call, and primitives are not forced through boxing.

JAVA
package com.bank.service;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.math.BigDecimal;

public class MethodHandleDemo {
    public static void main(String[] args) throws Throwable {
        MethodHandles.Lookup lookup = MethodHandles.lookup();

        // Access check happens once here
        MethodHandle mh = lookup.findVirtual(
                AccountService.class,
                "getBalance",
                MethodType.methodType(BigDecimal.class, String.class)
        );

        AccountService service = new AccountService();

        // Invocation is near direct-call speed
        BigDecimal balance = (BigDecimal) mh.invoke(service, "SAV2001");
        System.out.println("Rajesh's balance via MethodHandle: " + balance);
    }
}
TEXT
Rajesh's balance via MethodHandle: 150000.00

What should a senior engineer know?

  • The --add-opens flag is the escape hatch for legacy code on newer JDKs, commonly seen when running older frameworks.
  • Native image (GraalVM) cannot see reflective access at build time, so the same reflective code that needs opens under modules needs reflection configuration under native image.
  • Frameworks increasingly use MethodHandle and invokedynamic or build-time code generation to avoid run-time reflection entirely.
  • The --illegal-access flag was removed in Java 17, so strong encapsulation is now fully enforced.
JAVA
package com.bank.service;

public class CheckModuleAccess {
    public static void main(String[] args) {
        Module myModule = CheckModuleAccess.class.getModule();
        System.out.println("Module: " + myModule.getName());
        System.out.println("Is open: " + myModule.getDescriptor().isOpen());

        // Check if a package is exported or opened
        myModule.getDescriptor().exports().forEach(e ->
                System.out.println("Exports: " + e.source()));
        myModule.getDescriptor().opens().forEach(o ->
                System.out.println("Opens: " + o.source()));
    }
}
TEXT
Module: com.bank.payments
Is open: false
Exports: com.bank.payments.api
Opens: com.bank.payments.model

Visual Overview

DIAGRAM
  Direct Call vs Reflective Call:

  Direct call (fast):
  ┌──────────┐  compile-time bind   ┌──────────────┐
  │  Caller  │─────────────────────▶│ getBalance() │
  │          │  JIT can inline      │              │
  └──────────┘                      └──────────────┘

  Reflective call (slow):
  ┌──────────┐  method.invoke()     ┌──────────────┐
  │  Caller  │─────────────────────▶│  Reflection  │
  │          │                      │  Subsystem   │
  └──────────┘                      ├──────────────┤
                                    │ 1. Access    │
                                    │    check     │
                                    │ 2. Box args  │
                                    │ 3. Dispatch  │
                                    │ 4. Unbox     │
                                    │    result    │
                                    └──────┬───────┘
                                           │
                                           ▼
                                    ┌──────────────┐
                                    │ getBalance() │
                                    └──────────────┘

  JPMS Reflection Constraint:
  ┌──────────────────┐       ┌──────────────────┐
  │ module            │       │ module            │
  │ com.bank.reporting│       │ com.bank.payments │
  │                   │       │                   │
  │ field.set         │──✗──▶│ internal package  │
  │ Accessible(true)  │       │ (not opened)      │
  │                   │       │                   │
  │ field.set         │──✓──▶│ model package     │
  │ Accessible(true)  │       │ (opened)          │
  └──────────────────┘       └──────────────────┘

Key Takeaway

Reflection is the ability to inspect and invoke classes, methods, and fields by name at run time instead of through direct, compile-time-bound calls. It hurts performance because reflective calls go through generic dispatch the JIT cannot inline, each access pays access-control checks, and primitive arguments are boxed through an Object array. The standard mitigation every mature framework uses is to look up the Method or Field once, cache it, call setAccessible true once, and the JVM generates specialized accessors for hot sites, so steady-state reflection is far faster than the first calls even though it rarely matches a direct call.

Despite the cost, reflection is essential because frameworks must operate on classes they never saw at compile time. Spring injects dependencies into private fields, Hibernate maps database rows to entity private fields, Jackson serializes by reflecting over accessors, and JUnit discovers test methods by scanning annotations. The cost is accepted because it falls mostly on startup and cold paths, and where ultimate speed matters frameworks move to MethodHandle or build-time code generation.

Under the Java Platform Module System, strong encapsulation constrains reflection across module boundaries. By default, deep reflective access into another module's non-opened package throws InaccessibleObjectException. The owning module must open the package with the opens directive, either to everyone or to specific modules. In the reporting service at National Bank of India Rajesh hit exactly this: a reflective mapper that populated report fields worked on the classpath, slowed down until he cached the Field handles and set accessible once, and then threw InaccessibleObjectException after modularization until he opened the model package to the reporting module.

Remember one sentence: reflection trades speed for run-time flexibility by bypassing JIT inlining and paying access checks, yet frameworks depend on it, and under JPMS it needs the opens directive before it can reach private members across module boundaries.

---

Share
Use ← → arrow keys to navigate