Spring offers strong support for aspect-oriented programming (AOP). Its purpose is to separate business logic from cross-cutting system concerns such as auditing and transaction management, so application code can stay focused on what it is actually supposed to do. A business object should handle business rules and nothing more; it should not need to manage logging, transactions, or similar infrastructure concerns, and ideally it should not even be aware of them.

In Spring, AOP is implemented through dynamic proxies. Proxying itself is a classic design pattern, but the terms static proxy and dynamic proxy here are not categories from the GoF design pattern list. They are Java-side implementation styles distinguished by when the proxy class is created. In other words, they are concrete technical ways to apply the proxy pattern.

What a proxy does

A typical proxy call path looks like this:

A (caller) -> C (proxy) -> B (target)

Instead of calling B directly, the caller reaches it through C. That indirection is the essence of a proxy.

A proxy is commonly useful in three situations:

  1. When two objects cannot interact directly
    Think of an intermediary introducing two people who do not know each other.

  2. When some code needs enhancement or decoupling
    The proxy can wrap the original behavior and add extra handling without changing the target implementation.

  3. When access needs protection
    The proxy can act as a gatekeeper and decide what reaches the target.

In all of these cases, the target object remains the real worker, while the proxy stands in front of it.

Starting with a tightly coupled example

Suppose we want a very simple login function: the username is admin, and the password is 123456.

First, define a LoginService interface:

package top.dhbxs.demo.springaop.staticproxy.service;

/**
 * @author dhbxs
 * @since 2026/4/19
 */
public interface LoginService {

    boolean login(String username, String password);
}

Then implement it:

package top.dhbxs.demo.springaop.staticproxy.service.impl;

import lombok.extern.slf4j.Slf4j;
import top.dhbxs.demo.springaop.staticproxy.service.LoginService;

/**
 * @author dhbxs
 * @since 2026/4/19
 */
@Slf4j
public class LoginServiceImpl implements LoginService {

    @Override
    public boolean login(String username, String password) {
        log.info("开始登陆"); // 日志:非核心业务代码
        log.info("{} 正在登陆系统", username); // 日志:非核心业务代码
        boolean flag = "admin".equals(username) && "123456".equals(password); // 核心业务代码
        if (!flag) {
            log.error("{} 登陆失败", username); // 日志:非核心业务代码
        } else {
            log.info("{} 登陆成功", username); // 日志:非核心业务代码
        }
        return flag;
    }
}

And test it:

package top.dhbxs.demo.springaop.staticproxy.service;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import top.dhbxs.demo.springaop.staticproxy.service.impl.LoginServiceImpl;

@SpringBootTest
class LoginServiceTest {

    @Test
    void login() {
        LoginService loginService = new LoginServiceImpl();

        boolean flag = loginService.login("admin", "123456");
        System.out.println("flag = " + flag);
    }
}

The output is:

2026-04-19T22:17:05.145+08:00  INFO 28308 --- [SpringAOP] [           main] t.d.d.s.s.service.impl.LoginServiceImpl  : 开始登陆
2026-04-19T22:17:05.145+08:00  INFO 28308 --- [SpringAOP] [           main] t.d.d.s.s.service.impl.LoginServiceImpl  : admin 正在登陆系统
2026-04-19T22:17:05.146+08:00  INFO 28308 --- [SpringAOP] [           main] t.d.d.s.s.service.impl.LoginServiceImpl  : admin 登陆成功
flag = true

The login logic itself is straightforward, but the implementation mixes business code with a lot of logging. That means infrastructure concerns and core logic are tightly coupled.

Static proxy: move the logging out

A static proxy improves this by placing the extra behavior in another class.

Create a proxy class that implements the same LoginService interface:

package top.dhbxs.demo.springaop.staticproxy.service.proxy;

import lombok.extern.slf4j.Slf4j;
import top.dhbxs.demo.springaop.staticproxy.service.LoginService;
import top.dhbxs.demo.springaop.staticproxy.service.impl.LoginServiceImpl;

/**
 * 静态代理:解决代码耦合
 * 代理类和目标类都实现了同一个接口
 *
 * @author dhbxs
 * @since 2026/4/19
 */
@Slf4j
public class LoginServiceProxy implements LoginService {


    /**
     * 代理方法,帮你调用目标
     *
     * @param username 用户名
     * @param password 密码
     * @return 登陆结果
     */
    @Override
    public boolean login(String username, String password) {


        log.info("开始登陆"); // 日志:非核心业务代码
        log.info("{} 正在登陆系统", username); // 日志:非核心业务代码

        LoginService loginService = new LoginServiceImpl(); // 创建目标对象
        boolean flag = loginService.login(username, password); // 调用目标方法

        if (!flag) {
            log.error("{} 登陆失败", username); // 日志:非核心业务代码
        } else {
            log.info("{} 登陆成功", username); // 日志:非核心业务代码
        }

        return flag;
    }
}

Now simplify the target implementation so it only contains the core business logic:

package top.dhbxs.demo.springaop.staticproxy.service.impl;

import lombok.extern.slf4j.Slf4j;
import top.dhbxs.demo.springaop.staticproxy.service.LoginService;

/**
 * @author dhbxs
 * @since 2026/4/19
 */
@Slf4j
public class LoginServiceImpl implements LoginService {

    @Override
    public boolean login(String username, String password) {

        boolean flag = "admin".equals(username) && "123456".equals(password); // 核心业务代码

        return flag;
    }
}

And update the test to call the proxy instead of the target directly:

package top.dhbxs.demo.springaop.staticproxy.service;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import top.dhbxs.demo.springaop.staticproxy.service.proxy.LoginServiceProxy;

@SpringBootTest
class LoginServiceTest {

    @Test
    void login() {
        LoginService loginService = new LoginServiceProxy(); // 创建代理对象

        boolean flag = loginService.login("admin", "123456"); // 调用代理对象方法
        System.out.println("flag = " + flag);
    }
}

This version decouples logging from the login logic. The caller still works with LoginService, but it talks to the proxy object, and the proxy performs the extra work before and after delegating to the real target.

The drawback is obvious: every target may need its own manually written proxy class. That quickly becomes repetitive, which is exactly why dynamic proxying matters.

Dynamic proxy

The two common dynamic proxy approaches in Java are:

  • JDK dynamic proxy
  • CGLIB dynamic proxy

JDK dynamic proxy

JDK dynamic proxy is interface-based. Both the proxy class and the target class share the same interface. The proxy class is generated at runtime through reflection, and its class name usually starts with $Proxy followed by a number.

Core APIs:

  • java.lang.reflect.Proxy — generates the proxy object
  • InvocationHandler — defines the enhancement logic

Call flow:

A -> C (proxy object -> InvocationHandler.invoke()) -> B (target)

Create a new package for the JDK proxy example, copy the earlier LoginService interface and LoginServiceImpl implementation into it, and use this test code:

package top.dhbxs.demo.springaop.jdkproxy.service;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import top.dhbxs.demo.springaop.jdkproxy.service.impl.LoginServiceImpl;

import java.lang.reflect.Proxy;

@Slf4j
@SpringBootTest
class LoginServiceTest {

    @Test
    void login() {
        // 创建目标对象
        LoginService loginService = new LoginServiceImpl();

        // 创建代理对象
        // 代理对象和目标对象都实现了同一个接口,所以这个方法默认返回的 Object 对象可以强制向下转型为LoginService
        LoginService loginServiceProxy = (LoginService) Proxy.newProxyInstance(
                loginService.getClass().getClassLoader(), // 类加载器,需要提供一个类加载器,让动态生成的字节码能加载到内存中
                loginService.getClass().getInterfaces(), // 代理类要实现哪个接口,或者哪些接口
                (proxy, method, args) -> { // InvocationHandler 对象 隐式函数式接口
                    log.info("开始登陆"); // 日志:非核心业务代码
                    log.info("{} 正在登陆系统", args[0]); // 日志:非核心业务代码

                    boolean flag = (boolean) method.invoke(loginService, args); // 调用目标方法

                    if (!flag) {
                        log.error("{} 登陆失败", args[0]); // 日志:非核心业务代码
                    } else {
                        log.info("{} 登陆成功", args[0]); // 日志:非核心业务代码
                    }

                    return flag;
                });


        // 调用代理对象
        boolean flag = loginServiceProxy.login("admin", "123456");
        System.out.println("flag = " + flag);
    }
}

