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 9b3fe88813
53 changed files with 1891 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
[README](../README.md) · next: [The self-invocation trap](02-the-self-invocation-trap.md)
# 1. What `@Async` actually does
`@Async` is not a keyword and it is not a thread. It is a marker that
[`AsyncAnnotationBeanPostProcessor`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/AsyncAnnotationBeanPostProcessor.html)
looks for while the context is being built. When it finds one, it wraps the bean in a proxy. The
proxy's version of the method does not call your code; it wraps your code in a `Callable`, hands
that to an `AsyncTaskExecutor`, and returns immediately.
Three consequences follow from that sentence, and between them they explain almost every
`@Async` question ever asked:
1. **The proxy is the whole mechanism.** A call that does not go through the proxy is not
asynchronous. See [chapter 2](02-the-self-invocation-trap.md) and
[chapter 3](03-what-the-proxy-cannot-see.md).
2. **The return value has to be produced before your code runs.** So the return type is
constrained, and exceptions have nowhere obvious to go. See
[chapter 4](04-return-types-and-exceptions.md).
3. **Which executor gets the `Callable` is resolved separately, by type and then by name.** It is
not necessarily the one you configured. See [chapter 7](07-which-executor-runs-it.md).
## `@EnableAsync` is not automatic
Spring Boot auto-configures the *executor*. It does not enable the *annotation*. Without
`@EnableAsync` somewhere in the context, no post-processor is installed, no proxy is created, and
every `@Async` method in the application runs on its caller's thread. There is no warning at any
log level, because from Spring's point of view nothing unusual has happened — you have a bean
with an annotation nobody asked it to process.
`AsyncDemoApplication` carries the annotation for exactly this reason.
## The evidence in this module
Every claim in these chapters is asserted by a test and captured in `docs/output/`. The
measurement is always the same one: the name of the thread the method body actually ran on,
returned from the method itself.
```
caller thread : main (virtual=false)
service.annotated() : task-5 (virtual=false)
service.viaSelfInvocation() : main (virtual=false)
service.viaSelfReference() : task-6 (virtual=false)
```
Timing cannot tell you this. A method that runs synchronously in 3 ms and a method that runs on a
pool thread in 3 ms look identical from the outside, which is why `@Async` failures survive so
long in production.
next: [The self-invocation trap](02-the-self-invocation-trap.md)

View File

@@ -0,0 +1,63 @@
prev: [What `@Async` actually does](01-what-async-actually-does.md) · [README](../README.md) · next: [What the proxy cannot see](03-what-the-proxy-cannot-see.md)
# 2. The self-invocation trap
The proxy wraps the bean. It does not replace `this`.
```java
@Async
public CompletableFuture<String> annotated() { ... }
public CompletableFuture<String> viaSelfInvocation() {
return annotated(); // this.annotated() — the proxy is not involved
}
```
When another bean calls `service.annotated()`, it holds a reference to the *proxy*, so the call
is intercepted. When `viaSelfInvocation()` calls `annotated()`, the compiler emits
`this.annotated()`, and `this` inside the method body is the *target* object, not the proxy.
There is no interception, so the method runs inline and the `CompletableFuture` you get back is
already complete.
Measured, in [`docs/output/self-invocation.txt`](output/self-invocation.txt):
```
service.annotated() : task-5 (virtual=false)
service.viaSelfInvocation() : main (virtual=false)
service.viaSelfReference() : task-6 (virtual=false)
```
Nothing is logged. The method returns a valid future with the correct value in it. The only
symptom is that the caller waited.
## The fixes, in the order you should prefer them
**Move the method to another bean.** If work is asynchronous, it belongs behind a boundary, and
the boundary is a good place for a class. This is the fix that survives a refactor.
**Go back out through the proxy on purpose.** `SelfInvocationService` does this with an
`ObjectProvider<SelfInvocationService>`:
```java
public CompletableFuture<String> viaSelfReference() {
return self.getObject().annotated();
}
```
`ObjectProvider` is lazy, so there is no circular dependency to resolve at construction time.
Injecting `SelfInvocationService` directly also works — Spring special-cases self-references —
but it looks like a bug to every reviewer who has not read this chapter.
**`@EnableAsync(mode = AdviceMode.ASPECTJ)`** removes the limitation entirely, because
load-time weaving rewrites the method itself rather than wrapping the object. It also adds an
agent to your startup and a weaving configuration to your build. Very few applications should
pay that.
## Why this keeps happening
The trap is not that people do not know about proxies. It is that the calling code often did not
start out in the same class. A method gets extracted, a controller's logic moves into the
service that already had the `@Async` method, and one day a call that used to arrive from outside
arrives from inside. Nothing fails. The throughput just changes.
next: [What the proxy cannot see](03-what-the-proxy-cannot-see.md)

View File

