Skip to content

Avoid decorated task map overhead in ThreadPoolTaskExecutor - #37298

Open
bazzi2548 wants to merge 1 commit into
spring-projects:mainfrom
bazzi2548:remove-decorated-task-map-pr
Open

bazzi2548 wants to merge 1 commit into
spring-projects:mainfrom
bazzi2548:remove-decorated-task-map-pr

Conversation

@bazzi2548

@bazzi2548 bazzi2548 commented Sep 18, 2026

Copy link
Copy Markdown

Replace the shared weak-reference bookkeeping map in ThreadPoolTaskExecutor with a task-local wrapper that retains both the decorated task and its original task. This removes reference-map updates from the submission path while preserving cancellation of queued futures during shutdown.

Problem

When a TaskDecorator returns a different Runnable, every submission inserts a mapping into decoratedTaskMap before reaching ThreadPoolExecutor.execute(). Completed tasks do not explicitly remove those mappings, so cleanup depends on weak-reference processing and later map maintenance. ConcurrentReferenceHashMap resizing and purging can make submitting threads contend before work reaches the executor queue.

This was observed in a production deployment where weak-reference bookkeeping on the decorated task submission path accumulated and later contended during map maintenance. Request threads were observed waiting before work reached the executor, while executor workers were idle. Replacing the executor with an implementation that avoided this shared map resolved the production symptom. These observations motivate the change; the reproducible measurements below are a separate local experiment and are not production data.

Change

  • Replace decoratedTaskMap with a private DecoratedTask wrapper holding the decorated and original tasks.
  • Submit the wrapper once and delegate execution to the decorated task.
  • During shutdown, cancel both the decorated and original task when applicable.
  • Preserve the direct path for tasks that are not wrapped.

This removes reference-map resizing and purging from the submission path at the cost of one wrapper per decorated task.

Validation

  • 41 Spring scheduling tests passed, including new coverage for single execution, queued Runnable/Callable/FutureTask cancellation, future-producing decorators, and identity decorators.
  • spring-context checkstyle checks passed.
  • git diff --check passed.

Reproducible experiment

A standalone harness compares the original executor from the parent revision with the patched executor, with an identity-decorator control. It uses distinct metrics wrappers, independently paced producers, separate JVMs, JFR ThreadPark events, GC logs, stack sampling, and reflective map counters. It does not retain map entries, force hash collisions, or force GC in the normal experiment.

One local run used Temurin 25.0.3+9-LTS, Generational ZGC, a 2 GiB heap, 400 producers, 8 workers, 100,000 queue capacity, and 15,000 offered submissions/second for 90 seconds per variant:

Observation Original + metrics Patched + metrics Original + identity
Observed submissions/s 14,999.0 14,999.5 14,990.4
Maximum submission call 56.6 ms 4.3 ms 14.2 ms
Peak approximate map entries 1,349,150 0 0
Peak map bucket capacity 2,097,152 0 16
Peak sampled producer map waiters 52 0 0
Samples with map waiters, zero active workers and empty queue 19 0 0
Producer map parks meeting JFR's 10 ms threshold 612 0 0
Longest recorded producer map park 55.6 ms 0 0

All accepted tasks completed, with no rejections. The longest baseline JFR event included ReentrantLock.lock, ConcurrentReferenceHashMap$Segment.doTask, ConcurrentReferenceHashMap.doTask, ConcurrentReferenceHashMap.put, and ThreadPoolTaskExecutor$1.execute. The 612 events summed to 13.23 thread-seconds; this is thread-time, not service-outage time. The sampled idle-worker observations are not evidence of one continuous starvation interval, and the measurements do not establish that resizing caused every wait.

The baseline GC log contained three warmup major collections and one minor collection. Concurrent non-strong processing in the major collections was 1.358 ms, 2.848 ms, and 2.025 ms. No explicit GC was used. This run demonstrates the submission-path map lock under the stated load.

Compatibility note

When decoration changes the task, the underlying executor now receives a private wrapper. Custom rejection handlers, execution hooks, and callers inspecting the native executor queue will observe that wrapper rather than the decorator's returned object.

Retain decorated and original tasks in a private wrapper instead of
updating a shared weak-reference map on every decorated submission.
This removes reference-map resizing and purging from the submission
path while preserving cancellation of queued futures during shutdown.

Add regression tests for single execution, queued future cancellation,
future-producing decorators, and identity decorators.

Signed-off-by: 문정환 <70272622+bazzi2548@users.noreply.github.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Sep 18, 2026
@sbrannen sbrannen added the in: core Issues in core modules (aop, beans, core, context, expression) label Sep 19, 2026
@sbrannen
sbrannen requested a review from jhoeller September 19, 2026 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in: core Issues in core modules (aop, beans, core, context, expression) status: waiting-for-triage An issue we've not yet triaged or decided on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants