Files
spring-async-demo/async/docs/03-what-the-proxy-cannot-see.md
Ankur Mhatre 243cccd4ca Add the async module
Every @Async behaviour that surprises people, asserted by a test and captured
to docs/output/: the self-invocation trap, what CGLIB cannot override, the
IllegalArgumentException a plain return type throws, the unbounded queue that
makes max-size decoration, spring.task.execution.propagate-context (new in
Boot 4.1.0), the two Executor beans that leave @Async on an unpooled
SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
2026-09-01 23:27:47 +05:30

2.1 KiB

prev: The self-invocation trap · README · next: Return types and exceptions

3. What the proxy cannot see

Spring Boot proxies with CGLIB by default, so the proxy is a generated subclass of your bean:

proxy class : com.ankurm.async.VisibilityService$$SpringCGLIB$$0
isCglibProxy: true

A subclass can override public and protected methods. It cannot override final ones, and it cannot see private ones. @Async on either is inert, and Spring does not complain.

From docs/output/visibility.txt:

publicMethod()                     : task-3 (virtual=false)
finalMethod()                      : main (virtual=false)
protectedMethod() via the proxy    : task-4 (virtual=false)
callProtectedInternally()          : main (virtual=false)

Two things in that transcript are worth separating, because they look the same and are not:

  • finalMethod() ran on main because CGLIB could not override it. Calling it from another bean would not help.
  • callProtectedInternally() ran on main because it is a self-invocation (chapter 2). protectedMethod() itself is perfectly proxyable, and running on task-4 when the test calls it through the proxy proves it.

So "it ran on the caller's thread" has at least two distinct causes, and the fix differs. The test in VisibilityTest can call a protected method at all only because it lives in the same package as the service.

The final class case

A final class cannot be subclassed either, so CGLIB cannot proxy it at all. That one does fail loudly — context startup throws — which makes it much less dangerous than a final method.

Kotlin

Kotlin classes and members are final unless declared open. A Kotlin service with @Async and no open keyword, and no kotlin-spring compiler plugin, is the final case above. The kotlin-spring plugin exists to open Spring-annotated classes automatically; it does not open methods annotated only with @Async unless the class-level rule already applies.

next: Return types and exceptions