Async Tasks, Thread Pools and Scheduling
This article solves one problem: some work should not make the user wait. Writing the order to the database is the main flow — the user must wait for it. Sending the SMS, the email, recalculating points — none of that affects "the order succeeded", so move it to the background. Spring gives you two annotations: @Async (someone else runs it, I do not wait) and @Scheduled (run it when the clock says so).
But neither annotation creates a thread. They only hand the task over. What decides whether you survive is the thing that catches it — the thread pool. That is why half of this article is about pools: it is not a detour, it is the point.
Six terms, one line each (used throughout):
- Thread: one line of execution. Your request normally runs on a Tomcat worker; going async means starting another line to do the work
- Thread pool: a set of threads built in advance and reused, like a rostered crew; tasks are jobs, whoever is free picks them up
- Core threads (
corePoolSize): the permanent members of the pool — they stay even when idle - Work queue (
queueCapacity): when every permanent member is busy, new tasks wait here for a free hand - Max threads (
maxPoolSize): only once the queue is full does the pool hire temporary extra hands, up to this number - Rejection policy (
RejectedExecutionHandler): what to do when both crew and queue are full — throw, make the submitter run it, drop it, or drop the oldest
pin the whole article on a food-delivery dispatch station. Core threads are the station's staffed riders (a fixed few, always on call). The work queue is the row of pickup tickets hanging on the wall during an order surge (jobs wait there until a rider returns). Max threads are the crowd-sourced riders you call in at peak — they only appear once the wall is full, and go home when it quiets down. The rejection policy is the station's four possible rules when it truly cannot take more: tell the customer "order refused" (throw), have the shop clerk ride it out themselves (the caller runs it), quietly tear the ticket up (discard), or cancel the oldest ticket and keep the newest (discard-oldest). And scheduled tasks? They are orders that open automatically at a fixed time every day: if the station has only one rider on shift and he takes a slow delivery, every later timed order waits with him — that is the entire truth behind Section 9's trap.

That comparison is the first life-or-death line of this article: if @Async gets no configured pool, you are in the left column (a fresh new Thread() per task, no bound, no reuse) — Section 3 shows the source evidence. The animation below is how the right column actually behaves internally, and note the part almost everyone remembers backwards — when core threads are busy, tasks are enqueued first, not answered with new threads:

After this article you should be able to answer three questions:
- A
@Asyncmethod throws an exception — why does neither your log nor the HTTP response show it, and what must you add to catch it? - Calling your own
@Asyncmethod viathis.sendSms()inside the same class — why does it run synchronously? - You deploy three replicas — why did the daily reconciliation job run three times, and how do you make it run once?
Production reported that "the order endpoint sometimes takes 8 seconds to return". Investigation found that right after writing to the database, the main flow also called two external services synchronously — sending an SMS and sending an email. Each takes 1–3 seconds, and neither has anything to do with the outcome: the user only needs to know the order succeeded; an SMS two seconds late is fine.
| Approach | Main-flow time | UX | Impact of SMS failure |
|---|---|---|---|
| All synchronous | 1.5s + 2s + 3s ≈ 6.5s | Users want to close the page | An SMS glitch fails the whole order |
| SMS / email async | ≈ 1.5s | Instant | Failure is just logged; the order stands |
This is exactly what async tasks solve: move "unrelated to the main result and slow" work onto a background thread and return immediately. Spring offers two annotations: @Async (run asynchronously) and @Scheduled (run on a schedule). They look trivial and hide many traps; this article covers both from usage through thread pools, context propagation and duplicate execution in a cluster.

First add @EnableAsync to a config class, then mark the method @Async:
@Configuration@EnableAsync // turn on async supportpublic class AsyncConfig { }@Servicepublic class OrderService { private final SmsClient smsClient; public OrderService(SmsClient smsClient) { this.smsClient = smsClient; } public Long createOrder(OrderCmd cmd) { Long orderId = doCreate(cmd); // main flow: the DB write must be synchronous sendNotifyAsync(orderId, cmd.getPhone()); // notification: async, does not block return orderId; // return right away } @Async // this runs on another thread public void sendNotifyAsync(Long orderId, String phone) { smsClient.send(phone, "order placed: " + orderId); }}@EnableAsyncis the master switch; without it@Asyncdoes nothing.- Spring generates a proxy for the annotated method and hands the body to a thread pool.
The return type decides what the caller can get — the easiest part to misuse:
| Return type | Caller can... | Best for |
|---|---|---|
void | Ignore the result; exceptions never come back | Fire-and-forget (SMS, logging) |
Future<T> | get() and block | Legacy, not recommended |
CompletableFuture<T> | Compose, call back, time out | First choice when orchestrating results |