How JDK dynamic proxy creation works

Creating a proxy for a target object through JDK dynamic proxy can be understood in three steps.

1. Create the target object first
LoginService loginService = new LoginServiceImpl();

The class being enhanced is the target class, here LoginServiceImpl. The reference is declared as the interface type LoginService, which is the usual use of polymorphism. This matters because JDK dynamic proxies work through interfaces, so the target class must implement one.

2. Create the proxy object

JDK dynamic proxies are mainly created through Proxy.newProxyInstance() from java.lang.reflect.Proxy.

In essence, this method does two things:

  • Dynamically generates the bytecode of a proxy class in memory
  • Instantiates an object from that generated proxy class

The method takes three parameters: a class loader, a set of interfaces, and an invocation handler.

Parameter 1: ClassLoader loader

This tells the JDK which class loader should load the dynamically generated proxy class into memory. In most cases, using the target object’s class loader is enough:

loginService.getClass().getClassLoader()

In Spring, class loader selection is handled by a static method in org.springframework.util.ClassUtils, named getDefaultClassLoader():

/**
 * Return the default ClassLoader to use: typically the thread context
 * ClassLoader, if available; the ClassLoader that loaded the ClassUtils
 * class will be used as fallback.
 * <p>Call this method if you intend to use the thread context ClassLoader
 * in a scenario where you clearly prefer a non-null ClassLoader reference:
 * for example, for class path resource loading (but not necessarily for
 * {@code Class.forName}, which accepts a {@code null} ClassLoader
 * reference as well).
 * @return the default ClassLoader (only {@code null} if even the system
 * ClassLoader isn't accessible)
 * @see Thread#getContextClassLoader()
 * @see ClassLoader#getSystemClassLoader()
 */
@Nullable
public static ClassLoader getDefaultClassLoader() {
    ClassLoader cl = null;
    try {
        cl = Thread.currentThread().getContextClassLoader();
    }
    catch (Throwable ex) {
        // Cannot access thread context ClassLoader - falling back...
    }
    if (cl == null) {
        // No thread context class loader -> use class loader of this class.
        cl = ClassUtils.class.getClassLoader();
        if (cl == null) {
            // getClassLoader() returning null indicates the bootstrap ClassLoader
            try {
                cl = ClassLoader.getSystemClassLoader();
            }
            catch (Throwable ex) {
                // Cannot access system ClassLoader - oh well, maybe the caller can live with null...
            }
        }
    }
    return cl;
}

Spring uses a three-level fallback strategy:

  1. Prefer the current thread context class loader (ThreadContextClassLoader, or TCCL)
  2. If that is unavailable, fall back to Spring’s own class loader
  3. If that also fails, fall back again to the system class loader

If none of them can be used, null is returned and the caller must handle it.

Parameter 2: Class<?>[] interfaces

This specifies which interfaces the generated proxy class should implement.

Just like in static proxying, the proxy must expose the same contract as the target. That way callers can interact with the proxy without caring whether they are using the original object or the wrapped one. If the proxy did not implement the same interface, it would no longer behave as a true proxy for that target.

In the example, the code uses:

loginService.getClass().getInterfaces()

loginService is the target object. Calling getClass() gets the runtime class, which is LoginServiceImpl.class, and getInterfaces() returns the interfaces implemented by that class, which includes LoginService.class.

The return type is an array because a class can implement multiple interfaces, even though it can extend only one parent class.

Parameter 3: InvocationHandler h

This is the invocation handler. The JDK generates the proxy class for us, and the proxy implements the interfaces we requested, but the JDK still does not know what those methods should actually do. That behavior is defined here.

InvocationHandler is an interface. It does not carry the @FunctionalInterface annotation, but it contains only one abstract method, invoke(), while invokeDefault() is static, so it can still be treated as an implicit functional interface and written using a lambda.

The logic that enhances the target method goes inside invoke(). Whenever a proxy method is called, the JDK routes execution into that handler.

Its parameters are:

  • Object proxy — the generated proxy object itself
  • Method method — the target method being called
  • Object[] args — the arguments passed to that method