@@ -0,0 +1,48 @@
prev: [The self-invocation trap](02-the-self-invocation-trap.md) · [README](../README.md) · next: [Return types and exceptions](04-return-types-and-exceptions.md)
# 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`](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](04-return-types-and-exceptions.md)

View File

@@ -0,0 +1,61 @@
prev: [What the proxy cannot see](03-what-the-proxy-cannot-see.md) · [README](../README.md) · next: [Pool sizing](05-pool-sizing.md)
# 4. Return types, and where the exceptions go
The proxy must return something to the caller before your method has run. That limits what it
can return, and `AsyncExecutionAspectSupport.doSubmit` enumerates the cases:
| declared return type | what the caller gets |
|---|---|
| `CompletableFuture<T>` | the future from `executor.submitCompletable(task)` |
| `Future<T>` | the future from `executor.submit(task)` |
| `void` (or Kotlin `Unit`) | `null`, after submitting |
| anything else | **`IllegalArgumentException`, thrown at the call site** |
That last row is worth stating plainly, because a great deal of writing on `@Async` claims such a
method "returns null". It does not. From [`docs/output/return-types.txt`](output/return-types.txt):
```
plainString() : java.lang.IllegalArgumentException: Invalid return type for async method
(only Future and void supported): class java.lang.String
```
The method body never runs. Nothing rejects the signature at startup, so this is a runtime
failure on whichever code path reaches it first — which may be a rarely exercised one.
## Exceptions
An exception from a `Future`-returning method is delivered through the future. The caller sees it
if the caller calls `get()` or `join()`, and never sees it otherwise:
```
futureThatThrows().get() : IllegalStateException: thrown from a CompletableFuture @Async method
```
An exception from a `void` method has nowhere to go. It is handed to the configured
`AsyncUncaughtExceptionHandler`; the default is `SimpleAsyncUncaughtExceptionHandler`, which logs
it at `ERROR` and discards it:
```
ERROR ... o.s.a.i.SimpleAsyncUncaughtExceptionHandler :
Unexpected exception occurred invoking async method:
public void com.ankurm.async.ReturnTypeService.voidThatThrows()
java.lang.IllegalStateException: thrown from a void @Async method
```
That log line is the entire error handling of a `void @Async` method in a stock application. It
does not increment a metric, it does not fail a health check, and it does not reach the caller.
Replace it by implementing `AsyncConfigurer` and returning your own handler from
`getAsyncUncaughtExceptionHandler()`. Bear in mind that only one `AsyncConfigurer` may exist, and
in Boot 4.1 the auto-configuration already contributes one — so supplying your own also takes
over executor selection unless you return the auto-configured executor from
`getAsyncExecutor()`.
## The practical rule
Return `CompletableFuture<T>` even when the caller ignores the value, and make sure something
eventually calls `.exceptionally(...)` or `whenComplete(...)`. `void` is for fire-and-forget work
whose failure genuinely does not matter, and there is much less of that than people assume.
next: [Pool sizing](05-pool-sizing.md)

View File

@@ -0,0 +1,57 @@
prev: [Return types and exceptions](04-return-types-and-exceptions.md) · [README](../README.md) · next: [Context propagation](06-context-propagation.md)
# 5. Pool sizing, and why `max-size` usually does nothing
The stock executor, printed from the live bean in
[`docs/output/executor-report.txt`](output/executor-report.txt):
```
applicationTaskExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task-
```
Eight core threads, an effectively unbounded maximum, and an effectively unbounded queue.
`ThreadPoolTaskExecutor` delegates to `java.util.concurrent.ThreadPoolExecutor`, whose growth
rule is the part people misremember. It is not "grow under load". It is:
1. fewer than `corePoolSize` threads → create a thread;
2. otherwise → offer the task to the queue;
3. **only if the queue refuses** → create a thread, up to `maxPoolSize`;
4. otherwise → reject.
An unbounded queue never refuses. So with the defaults, step 3 is unreachable and `maxPoolSize`
is decoration.
Two runs of the same 16 blocking tasks, differing in one property:
| properties | distinct threads used |
|---|---|
| `core-size=4`, `max-size=12` | **4** ([output](output/pool-unbounded-queue.txt)) |
| `core-size=4`, `max-size=12`, `queue-capacity=4` | **12** ([output](output/pool-bounded-queue.txt)) |
Triple the concurrency, one line of YAML. The corollary is that raising `max-size` alone — the
usual response to a slow async pipeline — changes nothing at all.
## What to set instead
- **Set `queue-capacity` deliberately.** It is the backpressure boundary. Unbounded means an
incident consists of a heap filling up rather than tasks being rejected, which is the worse of
the two failure modes because it takes the whole process with it.
- **Size `core-size` to the work, not to the CPU.** Async work in most Spring applications is I/O
bound, so the useful number is closer to "how many concurrent downstream calls will that
service tolerate" than to `Runtime.availableProcessors()`.
- **Decide what rejection means.** The default policy is `AbortPolicy`, so a full queue and a
full pool produce `RejectedExecutionException` at the *call site* — synchronously, in whichever
thread called the `@Async` method. If you want the caller to absorb the load instead, use
`CallerRunsPolicy`, and understand that you have just made the method synchronous under
saturation.
## Shutdown
`spring.task.execution.shutdown.await-termination` is `false` by default. On shutdown, tasks that
are still queued are simply dropped. If your async work is "send the email" rather than "warm the
cache", set it to `true` and give
`spring.task.execution.shutdown.await-termination-period` a bound.
next: [Context propagation](06-context-propagation.md)

