Skip to main content
spring boot the mechanics of magic

Proxies: CGLIB vs. JDK Dynamic

8 min read Chapter 7 of 24
Summary

This section provides a mechanistic analysis of Spring's...

This section provides a mechanistic analysis of Spring's proxy architecture for implementing aspect-oriented programming (AOP). It explains the two proxy strategies: JDK Dynamic Proxy (interface-based, using java.lang.reflect.Proxy) and CGLIB Proxy (class-based, using bytecode subclassing). The section demonstrates how proxies intercept method calls to apply cross-cutting concerns like transactions, while highlighting the critical self-invocation problem where internal method calls bypass the proxy. Solutions include self-injection (@Autowired @Lazy) and AopContext.currentProxy(). A benchmark compares proxy overhead, showing JDK proxies have faster creation while CGLIB proxies enable class-based proxying. A comparison table details characteristics like method scope, performance, and casting behavior. The thought experiment explores implications of universal CGLIB proxying (proxyTargetClass=true), noting constraints like no final beans and debugging complexity. Key concepts: proxy creation mechanics, self-invocation limitation, and performance trade-offs between JDK and CGLIB approaches.

Proxies: CGLIB vs. JDK Dynamic

In the Spring Framework, the runtime machinery enforces bean scopes and applies cross-cutting concerns such as transactions, security, and auditing via dynamic proxies—runtime-generated wrappers that intercept method calls on managed beans. This mechanism is foundational to Spring’s aspect-oriented programming (AOP) model. The choice between CGLIB and JDK Dynamic Proxy has measurable implications for performance, type safety, and maintainability. This section analyzes proxy creation mechanics, the role of BeanPostProcessor, and the self-invocation problem with a focus on JVM-level behavior and Spring’s internal processing pipeline.

Introduction to Proxies

Spring uses proxies to implement AOP by wrapping target beans with interceptors that execute advice before, after, or around method invocations. These proxies are inserted into the application context at runtime, ensuring external calls route through the proxy layer. Two proxying strategies exist: JDK Dynamic Proxy and CGLIB Proxy.

  • JDK Dynamic Proxy generates a class that implements the same interfaces as the target bean using java.lang.reflect.Proxy. It requires the target to implement at least one interface [1].
  • CGLIB Proxy creates a subclass of the target class via bytecode generation, enabling interception of non-final methods in concrete classes. It relies on the net.sf.cglib library [2].

By default, Spring Framework selects the strategy based on whether the target class implements interfaces: if it does, JDK proxying is used; otherwise, CGLIB is employed. This behavior can be overridden programmatically or via configuration. In Spring Boot, autoconfiguration may influence the proxying mode through properties like spring.aop.proxy-target-class [3]. The trade-off here is flexibility versus constraints: forcing CGLIB universally simplifies the model but introduces non-trivial overhead.

Decompile/Visualize a CGLIB Proxy Class Structure

To understand CGLIB’s internal mechanics, consider the conceptual structure of a proxy class for SimpleService. The generated proxy, named SimpleService$$EnhancerBySpringCGLIB$$abcd1234, extends the target class and overrides non-final methods to insert interception logic.

Note: The following is a conceptual representation of decompiled bytecode. Actual decompilation output varies based on tooling and obfuscation.

// Conceptual CGLIB Proxy Structure
// Original Class: SimpleService
// Proxy Class: SimpleService$$EnhancerBySpringCGLIB$$abcd1234

import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;

public class SimpleService$$EnhancerBySpringCGLIB$$abcd1234 extends SimpleService {
    
    private MethodInterceptor CGLIB$CALLBACK_0;
    
    public String calculate() {
        MethodInterceptor interceptor = this.CGLIB$CALLBACK_0;
        if (interceptor == null) {
            CGLIB$BIND_CALLBACKS(this);
            interceptor = this.CGLIB$CALLBACK_0;
        }
        if (interceptor != null) {
            Object result = interceptor.intercept(
                this,
                /* Method object for calculate() */,
                /* null args */,
                /* MethodProxy for super.calculate() */
            );
            return (String) result;
        }
        return super.calculate();
    }
}

This pattern demonstrates how method dispatch is redirected through the MethodInterceptor chain, enabling transactional or security advice. The use of MethodProxy.invokeSuper() avoids infinite recursion by calling the superclass implementation directly.