To call the real target method, reflection is used. Since method is already provided, method.invoke() can execute the target call.

In the example:

boolean flag = (boolean) method.invoke(loginService, args)

The first parameter is the target object, and the second is the argument list for that method call.

3. Call the proxy method

Once Proxy.newProxyInstance() returns the proxy object, it can be used just like the original target object, except that now the invocation goes through the proxy layer first.

In the example, that is simply:

loginServiceProxy.login("admin", "123456")

CGLIB dynamic proxy

CGLIB takes a different path. Instead of relying on interfaces, it works through inheritance. The generated proxy is a subclass of the target class, produced at runtime using bytecode generation tools.

That means:

  • the target class does not need to implement an interface
  • the proxy is created by subclassing the target class directly

Core APIs:

  • Enhancer — generates the proxy object
  • MethodInterceptor — defines the enhancement logic

1. Add the CGLIB dependency

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>3.3.0</version>
</dependency>

2. Create the test

package top.dhbxs.demo.springaop.cglibproxy.service;

import lombok.extern.slf4j.Slf4j;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@Slf4j
@SpringBootTest
class LoginServiceTest {

    @Test
    void login() {
        // 创建目标对象
        LoginService loginService = new LoginService();

        // 创建代理对象
        LoginService loginServiceProxy = (LoginService) Enhancer.create(
            LoginService.class,
            (MethodInterceptor) (proxyObj, method, argsArr, methodProxy) -> {
                log.info("开始登陆"); // 日志:非核心业务代码
                log.info("{} 正在登陆系统", argsArr[0]); // 日志:非核心业务代码
                boolean flag = (boolean) method.invoke(loginService, argsArr); // 调用目标对象方法
                if (!flag) {
                    log.error("{} 登陆失败", argsArr[0]); // 日志:非核心业务代码
                } else {
                    log.info("{} 登陆成功", argsArr[0]); // 日志:非核心业务代码
                }
                return flag;
            });

        // 调用代理对象
        boolean flag = loginServiceProxy.login("admin", "123456");
        System.out.println("flag = " + flag);
    }
}

3. Add the JVM option

Use this JVM argument:

--add-opens java.base/java.lang=ALL-UNNAMED

From JDK 9 onward, the module system (JPMS) tightened reflective access for security reasons. CGLIB tries to generate classes dynamically by reflecting into ClassLoader.defineClass(), but that method belongs to the java.lang package and is not opened to unnamed modules by default in a regular Java application.

Without that JVM option, the following error appears:

Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(...) accessible:
module java.base does not "opens java.lang" to unnamed module

4. Execution result

2026-04-19T23:36:10.364+08:00  INFO 15380 --- [SpringAOP] [           main] t.d.d.s.c.service.LoginServiceTest       : 开始登陆
2026-04-19T23:36:10.365+08:00  INFO 15380 --- [SpringAOP] [           main] t.d.d.s.c.service.LoginServiceTest       : admin 正在登陆系统
2026-04-19T23:36:10.367+08:00  INFO 15380 --- [SpringAOP] [           main] t.d.d.s.c.service.LoginServiceTest       : admin 登陆成功
flag = true

JDK proxy vs. CGLIB

The difference between the two approaches is straightforward:

<table> <thead> <tr> <th>Feature</th> <th>JDK Dynamic Proxy</th> <th>CGLIB Dynamic Proxy</th> </tr> </thead> <tbody> <tr> <td>Requirement</td> <td>Target class must implement an interface</td> <td>Target class does not need an interface</td> </tr> <tr> <td>Mechanism</td> <td>Implements the same interface as the target</td> <td>Inherits from the target class</td> </tr> <tr> <td>Core API</td> <td>Proxy + InvocationHandler</td> <td>Enhancer + MethodInterceptor</td> </tr> <tr> <td>Spring AOP behavior</td> <td>Used by default when the target has interfaces</td> <td>Used automatically when the target has no interface</td> </tr> </tbody> </table>

So the progression is clear: static proxy solves coupling but requires manual proxy classes, JDK dynamic proxy automates proxy generation for interface-based targets, and CGLIB fills the gap when there is no interface to work with. That is the foundation behind how Spring AOP weaves extra behavior around ordinary business code.