View File

@@ -0,0 +1,57 @@
prev: [Pool sizing](05-pool-sizing.md) · [README](../README.md) · next: [Which executor runs it](07-which-executor-runs-it.md)
# 6. `spring.task.execution.propagate-context` — new in Boot 4.1
Handing a `Callable` to another thread leaves every `ThreadLocal` behind. That is why the async
thread in a stock application sees no MDC entries, no `RequestAttributes`, and no
`SecurityContext` — and why "the trace id disappears in the async part" is such a common report.
Spring Boot 4.1.0 added a property for it:
```yaml
spring:
task:
execution:
propagate-context: true
```
It decorates the auto-configured executor with `ContextPropagatingTaskDecorator`, which takes a
snapshot of everything registered with micrometer's `ContextRegistry` at submission time and
restores it around the task on the executing thread.
## How new is it, exactly
New in **4.1.0**. It is absent from `TaskExecutionProperties` in 4.0.8 and in 3.5.9 — checked by
extracting `TaskExecutionProperties.class` from each `spring-boot-autoconfigure` jar and running
`javap` over it, rather than by reading release notes. The accessor pair
`getPropagateContext()`/`setPropagateContext(boolean)` appears first in the 4.1.0 jar.
Note also what it is *not*: there is no matching `spring.task.scheduling.propagate-context`. The
scheduler is not covered.
## Measured
`RequestId` in this module is a `ThreadLocal<String>` with a `ThreadLocalAccessor` registered
against `ContextRegistry`. Two tests differ only in the property:
- `ContextNotPropagatedTest` — the async thread reads `null`.
- `ContextPropagatedTest` — the async thread reads `req-4711`
([output](output/context-propagation.txt)).
## The three things that make it silently do nothing
1. **micrometer's `context-propagation` is not on the classpath.** The property is still bound
and still accepted; nothing decorates the executor. This module declares
`io.micrometer:context-propagation` explicitly for that reason.
2. **Nothing registered an accessor.** The snapshot only carries what `ContextRegistry` knows
about. Libraries that ship accessors (Micrometer tracing, Reactor) register their own; a
`ThreadLocal` of your own does not register itself.
3. **The executor is not the auto-configured one.** The decorator is applied by Boot's
auto-configuration. Declare your own `Executor` bean (chapter 7) and you have opted out of
the property along with everything else Boot was doing.
`SecurityContext` propagation is a related but separate mechanism, with its own set of ways to
get it wrong; that is covered in
[Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/).
next: [Which executor runs it](07-which-executor-runs-it.md)

View File