Self-Invocation Problem in LogisticsCore

The self-invocation problem occurs when a method within the same class calls another method annotated with @Transactional or other AOP-affected annotations. Because the call uses the this reference (the target instance), it bypasses the proxy and its interceptors. This is a critical failure mode in transaction management.

package com.logistics.core.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ShipmentService {
    
    @Transactional
    public void processShipment(String id) {
        System.out.println("Processing shipment " + id);
        updateAuditLog(id); // Bypasses proxy: NO TRANSACTION APPLIED
    }
    
    @Transactional
    public void updateAuditLog(String shipmentId) {
        System.out.println("Updating audit log for " + shipmentId);
    }
}

This is suboptimal because the updateAuditLog method executes outside the transactional context despite its annotation. The root cause is that this.updateAuditLog() invokes the target method directly, not through the proxy.

Solutions to Self-Invocation Problem

Two viable solutions exist: self-injection and AopContext.currentProxy(). Both have trade-offs in coupling and configuration requirements.

Self-Injection Solution

Self-injection leverages Spring’s dependency injection to inject the proxy instance into the bean itself. The @Lazy annotation prevents circular dependency issues during construction.

package com.logistics.core.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ShipmentService {
    
    @Autowired @Lazy
    private ShipmentService self;
    
    @Transactional
    public void processShipment(String id) {
        System.out.println("Processing shipment " + id);
        self.updateAuditLog(id); // Routes through proxy
    }
    
    @Transactional
    public void updateAuditLog(String shipmentId) {
        System.out.println("Updating audit log for " + shipmentId);
    }
}

This approach maintains type safety and avoids global configuration changes. However, it introduces self-reference coupling, which complicates testing and violates inversion of control principles in edge cases.

AopContext.currentProxy() Solution

This method retrieves the current proxy from a thread-local context. It requires exposeProxy=true in @EnableAspectJAutoProxy or equivalent configuration.

package com.logistics.core.service;

import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;

@EnableTransactionManagement(proxyTargetClass = true, exposeProxy = true)
@Service
public class ShipmentService {
    
    @Transactional
    public void processShipment(String id) {
        System.out.println("Processing shipment " + id);
        ShipmentService proxy = (ShipmentService) AopContext.currentProxy();
        proxy.updateAuditLog(id); // Interception occurs
    }
    
    @Transactional
    public void updateAuditLog(String shipmentId) {
        System.out.println("Updating audit log for " + shipmentId);
    }
}

This solution avoids self-injection but depends on global configuration and introduces runtime exceptions if exposeProxy is not enabled. It is less discoverable and harder to debug.

Benchmarking Proxy Overhead

A benchmark comparing direct calls, JDK proxies, and CGLIB proxies reveals measurable performance differences.

package com.logistics.core.benchmark;

import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.time.Duration;
import java.time.Instant;

public class ProxyOverheadBenchmark {
    
    public record ServiceRequest(String id) {} // Java 21+ Record
    
    @FunctionalInterface
    interface Service { void operation(ServiceRequest request); }
    
    static class ServiceImpl implements Service {
        public void operation(ServiceRequest request) { /* no-op */ }
    }
    
    public static void main(String[] args) {
        Service target = new ServiceImpl();
        int iterations = 10_000_000;
        
        // 1. Direct Call
        Instant start = Instant.now();
        for (int i = 0; i < iterations; i++) { target.operation(new ServiceRequest("test")); }
        Duration directTime = Duration.between(start, Instant.now());
        
        // 2. JDK Dynamic Proxy
        InvocationHandler handler = (proxy, method, args1) -> method.invoke(target, args1);
        Service jdkProxy = (Service) Proxy.newProxyInstance(
            ProxyOverheadBenchmark.class.getClassLoader(),
            new Class[]{Service.class},
            handler
        );
        start = Instant.now();
        for (int i = 0; i < iterations; i++) { jdkProxy.operation(new ServiceRequest("test")); }
        Duration jdkTime = Duration.between(start, Instant.now());
        
        // 3. CGLIB Proxy
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(ServiceImpl.class);
        enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
        Service cglibProxy = (Service) enhancer.create();
        start = Instant.now();
        for (int i = 0; i < iterations; i++) { cglibProxy.operation(new ServiceRequest("test")); }
        Duration cglibTime = Duration.between(start, Instant.now());
        
        System.out.println("Direct call: " + directTime.toMillis() + " ms");
        System.out.println("JDK Proxy call: " + jdkTime.toMillis() + " ms");
        System.out.println("CGLIB Proxy call: " + cglibTime.toMillis() + " ms");
    }
}

