Skip to main content
spring internals

JDK Dynamic Proxies: Interface-Based Proxying and InvocationHandler

7 min read Chapter 23 of 78

JDK Dynamic Proxies: Interface-Based Proxying and InvocationHandler

JDK dynamic proxies are the original proxying mechanism in Java, available since JDK 1.3. They work exclusively through interfaces. If a bean implements an interface and Spring is configured to use interface-based proxying, this is the mechanism that runs.

The Proxy.newProxyInstance Contract

The factory method java.lang.reflect.Proxy.newProxyInstance creates a proxy class and instantiates it in one call. Three parameters:

public static Object newProxyInstance(
    ClassLoader loader,
    Class<?>[] interfaces,
    InvocationHandler h
)

ClassLoader: the class loader that defines the proxy class. Typically the target’s class loader.

Interfaces: the array of interfaces the proxy class will implement. The generated proxy class is a subclass of java.lang.reflect.Proxy that implements all of these interfaces.

InvocationHandler: the dispatch target. Every method call on the proxy is forwarded to InvocationHandler.invoke.

The generated class is synthetic. It has a name like jdk.proxy2.$Proxy45 (the numbering is per-classloader, sequential). It extends java.lang.reflect.Proxy. It implements every interface in the array.

InvocationHandler: The Dispatch Mechanism

InvocationHandler is a single-method interface:

public interface InvocationHandler {
    Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}

Every method call on the proxy object, including toString, hashCode, and equals, goes through this method. The proxy parameter is the proxy instance itself (rarely used, and calling methods on it will re-enter the handler). The method is the java.lang.reflect.Method object. The args are the arguments.

Spring’s JdkDynamicAopProxy implements InvocationHandler. Its invoke method is where the AOP chain executes. Simplified logic:

  1. Look up the interceptor chain for the method.
  2. If no interceptors, invoke the method directly on the target.
  3. If interceptors exist, create a ReflectiveMethodInvocation and call proceed().

Building a JDK Proxy for the SaaS Backend

Consider the notification subsystem. It has an interface:

public interface NotificationGateway {
    void send(String tenantId, String channel, String message);
    boolean isChannelAvailable(String tenantId, String channel);
}

And an implementation:

@Service
public class NotificationGatewayImpl implements NotificationGateway {

    @Override
    public void send(String tenantId, String channel, String message) {
        System.out.println("Sending to " + channel + " for tenant " + tenantId);
        // actual dispatch logic
    }

    @Override
    public boolean isChannelAvailable(String tenantId, String channel) {
        return "email".equals(channel) || "slack".equals(channel);
    }
}

To see a JDK proxy in action, create one manually:

NotificationGateway target = new NotificationGatewayImpl();

NotificationGateway proxy = (NotificationGateway) Proxy.newProxyInstance(
    target.getClass().getClassLoader(),
    new Class<?>[]{ NotificationGateway.class },
    (proxyObj, method, args) -> {
        String tenantId = (String) args[0];
        System.out.println("[AUDIT] " + tenantId + " calling " + method.getName());
        long start = System.nanoTime();
        Object result = method.invoke(target, args);
        long elapsed = System.nanoTime() - start;
        System.out.println("[AUDIT] " + method.getName() + " took " + elapsed + "ns");
        return result;
    }
);

proxy.send("tenant-42", "email", "Invoice ready");

Output:

[AUDIT] tenant-42 calling send
Sending to email for tenant tenant-42
[AUDIT] send took 48231ns

The proxy is not an instance of NotificationGatewayImpl. It is an instance of NotificationGateway (the interface) and java.lang.reflect.Proxy (the base class).

System.out.println(proxy instanceof NotificationGateway);      // true
System.out.println(proxy instanceof NotificationGatewayImpl);   // false
System.out.println(proxy.getClass().getName());                 // jdk.proxy2.$Proxy12
System.out.println(proxy.getClass().getSuperclass().getName()); // java.lang.reflect.Proxy
System.out.println(Proxy.isProxyClass(proxy.getClass()));       // true

The Type Relationship Problem

This is the source of bugs that JDK proxies introduce. The proxy implements the interface but is not related to the implementation class by inheritance.

// BROKEN: ClassCastException at runtime
@Component
public class CampaignRunner {

    @Autowired
    private NotificationGatewayImpl gateway; // Injecting by concrete type

    public void runCampaign(String tenantId) {
        gateway.send(tenantId, "email", "Campaign message");
    }
}

When proxyTargetClass=false, Spring creates a JDK proxy for NotificationGatewayImpl because it implements NotificationGateway. The proxy’s type is $Proxy87, which implements NotificationGateway but does not extend NotificationGatewayImpl.