@@ -0,0 +1,67 @@
prev: [Context propagation](06-context-propagation.md) · [README](../README.md) · next: [Virtual threads and pinning](08-virtual-threads-and-pinning.md)
# 7. Which executor actually runs it
Two independent decisions are involved, and conflating them is the source of most of the
surprise.
**Decision one — does Boot create `applicationTaskExecutor`?** `TaskExecutorConfigurations` gates
it on `OnExecutorCondition`, an `AnyNestedCondition` whose arms are "there is no `Executor` bean"
and "`spring.task.execution.mode` is `force`". So *any* `Executor` bean of your own removes
Boot's — along with its properties, its thread-name prefix, and its context-propagation
decorator.
**Decision two — which executor does `@Async` resolve?** `AsyncExecutionAspectSupport` asks the
bean factory for a unique bean of type `TaskExecutor`. Failing that, it looks for a bean named
exactly `taskExecutor`. Failing that, it falls back to a plain `SimpleAsyncTaskExecutor`.
Three contexts, all captured:
| context | executor beans | `@Async` ran on |
|---|---|---|
| stock ([output](output/executor-report.txt)) | `applicationTaskExecutor` | `task-1` |
| one custom `Executor` ([output](output/executor-one-custom.txt)) | `myExecutor` | `mine-1` |
| two custom `Executor`s ([output](output/executor-two-custom.txt)) | `reportsExecutor`, `emailsExecutor` | **`SimpleAsyncTaskExecutor-1`** |
The third row is the one that hurts. Neither of your carefully sized two-thread pools is used.
`SimpleAsyncTaskExecutor` starts **a brand new platform thread for every single call** and has no
bound, so an application that was throttled to two concurrent report generations is now
unthrottled, and the symptom is thread exhaustion under load rather than anything at the point of
the change.
It is not entirely silent — the interceptor logs it, once, at `INFO`:
```
o.s.a.i.AnnotationAsyncExecutionInterceptor : More than one TaskExecutor bean found within
the context, and none is named 'taskExecutor'. Mark one of them as primary or name it
'taskExecutor' (possibly as an alias) in order to use it for async processing:
[reportsExecutor, emailsExecutor]
```
`INFO`, on first use, in the middle of startup noise. In practice nobody sees it.
## The three ways out
- **`@Async("reportsExecutor")`** — name the executor at each call site. Explicit, and it
survives someone adding a third executor later. This is the right answer when the executors
genuinely differ in purpose.
- **`spring.task.execution.mode=force`** — Boot creates `applicationTaskExecutor` alongside
yours, and `@Async` resolves it ([output](output/executor-force-mode.txt)). Use it when your
extra `Executor` beans exist for something other than `@Async` and you did not mean to disturb
it.
- **Name one of them `taskExecutor`**, or mark it `@Primary`. Works, and reads like an accident
to the next person.
## The diagnostic
`ExecutorDiagnostics` prints every `Executor` bean, its class, and the real pool numbers off the
live object. Two lines of it answer questions that otherwise take an afternoon:
```
Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: false
```
Delete it before shipping. It is a diagnostic, not a feature.
next: [Virtual threads and pinning](08-virtual-threads-and-pinning.md)

View File

@@ -0,0 +1,70 @@
prev: [Which executor runs it](07-which-executor-runs-it.md) · [README](../README.md)
# 8. Virtual threads, and the pinning advice that expired
```yaml
spring:
threads:
virtual:
enabled: true
```
`applicationTaskExecutor` becomes a `SimpleAsyncTaskExecutor` over virtual threads. From
[`docs/output/virtual-threads.txt`](output/virtual-threads.txt):
```
applicationTaskExecutor : org.springframework.core.task.SimpleAsyncTaskExecutor
@Async ran on : task-1 (virtual=true)
```
Note that the thread-name prefix is unchanged, so `task-1` alone does not tell you which world
you are in. `Thread.currentThread().isVirtual()` does.
## What you gave up
The pool properties are still bound and now mean nothing. Boot's own metadata says so for each
one: *"Doesn't have an effect if virtual threads are enabled."* `core-size`, `max-size`,
`queue-capacity`, `keep-alive` — all inert. The test in `VirtualThreadsTest` sets `core-size=4`
and `max-size=12` precisely to show that they are accepted and ignored.
More importantly, a `SimpleAsyncTaskExecutor` has **no queue and, by default, no concurrency
limit**. Cheap threads are not free downstream capacity: ten thousand concurrent `@Async` calls
to a service with a twenty-connection pool is ten thousand threads queueing on a semaphore. If
you want a bound, set `spring.task.execution.simple.concurrency-limit`, and decide whether
`spring.task.execution.simple.reject-tasks-when-limit-reached` should be `true` (fail fast) or
left `false` (block the caller).
## The pinning advice
Nearly everything written about virtual threads before 2025 tells you to avoid `synchronized`,
because a virtual thread that blocks while holding a monitor pins its carrier. JEP 491, delivered
in **JDK 24**, removed that.
`PinningProbe` runs 32 virtual threads, each sleeping 300 ms, with the scheduler limited to two
carrier threads — once with the sleep inside a `synchronized` block on an uncontended private
monitor, once without. If pinning happens, the guarded run must take about 32 / 2 × 300 ms =
4800 ms. The same class file, run on both JVMs
([`docs/output/pinning-probe.txt`](output/pinning-probe.txt)):
```
java.version : 21.0.12.1
no monitor held : 313 ms
blocked inside synchronized: 4806 ms
java.version : 25.0.4.1
no monitor held : 312 ms
blocked inside synchronized: 301 ms
```
4806 ms against the 4800 ms the arithmetic predicts, then the whole effect gone.
**What has not changed:** a virtual thread still pins its carrier while executing a native frame
or inside a class initialiser. And `synchronized` is still a mutual-exclusion lock, so a
*contended* monitor still serialises your work — JEP 491 removed the carrier-thread cost, not the
lock. `ReentrantLock` remains preferable where you want fairness, timeouts or `tryLock`; it is no
longer required merely to avoid pinning.
If you are on JDK 21 — still an LTS, still perfectly reasonable — the old advice is your advice,
and the 4806 ms above is what it is protecting you from.
[README](../README.md)

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)