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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
*.log

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# spring-async-demo
Companion code for the asynchronous execution series on [ankurm.com](https://ankurm.com). Each
directory is a self-contained Maven project for one article, with its own `pom.xml`, its own
numbered documentation chapters, and its own captured output under `docs/output/` — regenerated
by that module's `scripts/run-all.sh`, never typed by hand.
| Module | Article | What it demonstrates |
|---|---|---|
| [`async/`](async/README.md) | [@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/) | Which thread a method actually ran on, in every case where the answer is not the one you expect |
## Common ground
All modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1), **Spring Boot
4.1.1**, **Spring Framework 7.0.9**. Versions were read from `maven-metadata.xml` on Maven Central
and from Boot's own `spring-boot-dependencies` POM, rather than from release announcements.
Everything is asserted by a test and captured to a file. The measurement is nearly always the same
one: the name of the thread that ran the work, returned by the code itself. Timing cannot tell a
fast synchronous call from an asynchronous one, which is why `@Async` failures survive so long in
production.
## Licence
MIT — see [LICENSE](LICENSE).

64
async/README.md Normal file
View File

@@ -0,0 +1,64 @@
# async
Companion project for **@Async in Spring Boot 4: Executors, Virtual Threads and the
Self-Invocation Trap** on [ankurm.com](https://ankurm.com).
Everything here is asserted by a test and captured under [`docs/output/`](docs/output). The
measurement is always the same: the name of the thread the method body actually ran on, returned
by the method itself. Timing cannot distinguish a fast synchronous call from an asynchronous one;
a thread name can.
## Verified stack
| Component | Version | Source of the number |
|---|---|---|
| JDK | 25.0.4.1+1 (Temurin) | `java -version` |
| Spring Boot | 4.1.1 | `maven-metadata.xml` on Maven Central |
| Spring Framework | 7.0.9 | `spring-boot-dependencies-4.1.1.pom` |
| micrometer context-propagation | Boot-managed | `spring-boot-dependencies-4.1.1.pom` |
| JDK used for the pinning comparison | 21.0.12.1+1 (Temurin) | `java -version` |
## Quickstart
```bash
mvn test # 22 tests, all of the transcripts
scripts/run-all.sh # regenerates every docs/output/ file
scripts/run-all.sh /path/to/jdk21/bin/java # adds the JDK 21 vs 25 pinning comparison
```
## Documentation
| Chapter | What it settles |
|---|---|
| [01 What `@Async` actually does](docs/01-what-async-actually-does.md) | The proxy is the whole mechanism, and `@EnableAsync` is not automatic |
| [02 The self-invocation trap](docs/02-the-self-invocation-trap.md) | Why an internal call runs inline, and the three fixes |
| [03 What the proxy cannot see](docs/03-what-the-proxy-cannot-see.md) | `final` and `private` methods, and how to tell that case from a self-invocation |
| [04 Return types and exceptions](docs/04-return-types-and-exceptions.md) | A plain return type throws; it does not return null |
| [05 Pool sizing](docs/05-pool-sizing.md) | Why `max-size` does nothing until `queue-capacity` is bounded |
| [06 Context propagation](docs/06-context-propagation.md) | `spring.task.execution.propagate-context`, new in Boot 4.1.0 |
| [07 Which executor runs it](docs/07-which-executor-runs-it.md) | Two `Executor` beans and `@Async` quietly uses neither |
| [08 Virtual threads and pinning](docs/08-virtual-threads-and-pinning.md) | JEP 491 measured on JDK 21 against JDK 25 |
## Captured output
| File | What it shows |
|---|---|
| [`self-invocation.txt`](docs/output/self-invocation.txt) | The same method on `task-5`, on `main`, and on `task-6` |
| [`visibility.txt`](docs/output/visibility.txt) | `final` is inert; `protected` is not |
| [`return-types.txt`](docs/output/return-types.txt) | The `IllegalArgumentException` a plain return type throws |
| [`executor-report.txt`](docs/output/executor-report.txt) | Stock pool numbers, read off the live bean |
| [`executor-one-custom.txt`](docs/output/executor-one-custom.txt) | Boot backing off in favour of one custom `Executor` |
| [`executor-two-custom.txt`](docs/output/executor-two-custom.txt) | Two custom executors and neither is used |
| [`executor-force-mode.txt`](docs/output/executor-force-mode.txt) | `spring.task.execution.mode=force` restoring it |
| [`pool-unbounded-queue.txt`](docs/output/pool-unbounded-queue.txt) | 16 tasks, 4 threads, `max-size=12` ignored |
| [`pool-bounded-queue.txt`](docs/output/pool-bounded-queue.txt) | The same 16 tasks on 12 threads |
| [`virtual-threads.txt`](docs/output/virtual-threads.txt) | `SimpleAsyncTaskExecutor`, `virtual=true`, pool properties inert |
| [`context-propagation.txt`](docs/output/context-propagation.txt) | A `ThreadLocal` surviving the hop |
| [`pinning-probe.txt`](docs/output/pinning-probe.txt) | 4806 ms on JDK 21, 301 ms on JDK 25 |
| [`tests.txt`](docs/output/tests.txt) | The test run behind all of the above |
## The one diagnostic
`ExecutorDiagnostics` prints every `Executor` bean in the context with its real core size, max
size and queue capacity. It is the fastest way to answer "which executor is actually running
this". Delete it before shipping.

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)

