🪞 Chapter 20: Reflection, Custom Annotations, and Dynamic Proxies
1. What is Reflection? (Inspecting Code at Runtime)
Under normal compilation, Java programs are static: your source code defines classes, methods, and fields, and the compiler turns those into fixed bytecode instructions.
Reflection (java.lang.reflect) allows a program to inspect and modify its own structure and behavior while running. Through reflection, your code can:
* Inspect any class's constructors, methods, fields, and annotations at runtime.
* Instantiate classes whose names are only known as strings (e.g., from a configuration file).
* Access and modify fields, including private ones.
* Invoke methods dynamically without knowing their signatures at compile time.
Compiled Code (.class)
│
▼
[ ClassLoader Loads Class ]
│
▼
java.lang.Class<T> ◄────── Inspection Gateway (Reflection)
│
┌───────────┼───────────┐
▼ ▼ ▼
[Field[]] [Method[]] [Constructor[]]
2. The Entry Point: The Class<?> Token
Every type loaded into the JVM has a corresponding instance of java.lang.Class. You can obtain this token in three ways:
// 1. Through the class literal syntax (compile-time known)
Class<String> stringClass = String.class;
// 2. Through an existing object instance
String text = "Reflection in Java";
Class<? extends String> instanceClass = text.getClass();
// 3. Dynamically by fully-qualified name (great for plugins/drivers)
try {
Class<?> loadedClass = Class.forName("java.util.ArrayList");
System.out.println("Loaded: " + loadedClass.getName());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
3. Inspecting and Manipulating Members
Reflection provides access to class members through three core classes in java.lang.reflect:
* Constructor<?>: Allows reflective instantiation of objects via .newInstance().
* Field: Reads and writes field values on target instances via .get() and .set().
* Method: Invokes instance or static methods via .invoke(instance, args...).
Inspecting Private Members
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
class SecureVault {
private String secretKey = "VAULT_PASS_#9920";
private SecureVault() {
// Private constructor
}
private void unlock(String accessCode) {
System.out.println("Vault unlocked with code: " + accessCode);
}
}
public class ReflectionHacker {
public static void main(String[] args) throws Exception {
Class<SecureVault> clazz = SecureVault.class;
// 1. Instantiate via private constructor
Constructor<SecureVault> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true); // Bypass private access modifier
SecureVault vault = constructor.newInstance();
// 2. Read and modify private fields
Field field = clazz.getDeclaredField("secretKey");
field.setAccessible(true);
System.out.println("Extracted Key: " + field.get(vault));
field.set(vault, "TAMPERED_KEY_#0000");
System.out.println("Modified Key: " + field.get(vault));
// 3. Invoke private methods
Method method = clazz.getDeclaredMethod("unlock", String.class);
method.setAccessible(true);
method.invoke(vault, "ALPHA-CLEARANCE");
}
}
> The Cost and Dangers of Reflection:
> * Performance Overhead: Dynamic method resolution and field lookups bypass JIT optimizations and inlining.
> * Loss of Type Safety: Errors that would normally fail at compile time become runtime exceptions (NoSuchMethodException, IllegalAccessException).
> * Encapsulation Breakdown: Reflective access breaks internal state boundaries. Since Java 9, the Java Module System (JPMS) restricts reflective access across module boundaries unless explicitly opened via opens package to module.
>
4. Metadata in Code: Defining Custom Annotations
Annotations (@interface) provide structured metadata attached to declarations (classes, methods, fields, parameters). By themselves, annotations perform no actions—they are passive tags until read and processed by an engine (either at compile time via annotation processors, or at runtime via reflection).
Defining an Annotation
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
// 1. Where can this annotation be placed?
@Target({ElementType.METHOD, ElementType.TYPE})
// 2. How long should this annotation survive?
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
int maxAttempts() default 3; // Attribute with a default value
long backoffMillis() default 1000L;
String description() default "";
}
The Three RetentionPolicy Lifecycles
* SOURCE: Kept only in source code; discarded by the compiler (e.g., @Override, @SuppressWarnings).
* CLASS (Default): Recorded in the .class bytecode file, but ignored by the JVM at runtime. Cannot be read via standard reflection.
* RUNTIME: Recorded in .class files and retained in memory by the JVM. These are readable at runtime via reflection.
5. Building an Annotation Processing Engine
Frameworks like JUnit scan your classes for annotated methods and execute them. Let's build a minimal testing framework to see how this works:
import java.lang.annotation.*;
import java.lang.reflect.Method;
// Custom Test Marker
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface AutoTest {
String value() default "Unspecified Test";
}
// User-written test suite
class PaymentEngineTests {
@AutoTest("Valid balance should pass checkout")
public void testCheckoutSuccess() {
// Assertion logic
System.out.println(" -> Assert balance deduction: PASS");
}
public void helperUtilityMethod() {
// Not a test; should not be executed by our engine
}
@AutoTest("Overdraw should throw InsufficientFundsException")
public void testOverdrawProtection() {
throw new IllegalStateException("Card limit exceeded!");
}
}
// The Test Runner Engine
public class MiniJUnitRunner {
public static void main(String[] args) {
Class<PaymentEngineTests> testClass = PaymentEngineTests.class;
int passed = 0;
int failed = 0;
System.out.println("Running test suite: " + testClass.getSimpleName());
for (Method method : testClass.getDeclaredMethods()) {
// Check if the method is decorated with our custom annotation
if (method.isAnnotationPresent(AutoTest.class)) {
AutoTest metadata = method.getAnnotation(AutoTest.class);
System.out.println("[TEST CASE]: " + metadata.value() + " (" + method.getName() + ")");
try {
Object testInstance = testClass.getDeclaredConstructor().newInstance();
method.invoke(testInstance); // Execute the test
passed++;
} catch (Exception ex) {
System.err.println(" -> FAILED: " + ex.getCause().getMessage());
failed++;
}
}
}
System.out.printf("%nExecution Summary: %d Passed, %d Failed%n", passed, failed);
}
}
6. Dynamic Proxies: Intercepting Method Calls
What if you want to wrap existing methods with additional logic—such as transaction management, metrics timing, or security checks—without editing the original source code?
This design is known as the Proxy Pattern. Java provides built-in support for runtime proxies through java.lang.reflect.Proxy.
Client Code ──► [ Dynamic Proxy Instance ] ──► [ InvocationHandler ] ──► Real Target Object
(Mimics interface) (Adds logging, metrics,
security checks, etc.)
The Invocation Handler Contract
A Dynamic Proxy implements one or more interfaces on the fly. Any call made to the proxy's interface methods is routed to a single interception method:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
7. Practical Example: Building an Automated Audit and Performance Interceptor
Let's build a real-world tracing engine that measures method execution time and logs method entries automatically for any service implementing an interface.
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
// 1. The business interface
interface OrderService {
void placeOrder(String itemId, int quantity);
double getAccountBalance(String customerId);
}
// 2. The actual business implementation
class OrderServiceImpl implements OrderService {
@Override
public void placeOrder(String itemId, int quantity) {
try { Thread.sleep(120); } catch (InterruptedException e) {}
System.out.println("Order persisted to database: Item=" + itemId + " Qty=" + quantity);
}
@Override
public double getAccountBalance(String customerId) {
return 4500.75;
}
}
// 3. The Interception Logic (Logging + Timing)
class MetricsInvocationHandler implements InvocationHandler {
private final Object target;
public MetricsInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("[AUDIT TRACE] Entering method: " + method.getName());
long start = System.nanoTime();
try {
// Forward invocation to the actual business object
Object result = method.invoke(target, args);
return result;
} finally {
long durationMs = (System.nanoTime() - start) / 1_000_000;
System.out.printf("[METRICS] Method '%s' executed in %d ms%n%n", method.getName(), durationMs);
}
}
}
// 4. Bringing it together: Factory that wraps targets in proxies
public class DynamicProxyDemo {
@SuppressWarnings("unchecked")
public static <T> T wrapWithMetrics(T target, Class<T> interfaceType) {
return (T) Proxy.newProxyInstance(
interfaceType.getClassLoader(),
new Class<?>[]{interfaceType},
new MetricsInvocationHandler(target)
);
}
public static void main(String[] args) {
// Instantiate the real service
OrderService realService = new OrderServiceImpl();
// Wrap the real service in a dynamic proxy
OrderService proxyService = wrapWithMetrics(realService, OrderService.class);
// Caller interacts with the interface normally, but tracing executes automatically!
proxyService.placeOrder("ITEM-KB99", 2);
double balance = proxyService.getAccountBalance("CUST-104");
System.out.println("Balance read: $" + balance);
}
}
> Dynamic Proxy Limitation: Standard JDK dynamic proxies (Proxy.newProxyInstance) can only proxy interfaces, not concrete classes. To proxy concrete classes without interfaces (a common practice in Spring and Hibernate), frameworks use bytecode-generation libraries like ByteBuddy or CGLIB, which generate child subclasses directly in bytecode at runtime.
>
📝 Chapter Summary
* Reflection (java.lang.Class, Field, Method, Constructor) allows applications to inspect and modify their own structure and state dynamically at runtime.
* While powerful, reflection introduces runtime performance overhead, bypasses compiler type safety, and can break encapsulation.
* Annotations (@interface) supply structured metadata attached to code elements; use @Retention(RetentionPolicy.RUNTIME) to preserve annotations for runtime reflection.
* Frameworks like Spring and JUnit use reflection to discover annotated components, wire dependencies, and trigger test executions dynamically.
* Dynamic Proxies (Proxy.newProxyInstance) intercept interface method invocations at runtime, enabling cross-cutting concerns like logging, transactions, and metrics without altering business logic.