The autowiring fails:

org.springframework.beans.factory.BeanNotOfRequiredTypeException:
  Bean named 'notificationGatewayImpl' is expected to be of type
  'com.saas.notify.NotificationGatewayImpl' but was actually of type
  'jdk.proxy2.$Proxy87'
// CORRECT: Inject by interface type
@Component
public class CampaignRunner {

    @Autowired
    private NotificationGateway gateway; // Injecting by interface

    public void runCampaign(String tenantId) {
        gateway.send(tenantId, "email", "Campaign message");
    }
}

This works with both JDK and CGLIB proxies. The JDK proxy implements NotificationGateway, so the injection succeeds. The CGLIB proxy extends NotificationGatewayImpl which implements NotificationGateway, so it also succeeds.

This is exactly why Spring Boot switched to CGLIB by default. Developers consistently injected by concrete type, and the BeanNotOfRequiredTypeException was one of the most common Spring questions on Stack Overflow.

When Spring Still Uses JDK Proxies

Even with proxyTargetClass=true as the default, JDK proxies appear in specific cases:

1. Explicit configuration:

@EnableAspectJAutoProxy(proxyTargetClass = false)

or

spring.aop.proxy-target-class=false

2. Scoped proxies with interface mode:

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.INTERFACES)
public class RequestContext implements TenantContext {
    // ...
}

3. Spring Data repositories:

Repository interfaces get JDK proxies because the bean is defined by its interface. There is no concrete class to subclass. SimpleJpaRepository is the target, but the proxy implements the custom repository interface.

4. FactoryBean-produced beans where the FactoryBean returns an interface type.

What JDK Proxies Cannot Do

No concrete class methods. If NotificationGatewayImpl has a public method resetCircuitBreaker() that is not declared on NotificationGateway, the JDK proxy does not expose it. Callers injecting the interface cannot call it. This is by design, but it surprises developers who expect to access all public methods.

No field access interception. Proxies intercept method calls. Field reads and writes go directly to the object. There is no proxy mechanism in Java that intercepts field access (unlike AspectJ compile-time weaving, which can).

No constructor interception. The proxy is a separate object. The target’s constructor runs when the target is created, not when the proxy is created.

Performance: JDK Proxy vs CGLIB

JDK proxy method dispatch uses Method.invoke, which goes through reflection. CGLIB generates FastClass bytecode that calls the target method directly, avoiding reflection overhead.

In practice, the difference is negligible for business logic methods. Reflection has been heavily optimized since JDK 7 (method handles, inflation). The performance gap matters only in tight loops calling proxied methods millions of times per second, which is not a pattern you should have in application code.

The real performance consideration is startup. CGLIB generates bytecode for each proxy class, which takes time. In large applications with hundreds of proxied beans, this can add seconds to startup. JDK proxies are faster to create because the JVM has built-in support for Proxy.newProxyInstance.

For the SaaS backend with 200 beans, 40 of which are proxied, the difference in startup time is under 500ms. Not worth optimizing.

Diagnostic Pattern

When debugging proxy issues, add this to your application:

@Component
public class JdkProxyDetector implements CommandLineRunner {

    @Autowired private ApplicationContext ctx;

    @Override
    public void run(String... args) {
        for (String name : ctx.getBeanDefinitionNames()) {
            Object bean = ctx.getBean(name);
            if (AopUtils.isJdkDynamicProxy(bean)) {
                Class<?>[] interfaces = bean.getClass().getInterfaces();
                System.out.printf("JDK Proxy: %-30s | Interfaces: %s%n",
                    name, Arrays.toString(interfaces));

                // Get the target class
                if (bean instanceof Advised advised) {
                    try {
                        Object target = advised.getTargetSource().getTarget();
                        if (target != null) {
                            System.out.println("  Target class: " +
                                target.getClass().getName());
                        }
                    } catch (Exception e) {
                        System.out.println("  Could not resolve target: " +
                            e.getMessage());
                    }
                }
            }
        }
    }
}

The Advised interface is implemented by all Spring AOP proxies (both JDK and CGLIB). It exposes the advisor chain, the target source, and proxy configuration. Casting a proxied bean to Advised is always safe and is the standard way to inspect proxy internals.

Key Takeaways

JDK dynamic proxies implement interfaces and delegate through InvocationHandler. The proxy is not a subclass of the target. Injecting by concrete type fails. Spring Boot defaults to CGLIB precisely to avoid this problem. JDK proxies still appear in explicit configurations, scoped proxies, and repository interfaces. The next section covers CGLIB, which solves the type relationship problem by subclassing.