52
async/pom.xml Normal file
View File

@@ -0,0 +1,52 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- Boot 4.1.1 manages Spring Framework 7.0.9 and micrometer-context-propagation.
Versions were read from spring-boot-dependencies-4.1.1.pom, not from release notes. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>async</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- spring.task.execution.propagate-context (new in Boot 4.1.0) wires a
ContextPropagatingTaskDecorator, which needs micrometer's context-propagation on the
classpath. Without this dependency the property is accepted and silently does nothing.
See docs/06-context-propagation.md. -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>context-propagation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

24
async/scripts/pinning-probe.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Runs PinningProbe with the virtual-thread scheduler limited to two carrier threads, so that
# pinning (if it happened) would serialise the work into visible seconds.
#
# The probe is compiled on its own with --release 21 rather than as part of the module, so the
# same class file can be run on a JDK 21 and a JDK 25 JVM. That comparison is the whole point:
# JEP 491 landed in JDK 24, and the difference between the two runs is the feature.
#
# Usage: scripts/pinning-probe.sh <path-to-javac> <path-to-java> [more java binaries...]
set -euo pipefail
cd "$(dirname "$0")/.."
JAVAC_BIN="${1:-javac}"
shift || true
OUT=target/probe-classes
mkdir -p "$OUT"
"$JAVAC_BIN" --release 21 -d "$OUT" src/main/java/com/ankurm/async/PinningProbe.java
for JAVA_BIN in "${@:-java}"; do
echo "---------------------------------------------------------------"
"$JAVA_BIN" \
-Djdk.virtualThreadScheduler.parallelism=2 \
-Djdk.virtualThreadScheduler.maxPoolSize=2 \
-cp "$OUT" com.ankurm.async.PinningProbe
done