Two annotations switch async on, but the part that actually ships is a block of configuration. Do not copy somebody else's yml — tick the options you need, generate the file, and read why each line exists and what breaks without it:
server:
port: 8080
servlet:
encoding: { charset: UTF-8, enabled: true, force: true }
compression: { enabled: true, min-response-size: 2048 }
spring:
application:
name: demo-service
task:
execution:
pool: { core-size: 8, max-size: 32, queue-capacity: 200 }
threads:
virtual:
enabled: false # JDK 21 打开后 @Async 走虚拟线程
@Async defaults to SimpleAsyncTaskExecutor, whose name is lovely and whose source is alarming: it calls new Thread() for every task, never reuses, and has no upper bound.
// Simplified: the core behavior of SimpleAsyncTaskExecutorprotected void doExecute(Runnable task) { Thread thread = createThread(task, this.threadNamePrefix); // new thread every time thread.start();}The consequences:
- No concurrency limit: ten thousand requests spawn ten thousand threads and blow up CPU and memory, possibly OOM.
- No reuse: every task is born and dies, wasting creation/destruction cost.
- No observability: no queue, no rejection policy, no metrics.
So production must define a custom pool. Use ThreadPoolTaskExecutor:
@Configuration@EnableAsyncpublic class AsyncConfig implements AsyncConfigurer { @Bean("notifyExecutor") public ThreadPoolTaskExecutor notifyExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); // core threads: always alive executor.setMaxPoolSize(16); // max threads: temporary peak capacity executor.setQueueCapacity(200); // queue: buffer while cores are busy executor.setKeepAliveSeconds(60); // idle above-core threads are reclaimed executor.setThreadNamePrefix("notify-"); // business prefix for diagnosis executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); // graceful, see Section 7 executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } /** Executor used when @Async names no pool */ @Override public Executor getAsyncExecutor() { return notifyExecutor(); }}@Async("notifyExecutor")picks a specific pool; give different businesses their own pools so they don't interfere.- The thread-name prefix is a lifesaver: seeing
notify-3in logs immediately tells you it's an async notification thread.
The pool's working order is: core threads → queue → max threads → rejection policy. Many people wrongly assume "more tasks means new threads up to max":
| Stage | Trigger | Behavior |
|---|---|---|
| 1 | threads < core | Create a core thread |
| 2 | threads = core and queue not full | Enqueue the task |
| 3 | queue full and threads < max | Create a temporary thread |
| 4 | queue full and threads = max | Apply the rejection policy |
Those four rows are literally the script of the animation below; watch frames ②→③ in particular: what separates them is "the queue is full", not "the core threads are busy" — once that lands, every later discussion about capacity stops being confusing.

an unbounded queue makes maxPoolSize permanently useless. Set queue capacity to Integer.MAX_VALUE (the default for LinkedBlockingQueue) and tasks pile up forever, never reaching the "queue full → grow to max" step; max threads is a phantom and memory eventually blows up. Always give the queue a finite capacity.
The four rejection policies:
| Policy | Behavior | Best for |
|---|---|---|
| AbortPolicy (default) | Throws RejectedExecutionException | Fail fast and let the caller see it |
| CallerRunsPolicy | The submitting thread runs the task itself | Degrade to synchronous; don't lose tasks |
| DiscardPolicy | Silently drops | Tasks you can afford to lose |
| DiscardOldestPolicy | Drops the oldest queued task, then retries | Only the newest data matters |
The table above writes those four stages as four lines of prose, but "which one happens first" is exactly the thing prose cannot teach. Every cell below is clickable, and clicking it tells you what that stage does:
To feel how those three numbers fight each other, drag the queue capacity. The same @Async method has a completely different fate between 0 and 20000:
- A brief spike is buffered without spawning extra threads
- Once the queue fills, max finally gets used — that is its only real value
- Queueing delay has a bound you can predict and alert on
- The capacity most production services actually want
Like @Transactional, @Async depends on a proxy; if the call bypasses the proxy, the annotation vanishes:
| Failure | Cause | Fix |
|---|---|---|
| Self-invocation | this.method() skips the proxy | Move it to another bean, or inject self |
private / final method | Cannot be proxied/overridden | Make it public, non-final |
| Primitive return type | Async can only wrap void/Future | Use CompletableFuture<T> or void |
void method swallows exceptions | No one receives the exception | Configure an AsyncUncaughtExceptionHandler |
| Not a Spring bean | A newed object has no proxy | Let the container manage it |
Missing @EnableAsync | Master switch off | Add the annotation |
Exceptions from void methods disappear silently by default; catch them explicitly:
@Configuration@EnableAsyncpublic class AsyncConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> log.error("async task failed method={} params={}", method.getName(), params, ex); }}- With this, exceptions from
voidasync methods at least reach the logs instead of evaporating.
That table has six rows and nobody memorises six rows. An annotation silently not working is something you only remember after it has bitten you once. So play a round: click a symptom on the left, then the cause you believe fits — miss one and it explains itself right there.
Recall Section 3's core fact: async tasks run on another thread. Yet many Spring / SLF4J / transaction contexts live in ThreadLocal, which does not travel automatically:
- Lost MDC traceId: the main thread's logs carry a traceId while the async thread's don't, breaking the trace. Fix with a
TaskDecoratorthat copies the MDC when submitting:
public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable task) { Map<String, String> context = MDC.getCopyOfContextMap(); // capture the submitting thread's MDC return () -> { if (context != null) MDC.setContextMap(context); // restore on the worker thread try { task.run(); } finally { MDC.clear(); // avoid polluting reused threads } }; }}// config: executor.setTaskDecorator(new MdcTaskDecorator());- Lost SecurityContext: the async method cannot read the current user. Wrap the pool in a
DelegatingSecurityContextExecutor, which carries the security context automatically. - Transaction context cannot cross threads (the key point): a transaction's connection is bound to the
ThreadLocalof the thread that began it. An async method runs on another thread and cannot get the caller's transaction; it either has none or opens its own independent one via its own@Transactional.
Key point: remember three boundaries — carry MDC with a TaskDecorator, carry SecurityContext with DelegatingSecurityContextExecutor, and never try to carry a transaction. Especially the last: don't expect "if the main flow rolls back, the async task rolls back too" — they are two independent transactions by design.
Those are the conclusions, but "why it breaks" is not something conclusions teach. The lab below really runs it in your browser: the same handful of lines, read once on exec-3 and once on task-1.
Now spread the same story out as a debug session. The left pane is the code you are stepping through; the right pane refreshes the variables and the call stack. Press Step five times and watch the exact moment the thread name changes from exec-3 to task-1:
UserContext.set(userId); // we are on http-nio-8080-exec-3notifier.sendAsync(order); // this hop goes through the proxyexecutor.submit(wrap(order)); // handed to the pool; caller returns at once// ---- camera cuts to task-1 ----Long uid = UserContext.get(); // same ThreadLocal, different maplog.info("send to {}", uid); // uid = null| thread | http-nio-8080-exec-3 |
| UserContext | 42 |
| singletonObjects | 184 beans |
OrderController.pay- request threadThose "through the proxy" and "handed to the pool" moves from steps ② and ③ deserve their own look — the lab below prints every move AsyncExecutionInterceptor makes:
The correct way to move context, drawn as one picture — all four moves matter, and the fourth (giving it back) is the one people skip:

When the app receives a shutdown signal, killing the process outright loses every async task still in the queue. Two settings together form the full chain:
server: shutdown: graceful # 1. Web layer: stop accepting new requests, finish in-flight onesspring: lifecycle: timeout-per-shutdown-phase: 30s # 2. time budget for in-flight requests// 3. Pool layer: drain the queue before closingexecutor.setWaitForTasksToCompleteOnShutdown(true); // process the queue on shutdownexecutor.setAwaitTerminationSeconds(30); // wait up to 30s, then force- The first setting covers HTTP requests, the third covers async tasks — both are required.
- Keep the wait shorter than your orchestrator's
terminationGracePeriodSeconds(K8s defaults to 30s), or the container is killed and the wait is pointless.
Enable with @EnableScheduling, then annotate methods with @Scheduled. It has three triggers with very different semantics:
| Mode | Semantics | When one run exceeds the interval |
|---|---|---|
fixedRate | Fixed rate from the start of the previous run | No overlap, but it catches up immediately; tasks pile up |
fixedDelay | Fixed time after the previous run ends | Never piles up; drifts naturally |
cron | Fires at specific points in time | Same cron semantics; watch the multi-instance issue |
Timeline, with a 3-second task and a 2-second interval:
fixedRate: start0 ——3s—— start3 (0+2 already due, run now) ——6s——fixedDelay: start0 ——3s—— wait 2s start5 ——8s—— wait 2s start10fixedRate's rate is measured from the start, so long tasks cause a backlog (no overlap, but back-to-back runs).fixedDelaymeasures from the end, so it never backs up — usually the intuitive choice.
A cron expression has six fields: second minute hour day-of-month month day-of-week (Java's cron adds the "second" field that Linux's lacks):
| Expression | Meaning |
|---|---|
0 0 3 ? | Every day at 03:00 |
0 /5 ? | Every 5 minutes |
0 0 9-18 MON-FRI | Hourly on the hour, 9–18, weekdays |
0 30 2 1 * ? | Monthly on the 1st at 02:30 |
0 0/30 * ? | Every 30 minutes |
initialDelay postpones the first run, handy for "wait until the app is fully ready":
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)public void cleanExpiredTokens() { // first run 60s after startup, then every 5 minutes (measured from the end)}@Scheduled uses a single-threaded TaskScheduler by default. That means all scheduled tasks share one thread, and whoever runs longest blocks the rest.
a reporting task that runs every 5 minutes but takes 4 minutes makes every other task "late", because they all queue on the same thread.
Fix it with a custom multi-threaded TaskScheduler:
@Configuration@EnableSchedulingpublic class ScheduleConfig { @Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(4); // 4 threads, tasks don't block each other scheduler.setThreadNamePrefix("schedule-"); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(30); return scheduler; }}- Size the pool at least as large as the number of tasks you expect to run in parallel, or you're back to blocking.
The animation below plays this trap out frame by frame — pay attention to step ③: task B has reached its trigger time and nobody runs it. It did not fail; it is waiting for the one and only thread.

Dragging this number around makes what "1" costs obvious:
- The default is exactly 1 — every @Scheduled shares scheduling-1
- One slow HTTP call inside a job pushes back every later job
- It looks like "the job sometimes does not run", but it ran late, and the thread name in the log is always the same
- Your cron expression is fine; the problem is there is only one thread
Everything above assumes one instance. Deploy several replicas and every replica fires the same scheduled task: a 5-minute report runs three times, duplicate bills go out. Three solutions:
| Solution | Mechanism | Pros/cons |
|---|---|---|
| DB optimistic lock | Update with a version; only one instance succeeds | Simple, but DB pressure and coarse grain |
| Redis distributed lock | SET key value NX EX ttl to claim the right to run | Light and fast; must handle expiry/renewal |
| Scheduler center (XXL-JOB) | A central service dispatches to a chosen executor | Full-featured and observable, extra ops cost |
A lightweight Redis SETNX de-dup implementation:
@Componentpublic class ReportJob { private final StringRedisTemplate redis; public ReportJob(StringRedisTemplate redis) { this.redis = redis; } @Scheduled(cron = "0 0/5 * * * ?") public void generateReport() { // SETNX with an expiry: only one instance wins the lock String lockKey = "lock:report:" + ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); Boolean got = redis.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(4)); // TTL slightly less than the interval if (!Boolean.TRUE.equals(got)) { log.debug("report task already running elsewhere, skipping"); return; // didn't win the lock, do nothing } try { doGenerateReport(); // do the real work } finally { // The TTL releases it automatically; don't delete here, or you might remove a newer lock } }}- The lock key includes the time granularity (to the minute) so a given point fires once, while the next point gets a fresh key.
- The TTL must be slightly less than the interval, or a leftover lock blocks the next round.
- If a run can exceed the TTL, you must add renewal (a watchdog), or early expiry causes concurrent runs.
Sections 1-10 were reading material; this section is buttons. First the analogy that should stay with you throughout:
why do exceptions from @Async always seem to vanish? Picture a parcel you mailed that got lost in transit — nobody phones you to say so. The async worker thread is the recipient: after the main flow hands the parcel over it walks away immediately, leaving no phone number (the return type is void) and no delivery receipt (no Future). If the parcel burns halfway (an exception is thrown), apart from the courier's internal record (your log), nobody tells you. So Section 5's "you must configure an AsyncUncaughtExceptionHandler" is not a style preference — it is the only way to install that missing phone call.
Section 4's table says core → queue → max → reject, but "enqueue before growing" is counter-intuitive enough that almost nobody gets it right on the first try. Press it out with a lab:
Once you have seen that curve, Section 3's conclusion gets concrete: the default per-task executor has no stage ② at all — it never queues, it just opens a thread per arrival, so every parameter described in Section 4 is decoration on it. The 200/200 position of the Section 12 sandbox — "threads pile up, downstream collapses, shutdown cannot drain" — is exactly that default behaviour scaled up.
These two dominate async incident reports, and both are silent:
A very common use of @Async is pairing it with event listeners: the main flow publishes, side effects move to listeners. Mind the default semantics of publishing — many beginners assume "publish and forget", yet by default listeners run one after another on the same thread:
And as soon as an async method writes to the database, you hit Section 6's boundary head-on: a transaction cannot be carried across. Act it out with propagation types:
Finally, back to the resource itself. Async protects the order endpoint's latency by leaning on bounded resources plus queuing; the same model has a more famous name on database connections — the connection pool. Watch it borrow and return, and "the queue" stops being abstract:
That is enough clicking on buttons — time to type. The console below talks to the same in-browser container, and every answer is computed by the Java kernel rather than read from a script. Start with beans, then run the lab commands one by one:
run lab threadlocal lost and lab threadlocal fix back to back — the first timeline lands on null at step ③, the second reports 'replayed'. Same @Async, the only difference is whether a TaskDecorator is hung on the executor.
corePoolSize / maxPoolSize / queueCapacity bite each other, and a table gives you no feel for it. The sandbox exposes two switches (the third value is fixed at a queue of 500); every position shows throughput, backlog, rejections and shutdown loss together:
activeCount: 16 queuedTasks: 42 completedTaskCount: 1.2k/minP99 order latency: 1.6s# SMS cost is isolated on notify-* threads, invisible to the main flow
the numbers are illustrative, but three conclusions are real — more threads is not more throughput (past what downstream can take, throughput falls and you knock over dependencies); queue length decides how much latency you tolerate, not whether tasks survive; and picking a rejection policy is choosing who pays when things overflow: the caller, the business, or silence.
Then make the division of labour explicit: annotations only hand work over; the pool decides how it finishes — the left column below is the code you write, the right column is where every parameter, trap and tuning knob actually lands:

the default executor is hailing a cab off the street — find a fresh car per passenger and scrap it after the ride, so a surge means scrambling for cars everywhere (unbounded threads). A custom pool is a rostered taxi fleet — a few cars permanently on duty (core), a waiting lane (queue), extra cars called in at peak (max), and refuse-or-reassign rules when even that is full (rejection policy). Every switch you flip in Section 12 only edits this fleet's roster.
A warm-up on the order of stages that people remember backwards, straight from Section 4:
Now a combined question threading Sections 5, 6, 9 and 10:
Copy every snippet below straight into a search box — do not paraphrase or shorten it. Beginners stall in three places: tasks rejected, exceptions nowhere to be found, schedules drifting.
| Error text (fragment) | What really happened | 30-second fix | Read more in | |
|---|---|---|---|---|
org.springframework.task.support.TaskRejectedException: Executor java.util.concurrent.ThreadPoolExecutor@...[Running, pool size = 16, active threads = 16, queued tasks = 500, completed tasks = 8123] did not accept task: ... | Threads reached max and the queue is full, so the default AbortPolicy refused the task. Note it is Spring's wrapper around RejectedExecutionException | Read the three numbers inside the brackets: queued tasks pinned means the queue is too small, active threads pinned means concurrency is too small; then decide between growing the pool and switching to CallerRunsPolicy | Section 4 · Section 12 sandbox position `8/16\ | AbortPolicy` |
java.util.concurrent.RejectedExecutionException: Task ... rejected from java.util.concurrent.ThreadPoolExecutor[...] | Same root cause, but you called executor.submit() by hand or used a raw JDK pool outside the container | Let ThreadPoolTaskExecutor manage it and give the pool a threadNamePrefix, otherwise logs cannot tell which pool refused | Section 3 | |
An @Async method threw, yet the console shows no error at all and the endpoint still returns 200 | The method returns void, so the exception has no channel back; by default it is swallowed or reduced to one error line | Implement AsyncConfigurer#getAsyncUncaughtExceptionHandler (code in Section 5), or return CompletableFuture<T> and handle it with exceptionally on the caller side | Section 5 · Section 11 exc lab | |
You added @Async, but the SMS log line still prints on http-nio-8080-exec-1 (no thread switch) | One of three cases: ① same-class this.method() self-invocation bypassing the proxy; ② the method is private/final; ③ @EnableAsync is missing | Trust the thread name first: async must land on notify-*; then verify the injected bean with AopUtils.isAopProxy(bean); finally check the master switch | Section 5's failure table | |
| Scheduled jobs are "occasionally late" and the log shows the previous run had not finished | The default scheduler has one thread, so a long job pushes every later job back (they queue, they are not lost) | Register a multi-threaded ThreadPoolTaskScheduler (code in Section 9) with poolSize ≥ the number of jobs you expect to overlap | Section 9 · Section 11 sched lab | |
The same @Scheduled(cron=...) job ran on every replica in a multi-instance deployment and bills went out twice | @Scheduled is an in-process scheduler; replicas coordinate with each other not at all | Short term: Redis SETNX with a time-granular key (code in Section 10); properly: ShedLock (@SchedulerLock) or a scheduler centre such as XXL-JOB | Section 10 | |
ShedLock reports Cannot acquire lock yet the job never succeeds once | lockAtMostFor is shorter than the job really takes, or lockAtLeastFor makes the current round skip outright, or replica clocks disagree | Set lockAtMostFor to "worst-case duration + margin", enable NTP, and on first rollout confirm via logs which instance won the lock | Section 10's comparison table | |
| After a restart, a batch of queued async tasks vanished with no trace | Shutdown did not wait: waitForTasksToCompleteOnShutdown was off, or your wait exceeded the orchestrator's terminationGracePeriodSeconds and the container was killed | Configure the trio together: server.shutdown: graceful + timeout-per-shutdown-phase + pool-level setAwaitTerminationSeconds, keeping the pool wait below the platform grace period | Section 7 | |
| Async task logs carry no traceId and the trace breaks in half | The MDC lives in a ThreadLocal and does not travel across threads automatically | Set setTaskDecorator(new MdcTaskDecorator()) on the pool (code in Section 6) and remember MDC.clear() afterwards to avoid polluting reused threads | Section 6 | |
java.lang.OutOfMemoryError: unable to create new native thread, with SimpleAsyncTaskExecutor in the stack | Production kept the default executor: one new thread per task, no reuse, no upper bound, so a traffic burst stacks threads until the process dies | Switch to ThreadPoolTaskExecutor with explicit core/max/queue — the entire content of Section 3 | Section 3 · Section 12 sandbox |
if you print one row of this table, make it the first — the bracket after did not accept task already contains pool size / active threads / queued tasks as live numbers. That is your thread-pool dashboard: one glance tells you whether to grow threads or grow the queue.
Section 6 promised that touching the request from async code always blows up, so let a real stack trace close the argument. This is the highest-frequency variant in production — read it, then pick the frame you think is guilty before looking at the answer:
The order is created, an SMS is sent asynchronously, and an exception appears in the log — while the checkout endpoint returned 200 the whole time.
Goal: with a small runnable Spring project, print the full chain "main flow returns instantly → the work happens on an async thread → the exception is visible only on that thread", and watch self-invocation degrade async into sync.
Step one, pom.xml (Java 17; swapping in Boot's spring-boot-starter works identically):
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>6.1.8</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <!-- TaskExecutor lives here --> <version>6.1.8</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.5.6</version> </dependency></dependencies>Step two, the pool plus the exception safety net (src/main/java/com/example/async/AsyncConfig.java) — one class supplies both hooks of AsyncConfigurer:
package com.example.async;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.scheduling.annotation.AsyncConfigurer;import org.springframework.scheduling.annotation.EnableAsync;import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;import java.util.Arrays;import java.util.concurrent.Executor;@Configuration@EnableAsync // master switch: without it @Async does nothingpublic class AsyncConfig implements AsyncConfigurer { private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class); @Bean("notifyExecutor") public ThreadPoolTaskExecutor notifyExecutor() { ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(2); ex.setMaxPoolSize(4); ex.setQueueCapacity(10); ex.setThreadNamePrefix("notify-"); // key: a business prefix makes logs readable ex.setWaitForTasksToCompleteOnShutdown(true); ex.setAwaitTerminationSeconds(10); ex.initialize(); // plain container: call it yourself; Boot does it for you return ex; } /** Executor used when @Async names no pool */ @Override public Executor getAsyncExecutor() { return notifyExecutor(); } /** The only place a void async exception can be caught */ @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> log.error("async task failed method={} params={}", method.getName(), Arrays.toString(params), ex); }}Step three, the async service (NotifyService.java). It is called from another bean, so those calls pass through the proxy:
package com.example.async;import org.springframework.scheduling.annotation.Async;import org.springframework.stereotype.Service;@Servicepublic class NotifyService { @Async("notifyExecutor") public void sendNotify(Long orderId) { System.out.println("[notify] thread=" + Thread.currentThread().getName() + " sending SMS orderId=" + orderId); } @Async("notifyExecutor") public void failingAsync() { throw new IllegalStateException("the SMS gateway exploded"); // an exception nobody receives }}Step four, the main flow (OrderService.java), containing both the good case and the counter-example: cross-bean call vs same-class self-invocation.
package com.example.async;import org.springframework.scheduling.annotation.Async;import org.springframework.stereotype.Service;@Servicepublic class OrderService { private final NotifyService notifyService; public OrderService(NotifyService notifyService) { this.notifyService = notifyService; // constructor injection: complete at birth } public void createOrder(Long orderId) { System.out.println("[main] thread=" + Thread.currentThread().getName() + " writing order orderId=" + orderId); notifyService.sendNotify(orderId); // cross-bean -> through the proxy -> truly async } public void selfInvokeDemo() { System.out.println("[main] self-invocation starts thread=" + Thread.currentThread().getName()); sendNotifyInline(2002L); // this.xxx() bypasses the proxy -> synchronous System.out.println("[main] self-invocation ends thread=" + Thread.currentThread().getName()); } @Async("notifyExecutor") public void sendNotifyInline(Long orderId) { System.out.println("[inline] thread=" + Thread.currentThread().getName() + " sending SMS orderId=" + orderId); } public void boom() { notifyService.failingAsync(); // handed to an async thread; it will not come back here }}Step five, the launcher (Main.java):
package com.example.async;import org.springframework.context.annotation.AnnotationConfigApplicationContext;public class Main { public static void main(String[] args) throws InterruptedException { try (AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AsyncConfig.class, OrderService.class, NotifyService.class)) { OrderService svc = ctx.getBean(OrderService.class); long t0 = System.currentTimeMillis(); svc.createOrder(1001L); // external call: through the proxy System.out.println("createOrder returned in " + (System.currentTimeMillis() - t0) + "ms"); svc.selfInvokeDemo(); // internal call: bypasses the proxy svc.boom(); // void async method throws Thread.sleep(2000); // give the async threads a moment } System.out.println("---- container closed ----"); }}Run main. Expected output — the thread names are the only evidence, match them line by line (the [notify] line may float slightly depending on scheduling):
[main] thread=main writing order orderId=1001createOrder returned in 2ms[main] self-invocation starts thread=main[inline] thread=main sending SMS orderId=2002 <- self-invocation degraded into sync![main] self-invocation ends thread=main[notify] thread=notify-1 sending SMS orderId=1001 <- cross-bean call is the real asyncERROR c.example.async.AsyncConfig - async task failed method=failingAsync params=[]java.lang.IllegalStateException: the SMS gateway exploded at com.example.async.NotifyService.failingAsync(NotifyService.java:19)---- container closed ----Acceptance checklist: ① point at the thread-name difference between the [inline] and [notify] lines and say which call really went async; ② remove @EnableAsync and rerun — the container then creates no async proxy for these beans at all, so you will observe all three notification lines printing on main in strict sequence, while IllegalStateException no longer appears in AsyncConfig's error log but propagates up the call stack into main and kills the program (proof that "whether anyone catches the exception" depends on the proxy existing, not on how many handlers you wrote); ③ explain why "createOrder returned in 2ms" and the SMS still went out.
Change exactly one thing per run and the conclusion flips:
- Set
queueCapacitytoInteger.MAX_VALUEand submit 5000 tasks in a loop. You will observe:active threadsstays frozen atcorePoolSize=2,maxPoolSize=4never engages, everything piles up in the queue and memory climbs — Section 4's "an unbounded queue makes max useless", reproduced. - Restore
queueCapacity=2withcorePoolSize=2,maxPoolSize=4, then submit 100 tasks concurrently. You will observe:TaskRejectedException ... did not accept taskin the log withqueued tasks = 2andactive threads = 4both pinned — read those three numbers against the8/16|AbortPolicyposition of the Section 12 sandbox. - Swap the rejection handler for
new ThreadPoolExecutor.CallerRunsPolicy(), changing nothing else. You will observe: rejections disappear but the main thread starts running tasks itself, andcreateOrder returned injumps from 2ms to hundreds — "lossless" billed to the submitter, i.e. the backpressure the sandbox describes. - Add a
@Scheduled(fixedRate = 1000)method that sleeps 4 seconds, plus a lightweight job printing every 2 seconds. You will observe: the lightweight job now prints only every ~4 seconds — the default scheduler owns a single thread. Register theThreadPoolTaskScheduler(poolSize=4)from Section 9 and the two lines separate again. - Annotate
failingAsync()with@Transactional, insert a row and then throw, while the main flow also runs in a transaction. You will observe: the two rollbacks ignore each other and the async write follows its own transaction's rules — empirical proof of Section 6's boundary; watch it alongside theREQUIRES_NEWcase in thetxproplab.
Tip: after variants 2 and 3, return to the Section 12 sandbox, flip both switches to the matching positions, and confirm the readings agree.
Write an "async observability" component so any future project's pool state is visible at a glance and safe to shut down.
Requirements:
- A
PoolMetricsReporterprinting, every 5 seconds, for eachThreadPoolTaskExecutor:poolSize / activeCount / queueSize / queueRemainingCapacity / completedTaskCount / rejectCount - Do not guess
rejectCount: wrap a customRejectedExecutionHandlerthat increments a counter and then delegates to the original policy (keep Abort / CallerRuns / DiscardOldest selectable) - Allow per-pool parameters from configuration, and validate at startup: warn loudly if
queueCapacity == Integer.MAX_VALUEormaxPoolSize <= corePoolSize(turn the Section 4 and Section 12 traps into a boot self-check) - Install a
TaskDecoratorcarrying the MDC, log the submission timestamp so queue wait is computable as "started at − submitted at" - On graceful shutdown print "tasks remaining / seconds actually waited", and alert explicitly if
awaitTerminationSecondsis exceeded
Acceptance checklist: ① under load, queueSize tracks traffic and rejectCount increments at the exact moment of a rejection; ② deliberately set the queue to Integer.MAX_VALUE and the startup WARN must appear; ③ shutdown logs show "N remaining, waited Xs"; ④ the async log's traceId matches the HTTP request that triggered it (MDC carried successfully); ⑤ not one business method changes.
from memory, name the four stages a submitted task passes through, and explain why "queue first, then grow" makes maxPoolSize useless behind a large queue.
after a void @Async method throws, which objects does that exception travel through and where does it finally land? How many legitimate ways do you have to catch it?
what happens when a class calls its own @Async method? Is that the same root cause as @Transactional self-invocation failing?
among MDC, SecurityContext and the transaction context — which cross threads, with what mechanism, and which cannot? What is the underlying reason?
fixedRate versus fixedDelay differ in "measured from which instant". When a run exceeds the interval, how does each behave?
why does @Scheduled fire once per replica in a cluster? Why must the Redis lock key carry a time granularity, and why should its TTL sit slightly below the interval?
Mantra: **annotations hand work over; the pool decides its fate — queue first, hire next, refuse at the cap; a void exception is a lost parcel nobody reports; threads carry data but never a transaction; in a cluster, grab the lock before doing the work.**
an exception inside a @Scheduled task does not stop future runs — that's good; but it also doesn't stop the task, so always try-catch and log inside the task, or you'll see "the task seems not to run" when in fact it silently fails every time.
fixedRate means "a rate from the start instant", so a long task exceeding the interval runs back-to-back and accumulates backlog (note: no overlap, since the single-threaded scheduler waits for the previous run). If you don't want a backlog, switch to fixedDelay, which measures from the end of the previous run.
four takeaways — @Async / @Scheduled only submit; the thread pool (or scheduler) decides the behavior; @Async needs a custom pool, must handle void exceptions, and fails on self-invocation; contexts (MDC / SecurityContext / transactions) do not cross threads automatically; in a cluster, scheduled tasks run repeatedly and need a distributed lock or scheduler center. Nail these four and async plus scheduling stop being a "sometimes-works mystery".