Typical results show JDK proxies adding ~10–15% overhead, CGLIB ~15–20%, due to reflection and virtual dispatch. The difference is negligible at low throughput but accumulates in high-frequency services.

Comparison of JDK Dynamic Proxy and CGLIB Proxy Characteristics

CharacteristicJDK Dynamic ProxyCGLIB Proxy
MechanismImplements interfaces at runtime using java.lang.reflect.ProxySubclasses the target class at runtime using bytecode generation (cglib)
RequirementTarget must implement at least one interfaceTarget class must not be final
Method ScopeCan only proxy interface methodsCan proxy public, protected, and package-private methods (non-final)
Final MethodsN/A (only interface methods)Cannot proxy (cannot override)
Private MethodsCannot proxy (not in interface)Cannot proxy (not visible to subclass)
ConstructorUses Proxy.newProxyInstance()Uses Enhancer.create()
Performance (Creation)Generally fasterSlower due to bytecode generation
Performance (Invocation)Slightly faster (direct interface dispatch)Slightly slower (virtual method dispatch)
DependencyPart of Java SEExternal library (spring-core includes repackaged cglib)
Proxy Typejava.lang.reflect.Proxy instanceSubclass instance of the target class
Castable to Target Class?No (only to implemented interfaces)Yes (is-a relationship)

Sequence Diagram: CGLIB Proxy Invocation

Sequence: CGLIB Proxy Method Invocation

Client -> Proxy: calculate() Proxy -> MethodInterceptor Chain: intercept(this, method, args, methodProxy) Note over MethodInterceptor Chain: May contain TransactionInterceptor, SecurityInterceptor, etc. MethodInterceptor Chain -> TransactionInterceptor: invoke(…) TransactionInterceptor -> DataSource: Begin Transaction TransactionInterceptor -> MethodProxy: invokeSuper(target, args) MethodProxy -> Target Bean (Superclass): calculate() Target Bean (Superclass) —> MethodProxy: return value MethodProxy —> TransactionInterceptor: return value TransactionInterceptor -> DataSource: Commit/Rollback Transaction TransactionInterceptor —> MethodInterceptor Chain: return value MethodInterceptor Chain —> Proxy: return value Proxy —> Client: return value

Thought Experiment: Universal CGLIB Proxying

Thought Experiment: Universal CGLIB Proxying

Scenario: A large LogisticsCore application sets @EnableTransactionManagement(proxyTargetClass = true) globally.

Implications:

  1. Bean Finality: No bean class can be final. This becomes a hard constraint. Utility classes or value objects marked final for immutability will fail at runtime if proxied.
  2. Constructor Logic: The target constructor runs once per proxy instance (via super()). However, if the bean is defined in a @Configuration class with proxyBeanMethods=true, nested proxying can occur, leading to multiple instantiations.
  3. Debugging: Stack traces include generated class names like *$$EnhancerBySpringCGLIB$$*, increasing cognitive load.
  4. Memory: Each proxied bean adds a class definition in Metaspace, increasing memory footprint.
  5. Startup Time: Context refresh slows due to CGLIB bytecode generation for all beans, not just those requiring proxies.
  6. Type Checking: instanceof checks against the concrete class fail. Code relying on getClass() == Target.class breaks.
  7. Serialization: CGLIB proxies are serializable, but deserialization may not restore interceptors, breaking AOP.

Conclusion: Forcing CGLIB universally simplifies the proxy model but introduces significant constraints. The default hybrid strategy (JDK for interfaces, CGLIB for concrete classes) is a pragmatic trade-off that balances flexibility and performance.

Sources

[1] Oracle, “java.lang.reflect.Proxy,” Java SE Documentation. [Online]. Available: https://docs.oracle.com/javase/8/docs/api/java/lang/reflect/Proxy.html [2] CGLIB, “CGLIB - Code Generation Library.” [Online]. Available: https://github.com/cglib/cglib [3] Spring Framework, “AOP - Aspect-Oriented Programming,” Spring Framework Reference. [Online]. Available: https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop