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.
This commit is contained in:
2026-09-01 23:27:47 +05:30
commit 243cccd4ca
53 changed files with 1891 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
== spring.task.execution.propagate-context (new in Boot 4.1.0) ==
caller thread : main (virtual=false)
RequestId set : req-4711
@Async thread saw: req-4711

View File

@@ -0,0 +1,14 @@
== Two custom Executor beans plus spring.task.execution.mode=force ==
Executor beans in this context: 3
reportsExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=reports-
emailsExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=emails-
applicationTaskExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task-
Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: true
@Async ran on : task-1 (virtual=false)

View File

@@ -0,0 +1,10 @@
== A single custom Executor bean ==
Executor beans in this context: 1
myExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=mine-
Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: false
@Async ran on : mine-1 (virtual=false)

View File

@@ -0,0 +1,8 @@
== Stock Spring Boot 4.1.1 context, nothing configured ==
Executor beans in this context: 1
applicationTaskExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task-
Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: true

View File

@@ -0,0 +1,12 @@
== Two custom Executor beans, no 'taskExecutor' ==
Executor beans in this context: 2
reportsExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=reports-
emailsExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=emails-
Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: false
@Async ran on : SimpleAsyncTaskExecutor-1 (virtual=false)

View File

@@ -0,0 +1,20 @@
---------------------------------------------------------------
java.version : 21.0.12.1
jdk.virtualThreadScheduler.parallelism: 2
tasks=32 sleep=300ms
no monitor held : 313 ms
blocked inside synchronized: 4806 ms
Pinned would be about 4800 ms for the synchronized run (32 tasks / 2 carriers x 300 ms).
Not pinned is about 300 ms for both.
---------------------------------------------------------------
java.version : 25.0.4.1
jdk.virtualThreadScheduler.parallelism: 2
tasks=32 sleep=300ms
no monitor held : 312 ms
blocked inside synchronized: 301 ms
Pinned would be about 4800 ms for the synchronized run (32 tasks / 2 carriers x 300 ms).
Not pinned is about 300 ms for both.

View File

@@ -0,0 +1,6 @@
== core-size=4, max-size=12, queue-capacity=4, 16 blocking tasks ==
distinct threads that ran a task : 12
thread names : [task-6, task-7, task-11, task-12, task-10, task-8, task-9, task-1, task-2, task-3, task-4, task-5]
4 core threads, 4 tasks queued, 8 more threads created up to max-size.

View File

@@ -0,0 +1,6 @@
== core-size=4, max-size=12, queue-capacity=<unbounded default>, 16 blocking tasks ==
distinct threads that ran a task : 4
thread names : [task-1, task-2, task-3, task-4]
max-size had no effect: the queue never refused a task.

View File

@@ -0,0 +1,11 @@
== What each @Async return type hands back ==
completableFuture().get() : task-5 (virtual=false)
plainString() : java.lang.IllegalArgumentException: Invalid return type for async method (only Future and void supported): class java.lang.String
futureThatThrows().get() : IllegalStateException: thrown from a CompletableFuture @Async method
voidThatThrows() : returned normally. The exception went to
SimpleAsyncUncaughtExceptionHandler, which logs it at
ERROR under the logger
o.s.a.i.SimpleAsyncUncaughtExceptionHandler and
discards it. The caller is never told.

View File

@@ -0,0 +1,7 @@
== Which thread each call actually ran on (Boot 4.1.1, JDK 25) ==
caller thread : main (virtual=false)
service.annotated() : task-7 (virtual=false)
service.viaSelfInvocation() : main (virtual=false)
service.viaSelfReference() : task-8 (virtual=false)

View File

@@ -0,0 +1,26 @@
[INFO] Running com.ankurm.async.ContextNotPropagatedTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.804 s -- in com.ankurm.async.ContextNotPropagatedTest
[INFO] Running com.ankurm.async.VisibilityTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.068 s -- in com.ankurm.async.VisibilityTest
[INFO] Running com.ankurm.async.VirtualThreadsTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.204 s -- in com.ankurm.async.VirtualThreadsTest
[INFO] Running com.ankurm.async.ExecutorReportTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.015 s -- in com.ankurm.async.ExecutorReportTest
[INFO] Running com.ankurm.async.BoundedQueueTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.612 s -- in com.ankurm.async.BoundedQueueTest
[INFO] Running com.ankurm.async.OwnExecutorTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.102 s -- in com.ankurm.async.OwnExecutorTest
[INFO] Running com.ankurm.async.UnboundedQueueTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.624 s -- in com.ankurm.async.UnboundedQueueTest
[INFO] Running com.ankurm.async.ForceModeTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.126 s -- in com.ankurm.async.ForceModeTest
[INFO] Running com.ankurm.async.SelfInvocationTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.030 s -- in com.ankurm.async.SelfInvocationTest
[INFO] Running com.ankurm.async.TwoExecutorsTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.116 s -- in com.ankurm.async.TwoExecutorsTest
[INFO] Running com.ankurm.async.ReturnTypeTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.025 s -- in com.ankurm.async.ReturnTypeTest
[INFO] Running com.ankurm.async.ContextPropagatedTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.096 s -- in com.ankurm.async.ContextPropagatedTest
[INFO] Tests run: 22, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

View File

@@ -0,0 +1,10 @@
== spring.threads.virtual.enabled=true, with pool properties still set ==
applicationTaskExecutor : org.springframework.core.task.SimpleAsyncTaskExecutor
@Async ran on : task-1 (virtual=true)
Executor beans in this context: 1
applicationTaskExecutor -> org.springframework.core.task.SimpleAsyncTaskExecutor [TaskExecutor]
Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: true

View File

@@ -0,0 +1,9 @@
== @Async against final, protected and private methods ==
proxy class : com.ankurm.async.VisibilityService$$SpringCGLIB$$0
isCglibProxy : true
publicMethod() : task-4 (virtual=false)
finalMethod() : main (virtual=false)
protectedMethod() via the proxy : task-5 (virtual=false)
callProtectedInternally() : main (virtual=false)