21
async/scripts/run-all.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/. Nothing in this module's documentation, or in the
# article it accompanies, is typed by hand.
#
# scripts/run-all.sh # tests only
# scripts/run-all.sh /path/to/jdk21/bin/java # tests plus the JDK 21 vs JDK 25 pinning run
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p docs/output
mvn -B test 2>&1 | grep -E 'Running |Tests run:|BUILD ' > docs/output/tests.txt
echo "wrote docs/output/tests.txt"
JAVA_BIN="$(command -v java)"
JAVAC_BIN="$(command -v javac)"
if [ $# -ge 1 ]; then
./scripts/pinning-probe.sh "$JAVAC_BIN" "$1" "$JAVA_BIN" > docs/output/pinning-probe.txt
else
./scripts/pinning-probe.sh "$JAVAC_BIN" "$JAVA_BIN" > docs/output/pinning-probe.txt
fi
echo "wrote docs/output/pinning-probe.txt"

View File

@@ -0,0 +1,22 @@
package com.ankurm.async;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* Entry point for the @Async demonstrations.
*
* <p>{@code @EnableAsync} is what installs {@code AsyncAnnotationBeanPostProcessor}. Spring Boot
* does <em>not</em> turn it on for you: without this annotation every {@code @Async} method in
* this project runs on the caller's thread and nothing warns you. That is the first and most
* boring way to lose a day &mdash; see docs/01-what-async-actually-does.md.
*/
@SpringBootApplication
@EnableAsync
public class AsyncDemoApplication {
public static void main(String[] args) {
SpringApplication.run(AsyncDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,16 @@
package com.ankurm.async;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/** Reports what the async thread can see of the caller's {@link RequestId}. */
@Service
public class ContextService {
@Async
public CompletableFuture<String> readRequestId() {
return CompletableFuture.completedFuture(String.valueOf(RequestId.get()));
}
}

View File

@@ -0,0 +1,51 @@
package com.ankurm.async;
import java.util.Map;
import java.util.concurrent.Executor;
import org.springframework.context.ApplicationContext;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
/**
* Prints the executors that actually exist in this context, rather than the ones you believe
* exist. Runtime state here is otherwise invisible: an {@code @Async} call that silently landed
* on {@code SimpleAsyncTaskExecutor} looks exactly like one that landed on a tuned pool until
* you read a thread name under load.
*
* <p>Delete this from anything you ship &mdash; it is a diagnostic, not a feature.
*/
@Component
public class ExecutorDiagnostics {
private final ApplicationContext context;
public ExecutorDiagnostics(ApplicationContext context) {
this.context = context;
}
public String report() {
StringBuilder out = new StringBuilder();
Map<String, Executor> executors = this.context.getBeansOfType(Executor.class);
out.append("Executor beans in this context: ").append(executors.size()).append('\n');
executors.forEach((name, executor) -> {
out.append(" ").append(name)
.append(" -> ").append(executor.getClass().getName())
.append(executor instanceof TaskExecutor ? " [TaskExecutor]" : "")
.append('\n');
if (executor instanceof ThreadPoolTaskExecutor pool) {
out.append(" corePoolSize=").append(pool.getCorePoolSize())
.append(" maxPoolSize=").append(pool.getMaxPoolSize())
.append(" queueCapacity=").append(pool.getQueueCapacity())
.append(" threadNamePrefix=").append(pool.getThreadNamePrefix())
.append('\n');
}
});
out.append("Bean named 'taskExecutor' present: ")
.append(this.context.containsBean("taskExecutor")).append('\n');
out.append("Bean named 'applicationTaskExecutor' present: ")
.append(this.context.containsBean("applicationTaskExecutor")).append('\n');
return out.toString();
}
}

View File

@@ -0,0 +1,60 @@
package com.ankurm.async;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* Three ways to have an {@code Executor} bean, with three different outcomes for {@code @Async}.
*
* <p>Boot's {@code applicationTaskExecutor} is conditional on there being no {@code Executor}
* bean already (see {@code TaskExecutorConfigurations.OnExecutorCondition}). Declaring one of
* your own therefore removes Boot's, and what {@code @Async} does next depends entirely on how
* many candidates are left.
*
* <p>See docs/07-which-executor-runs-it.md.
*/
@Configuration(proxyBeanMethods = false)
public class ExecutorSelectionConfig {
/**
* One custom executor. Boot backs off, this is the only candidate, {@code @Async} uses it.
* Threads are named {@code mine-N}.
*/
@Bean
@Profile("ownexecutor")
public Executor myExecutor() {
return build("mine-");
}
/**
* Two custom executors and neither is called {@code taskExecutor}. Boot still backs off, and
* {@code AsyncExecutionAspectSupport} cannot pick between them: it catches the
* {@code NoUniqueBeanDefinitionException}, looks for a bean literally named
* {@code taskExecutor}, does not find one, and falls back to a plain
* {@code SimpleAsyncTaskExecutor} &mdash; a new unpooled thread for every call, forever.
*/
@Bean
@Profile("twoexecutors")
public Executor reportsExecutor() {
return build("reports-");
}
@Bean
@Profile("twoexecutors")
public Executor emailsExecutor() {
return build("emails-");
}
private static ThreadPoolTaskExecutor build(String prefix) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setThreadNamePrefix(prefix);
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,77 @@
package com.ankurm.async;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Measures whether blocking inside a {@code synchronized} block pins the carrier thread.
*
* <p>Every guide written before JDK 24 tells you not to combine virtual threads with
* {@code synchronized}, because a virtual thread that blocks while holding a monitor pins its
* carrier and the carrier cannot run anything else. JEP 491 (JDK 24) removed that. This probe is
* how you check rather than believe: it runs the same 32 tasks twice, once inside a
* {@code synchronized} block on a private monitor and once without, with the scheduler limited
* to two carrier threads.
*
* <p>If pinning happens, the synchronized run takes roughly {@code tasks / carriers * sleep}.
* If it does not, both runs take roughly {@code sleep}.
*
* <p>Run it with scripts/pinning-probe.sh, which sets the carrier count. See
* docs/08-virtual-threads-and-pinning.md.
*/
public final class PinningProbe {
private static final int TASKS = 32;
private static final Duration SLEEP = Duration.ofMillis(300);
public static void main(String[] args) throws Exception {
System.out.println("java.version : " + System.getProperty("java.version"));
System.out.println("jdk.virtualThreadScheduler.parallelism: "
+ System.getProperty("jdk.virtualThreadScheduler.parallelism", "<unset>"));
System.out.println("tasks=" + TASKS + " sleep=" + SLEEP.toMillis() + "ms");
System.out.println();
long plain = run(false);
long guarded = run(true);
System.out.printf("no monitor held : %5d ms%n", plain);
System.out.printf("blocked inside synchronized: %5d ms%n", guarded);
System.out.println();
System.out.println("Pinned would be about " + (TASKS / 2) * SLEEP.toMillis()
+ " ms for the synchronized run (32 tasks / 2 carriers x 300 ms).");
System.out.println("Not pinned is about " + SLEEP.toMillis() + " ms for both.");
}
private static long run(boolean holdMonitor) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
long start = System.nanoTime();
for (int i = 0; i < TASKS; i++) {
threads.add(Thread.ofVirtual().start(() -> task(holdMonitor)));
}
for (Thread t : threads) {
t.join();
}
return Duration.ofNanos(System.nanoTime() - start).toMillis();
}
private static void task(boolean holdMonitor) {
// A monitor per task, so nothing here is contended. The question is only whether the
// carrier thread is released while this virtual thread sleeps.
Object monitor = new Object();
try {
if (holdMonitor) {
synchronized (monitor) {
Thread.sleep(SLEEP);
}
}
else {
Thread.sleep(SLEEP);
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}

View File

@@ -0,0 +1,69 @@
package com.ankurm.async;
import io.micrometer.context.ContextRegistry;
import io.micrometer.context.ThreadLocalAccessor;
/**
* A ThreadLocal that survives the hop onto an {@code @Async} thread &mdash; but only because it
* is registered with micrometer's {@link ContextRegistry} and only when
* {@code spring.task.execution.propagate-context} is on.
*
* <p>{@code propagate-context} is new in Spring Boot 4.1.0. It was not present in 4.0.8 (checked
* by decompiling {@code TaskExecutionProperties} from both jars). It makes Boot decorate the
* auto-configured executor with {@code ContextPropagatingTaskDecorator}, which captures whatever
* is registered here at submission time and restores it around the task.
*
* <p>See docs/06-context-propagation.md.
*/
public final class RequestId {
public static final String KEY = "requestId";
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
static {
ContextRegistry.getInstance().registerThreadLocalAccessor(new Accessor());
}
private RequestId() {
}
public static void set(String value) {
HOLDER.set(value);
}
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
/** Touching this class runs the static initialiser that registers the accessor. */
public static void register() {
}
private static final class Accessor implements ThreadLocalAccessor<String> {
@Override
public Object key() {
return KEY;
}
@Override
public String getValue() {
return HOLDER.get();
}
@Override
public void setValue(String value) {
HOLDER.set(value);
}
@Override
public void setValue() {
HOLDER.remove();
}
}
}

View File

@@ -0,0 +1,51 @@
package com.ankurm.async;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* Return types {@code @Async} supports, and the one that quietly returns null.
*
* <p>{@code AsyncExecutionAspectSupport.doSubmit} branches on the declared return type.
* {@code CompletableFuture} and {@code Future} are submitted and the handle is returned;
* {@code void} is submitted and {@code null} returned. Anything else &mdash; including a plain
* {@code String} &mdash; falls into the final {@code else} and throws
* {@code IllegalArgumentException: Invalid return type for async method (only Future and void
* supported)}. It is thrown to the caller, at call time, on every invocation. The widely
* repeated claim that such a method &ldquo;returns null&rdquo; is wrong; the method body never
* runs at all.
*
* <p>See docs/04-return-types-and-exceptions.md.
*/
@Service
public class ReturnTypeService {
@Async
public CompletableFuture<String> completableFuture() {
return CompletableFuture.completedFuture(Threads.describe());
}
/**
* Compiles cleanly and throws {@code IllegalArgumentException} on every call. Nothing about
* the signature is rejected at startup, so this is a runtime failure in whichever code path
* happens to reach it first.
*/
@Async
public String plainString() {
return Threads.describe();
}
/** Exception from a void method: the caller cannot see it. */
@Async
public void voidThatThrows() {
throw new IllegalStateException("thrown from a void @Async method");
}
/** Exception from a future-returning method: the caller can see it, if it looks. */
@Async
public CompletableFuture<String> futureThatThrows() {
throw new IllegalStateException("thrown from a CompletableFuture @Async method");
}
}

View File

@@ -0,0 +1,51 @@
package com.ankurm.async;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* Where the pool actually grows, and where it does not.
*
* <p>{@code ThreadPoolTaskExecutor} wraps a {@code ThreadPoolExecutor}, and {@code
* ThreadPoolExecutor} only creates a thread beyond the core size when the queue refuses a task.
* Spring Boot's default queue capacity is unbounded, so it never refuses, so the pool never grows
* past {@code spring.task.execution.pool.core-size} &mdash; whatever {@code max-size} says.
*
* <p>Each method records the distinct thread names it ran on. Counting them is the measurement.
*
* <p>See docs/05-pool-sizing.md.
*/
@Service
public class SaturationService {
private final Set<String> threadsSeen = new ConcurrentSkipListSet<>();
/**
* Blocks until released, so every concurrently running task holds its thread. That is what
* makes the number of distinct threads equal to the pool's real concurrency.
*/
@Async
public void occupyAThread(CountDownLatch arrived, CountDownLatch release) {
threadsSeen.add(Threads.name());
arrived.countDown();
try {
release.await(30, TimeUnit.SECONDS);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
public Set<String> threadsSeen() {
return Set.copyOf(this.threadsSeen);
}
public void reset() {
this.threadsSeen.clear();
}
}

View File

@@ -0,0 +1,57 @@
package com.ankurm.async;
import java.util.concurrent.CompletableFuture;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* The self-invocation trap, and the two ways out of it.
*
* <p>{@code @Async} is implemented by a proxy that wraps this bean. Every call that arrives from
* outside goes through the proxy and gets handed to an executor. Every call that starts
* <em>inside</em> this class is a plain {@code this.method()} invocation on the target object, so
* the proxy is never consulted and the method runs on the caller's thread. Nothing is logged.
*
* <p>See docs/02-the-self-invocation-trap.md.
*/
@Service
public class SelfInvocationService {
private final ObjectProvider<SelfInvocationService> self;
/**
* An {@code ObjectProvider} rather than a constructor-injected {@code SelfInvocationService}:
* the latter is a self-reference that Spring resolves, but it hands you the proxy only
* because of a special case, and it makes the cycle explicit in a way reviewers argue about.
* {@code ObjectProvider} is lazy, so there is no cycle to resolve at construction time.
*/
public SelfInvocationService(ObjectProvider<SelfInvocationService> self) {
this.self = self;
}
/**
* The annotated method. Reached through the proxy, it runs on an executor thread.
*/
@Async
public CompletableFuture<String> annotated() {
return CompletableFuture.completedFuture(Threads.describe());
}
/**
* The trap. {@code annotated()} here is {@code this.annotated()}, which never touches the
* proxy, so the work runs on whatever thread called {@code viaSelfInvocation()} and the
* returned future is already complete.
*/
public CompletableFuture<String> viaSelfInvocation() {
return annotated();
}
/**
* The fix that keeps the code in one class: go out through the proxy deliberately.
*/
public CompletableFuture<String> viaSelfReference() {
return self.getObject().annotated();
}
}

View File

@@ -0,0 +1,26 @@
package com.ankurm.async;
/**
* Thread description used by every transcript in docs/output/.
*
* <p>The whole project is built on one idea: instead of asserting which executor Spring picked,
* print the name of the thread the method actually ran on. Thread names are the only honest
* evidence, because {@code @Async} failing silently and {@code @Async} working look identical
* from the outside.
*/
public final class Threads {
private Threads() {
}
/** e.g. {@code task-1 (virtual=false)} or {@code VirtualThread[#42]/runnable (virtual=true)}. */
public static String describe() {
Thread t = Thread.currentThread();
return t.getName() + " (virtual=" + t.isVirtual() + ")";
}
/** Just the name, for assertions that care about the executor's thread-name prefix. */
public static String name() {
return Thread.currentThread().getName();
}
}

View File

@@ -0,0 +1,49 @@
package com.ankurm.async;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* What CGLIB can and cannot override.
*
* <p>Spring Boot proxies with CGLIB by default ({@code spring.aop.proxy-target-class} is true),
* which means the proxy is a generated <em>subclass</em>. A subclass can override a public or
* protected method. It cannot override a {@code final} one, and it cannot see a private one.
* Both of those {@code @Async} annotations are therefore inert, and neither produces a warning.
*
* <p>See docs/03-what-the-proxy-cannot-see.md.
*/
@Service
public class VisibilityService {
@Async
public CompletableFuture<String> publicMethod() {
return CompletableFuture.completedFuture(Threads.describe());
}
/** Cannot be overridden by the CGLIB subclass, so the advice never runs. */
@Async
public final CompletableFuture<String> finalMethod() {
return CompletableFuture.completedFuture(Threads.describe());
}
/** Protected methods <em>can</em> be overridden by a subclass, so this one is advised. */
@Async
protected CompletableFuture<String> protectedMethod() {
return CompletableFuture.completedFuture(Threads.describe());
}
/** Reachable only from inside the class, so it is a self-invocation as well as private. */
@Async
@SuppressWarnings("unused")
private CompletableFuture<String> privateMethod() {
return CompletableFuture.completedFuture(Threads.describe());
}
/** Calls the protected one from outside its own {@code this}, via the caller's proxy. */
public CompletableFuture<String> callProtectedInternally() {
return protectedMethod();
}
}

View File

@@ -0,0 +1,6 @@
spring:
application:
name: async
# Nothing is set here on purpose. Every number in docs/output/executor-report.txt is a Spring
# Boot default, printed from the live bean rather than copied out of the reference guide.
# The profiles that change behaviour are in src/test/resources/ and in scripts/.

View File

@@ -0,0 +1,43 @@
package com.ankurm.async;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/** The same pool with a bounded queue. One property changes; the concurrency triples. */
@SpringBootTest(properties = {
"spring.task.execution.pool.core-size=4",
"spring.task.execution.pool.max-size=12",
"spring.task.execution.pool.queue-capacity=4" })
class BoundedQueueTest {
@Autowired
private SaturationService service;
@Test
void aBoundedQueueIsWhatLetsThePoolGrow() throws Exception {
CountDownLatch release = new CountDownLatch(1);
CountDownLatch arrived = new CountDownLatch(12);
service.reset();
for (int i = 0; i < 16; i++) {
service.occupyAThread(arrived, release);
}
assertThat(arrived.await(10, TimeUnit.SECONDS)).isTrue();
Thread.sleep(500);
int threads = service.threadsSeen().size();
release.countDown();
assertThat(threads).isEqualTo(12);
Capture.write("pool-bounded-queue.txt",
"core-size=4, max-size=12, queue-capacity=4, 16 blocking tasks",
"distinct threads that ran a task : " + threads + "\n"
+ "thread names : " + service.threadsSeen() + "\n"
+ "4 core threads, 4 tasks queued, 8 more threads created up to max-size.\n");
}
}

View File

@@ -0,0 +1,23 @@
package com.ankurm.async;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/** Writes a transcript under docs/output/ so every number in the article has a file behind it. */
final class Capture {
private Capture() {
}
static void write(String fileName, String heading, String body) {
Path dir = Path.of(System.getProperty("user.dir"), "docs", "output");
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), "== " + heading + " ==\n\n" + body + "\n");
}
catch (IOException ex) {
throw new IllegalStateException("could not write " + fileName, ex);
}
}
}

View File

@@ -0,0 +1,27 @@
package com.ankurm.async;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/** The default: a ThreadLocal set by the caller is not visible to the @Async thread. */
@SpringBootTest
class ContextNotPropagatedTest {
@Autowired
private ContextService service;
@Test
void theAsyncThreadSeesNothing() throws Exception {
RequestId.set("req-4711");
try {
assertThat(service.readRequestId().get()).isEqualTo("null");
}
finally {
RequestId.clear();
}
}
}

View File

@@ -0,0 +1,37 @@
package com.ankurm.async;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code spring.task.execution.propagate-context=true} &mdash; new in Spring Boot 4.1.0 &mdash;
* decorates the auto-configured executor so registered ThreadLocals survive the hop.
*/
@SpringBootTest(properties = "spring.task.execution.propagate-context=true")
class ContextPropagatedTest {
@Autowired
private ContextService service;
@Test
void onePropertyCarriesTheThreadLocalAcross() throws Exception {
RequestId.register();
RequestId.set("req-4711");
try {
String seen = service.readRequestId().get();
assertThat(seen).isEqualTo("req-4711");
Capture.write("context-propagation.txt",
"spring.task.execution.propagate-context (new in Boot 4.1.0)",
"caller thread : " + Threads.describe() + "\n"
+ "RequestId set : req-4711\n"
+ "@Async thread saw: " + seen + "\n");
}
finally {
RequestId.clear();
}
}
}

View File

@@ -0,0 +1,20 @@
package com.ankurm.async;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/** Prints the stock context so the article's "defaults" table has a file behind it. */
@SpringBootTest
class ExecutorReportTest {
@Autowired
private ExecutorDiagnostics diagnostics;
@Test
void printStockContext() {
Capture.write("executor-report.txt",
"Stock Spring Boot 4.1.1 context, nothing configured", this.diagnostics.report());
}
}

View File

@@ -0,0 +1,33 @@
package com.ankurm.async;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The same two custom executors, plus {@code spring.task.execution.mode=force}. Boot creates
* {@code applicationTaskExecutor} anyway and marks it as the one @Async should use.
*/
@SpringBootTest(properties = "spring.task.execution.mode=force")
@ActiveProfiles("twoexecutors")
class ForceModeTest {
@Autowired
private ReturnTypeService service;
@Autowired
private ExecutorDiagnostics diagnostics;
@Test
void forceRestoresTheApplicationTaskExecutor() throws Exception {
String ranOn = service.completableFuture().get();
assertThat(ranOn).startsWith("task-");
Capture.write("executor-force-mode.txt",
"Two custom Executor beans plus spring.task.execution.mode=force",
this.diagnostics.report() + "\n@Async ran on : " + ranOn + "\n");
}
}

View File

@@ -0,0 +1,29 @@
package com.ankurm.async;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/** One Executor bean of your own: Boot backs off and @Async uses yours. */
@SpringBootTest
@ActiveProfiles("ownexecutor")
class OwnExecutorTest {
@Autowired
private ReturnTypeService service;
@Autowired
private ExecutorDiagnostics diagnostics;
@Test
void yourExecutorWins() throws Exception {
String ranOn = service.completableFuture().get();
assertThat(ranOn).startsWith("mine-");
Capture.write("executor-one-custom.txt", "A single custom Executor bean",
this.diagnostics.report() + "\n@Async ran on : " + ranOn + "\n");
}
}

View File

@@ -0,0 +1,81 @@
package com.ankurm.async;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Return types, and where the exceptions go.
*
* <p>The interesting assertion is the first one. A very common claim is that an {@code @Async}
* method with a plain return type quietly returns {@code null}; on Spring Framework 7.0.9 it
* throws, at the call site, before the body has run.
*/
@SpringBootTest
class ReturnTypeTest {
@Autowired
private ReturnTypeService service;
@Test
void aPlainReturnTypeThrowsAtTheCallSite() {
assertThatThrownBy(() -> service.plainString())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Invalid return type for async method (only Future and void supported): "
+ "class java.lang.String");
}
@Test
void aFutureCarriesTheException() {
CompletableFuture<String> future = service.futureThatThrows();
assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(IllegalStateException.class);
}
@Test
void aVoidMethodThrowsNothingTheCallerCanSee() {
assertThatCode(() -> service.voidThatThrows()).doesNotThrowAnyException();
}
@Test
void capture() throws Exception {
String plain;
try {
plain = String.valueOf(service.plainString());
}
catch (IllegalArgumentException ex) {
plain = ex.getClass().getName() + ": " + ex.getMessage();
}
String futureOutcome;
try {
service.futureThatThrows().get(5, TimeUnit.SECONDS);
futureOutcome = "completed normally";
}
catch (ExecutionException ex) {
futureOutcome = ex.getCause().getClass().getSimpleName() + ": " + ex.getCause().getMessage();
}
service.voidThatThrows();
String body = """
completableFuture().get() : %s
plainString() : %s
futureThatThrows().get() : %s
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.
""".formatted(service.completableFuture().get(), plain, futureOutcome);
Capture.write("return-types.txt", "What each @Async return type hands back", body);
assertThat(plain).contains("IllegalArgumentException");
}
}

View File

@@ -0,0 +1,54 @@
package com.ankurm.async;
import java.util.concurrent.ExecutionException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves the self-invocation trap by thread name rather than by timing, because a task that runs
* on the caller's thread and a task that runs quickly on a pool thread are indistinguishable by
* clock.
*/
@SpringBootTest
class SelfInvocationTest {
@Autowired
private SelfInvocationService service;
@Test
void throughTheProxyItIsAsynchronous() throws Exception {
assertThat(service.annotated().get()).startsWith("task-");
}
@Test
void selfInvocationSilentlyRunsOnTheCallerThread() throws ExecutionException, InterruptedException {
String callerThread = Threads.name();
String ranOn = service.viaSelfInvocation().get();
assertThat(ranOn).startsWith(callerThread);
assertThat(ranOn).doesNotStartWith("task-");
}
@Test
void goingBackOutThroughTheProxyRestoresIt() throws Exception {
assertThat(service.viaSelfReference().get()).startsWith("task-");
}
@Test
void capture() throws Exception {
String body = """
caller thread : %s
service.annotated() : %s
service.viaSelfInvocation() : %s
service.viaSelfReference() : %s
""".formatted(Threads.describe(), service.annotated().get(),
service.viaSelfInvocation().get(), service.viaSelfReference().get());
Capture.write("self-invocation.txt",
"Which thread each call actually ran on (Boot 4.1.1, JDK 25)", body);
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.async;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Two Executor beans and no bean named {@code taskExecutor}. Boot's applicationTaskExecutor is
* gone, neither of yours can be chosen, and @Async falls back to an unpooled
* SimpleAsyncTaskExecutor without saying so at any log level.
*/
@SpringBootTest
@ActiveProfiles("twoexecutors")
class TwoExecutorsTest {
@Autowired
private ReturnTypeService service;
@Autowired
private ExecutorDiagnostics diagnostics;
@Test
void neitherOfYoursIsUsed() throws Exception {
String ranOn = service.completableFuture().get();
assertThat(ranOn).doesNotStartWith("reports-");
assertThat(ranOn).doesNotStartWith("emails-");
assertThat(ranOn).doesNotStartWith("task-");
Capture.write("executor-two-custom.txt", "Two custom Executor beans, no 'taskExecutor'",
this.diagnostics.report() + "\n@Async ran on : " + ranOn + "\n");
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.async;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* core-size 4, max-size 12, and the Boot default unbounded queue. Sixteen blocking tasks are
* submitted; four threads run them, because an unbounded queue never refuses a task and
* {@code ThreadPoolExecutor} only creates a non-core thread when the queue refuses one.
*/
@SpringBootTest(properties = {
"spring.task.execution.pool.core-size=4",
"spring.task.execution.pool.max-size=12" })
class UnboundedQueueTest {
@Autowired
private SaturationService service;
@Test
void maxSizeIsIgnoredWhenTheQueueIsUnbounded() throws Exception {
CountDownLatch release = new CountDownLatch(1);
CountDownLatch arrived = new CountDownLatch(4);
service.reset();
for (int i = 0; i < 16; i++) {
service.occupyAThread(arrived, release);
}
assertThat(arrived.await(10, TimeUnit.SECONDS)).isTrue();
Thread.sleep(500);
int threads = service.threadsSeen().size();
release.countDown();
assertThat(threads).isEqualTo(4);
Capture.write("pool-unbounded-queue.txt",
"core-size=4, max-size=12, queue-capacity=<unbounded default>, 16 blocking tasks",
"distinct threads that ran a task : " + threads + "\n"
+ "thread names : " + service.threadsSeen() + "\n"
+ "max-size had no effect: the queue never refused a task.\n");
}
}

View File

@@ -0,0 +1,48 @@
package com.ankurm.async;
import java.util.concurrent.Executor;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import static org.assertj.core.api.Assertions.assertThat;
/**
* With virtual threads on, {@code applicationTaskExecutor} is a {@code SimpleAsyncTaskExecutor}
* over virtual threads. Note what that costs: the pool properties are still accepted and now
* mean nothing, and a {@code SimpleAsyncTaskExecutor} has no queue and, by default, no
* concurrency limit at all.
*/
@SpringBootTest(properties = {
"spring.threads.virtual.enabled=true",
"spring.task.execution.pool.core-size=4",
"spring.task.execution.pool.max-size=12" })
class VirtualThreadsTest {
@Autowired
private ReturnTypeService service;
@Autowired
@Qualifier("applicationTaskExecutor")
private Executor applicationTaskExecutor;
@Autowired
private ExecutorDiagnostics diagnostics;
@Test
void asyncRunsOnAVirtualThread() throws Exception {
String ranOn = service.completableFuture().get();
assertThat(ranOn).contains("virtual=true");
assertThat(this.applicationTaskExecutor).isInstanceOf(SimpleAsyncTaskExecutor.class);
Capture.write("virtual-threads.txt",
"spring.threads.virtual.enabled=true, with pool properties still set",
"applicationTaskExecutor : " + this.applicationTaskExecutor.getClass().getName() + "\n"
+ "@Async ran on : " + ranOn + "\n\n"
+ this.diagnostics.report());
}
}

View File

@@ -0,0 +1,53 @@
package com.ankurm.async;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/** What the CGLIB subclass can and cannot override, measured. */
@SpringBootTest
class VisibilityTest {
@Autowired
private VisibilityService service;
@Test
void theBeanIsACglibProxy() {
assertThat(AopUtils.isCglibProxy(this.service)).isTrue();
}
@Test
void publicMethodIsAdvised() throws Exception {
assertThat(service.publicMethod().get()).startsWith("task-");
}
@Test
void finalMethodIsSilentlySynchronous() throws Exception {
assertThat(service.finalMethod().get()).doesNotStartWith("task-");
}
@Test
void protectedMethodIsAdvisedWhenCalledThroughTheProxy() throws Exception {
// Same package as the service, so the test can reach a protected method on the proxy.
assertThat(service.protectedMethod().get()).startsWith("task-");
}
@Test
void capture() throws Exception {
String body = """
proxy class : %s
isCglibProxy : %s
publicMethod() : %s
finalMethod() : %s
protectedMethod() via the proxy : %s
callProtectedInternally() : %s
""".formatted(service.getClass().getName(), AopUtils.isCglibProxy(this.service),
service.publicMethod().get(), service.finalMethod().get(),
service.protectedMethod().get(), service.callProtectedInternally().get());
Capture.write("visibility.txt", "@Async against final, protected and private methods", body);
}
}