Skip to content

Avoid allocations for cached annotation mappings - #37141

Open
gregjotau wants to merge 2 commits into
spring-projects:mainfrom
gregjotau:wt-spring-framework-annotation-cache-hit
Open

Avoid allocations for cached annotation mappings#37141
gregjotau wants to merge 2 commits into
spring-projects:mainfrom
gregjotau:wt-spring-framework-annotation-cache-hit

Conversation

@gregjotau

Copy link
Copy Markdown

AnnotationTypeMappings.forAnnotationType(...) currently creates a new
HashSet for visited annotation types before checking whether the mappings are
already cached. Cached lookups therefore allocate a set that is never used.

This change adds a cache-hit path that defers creation of the visited set until
a cache miss. Mapping creation and recursive annotation handling continue to
use the same visited-set logic.

Benchmarks

I measured 20 million cached mapping lookups after 2 million warmup lookups on
JDK 26.0.2 with compact object headers and -XX:TieredStopAtLevel=1. Limiting
tiered compilation models the cold/C1-compiled startup path where JFR showed
the allocation; fully warmed C2 can scalar-replace it.

Baseline This change Difference
Median time 153.676 ns/op 140.448 ns/op -8.6%
Allocated 72 bytes/op 16 bytes/op -77.8%

I also patched only these two generated class files into Spring Core 7.0.8 and
recorded startup of a large Kotlin/Spring Boot application with 409 dependency
jars and 191 Spring Data repositories. JFR allocation samples changed as
follows:

Baseline This change Difference
HashSet from AnnotationTypeMappings 121.84 MiB 0 MiB -100%
Total sampled startup allocation 9,216.72 MiB 9,018.28 MiB -2.15%

Wall-clock application startup was too noisy to make an end-to-end timing
claim, so the timing result above is limited to the isolated cached lookup.

Verification

./gradlew :spring-core:check passes, including the JDK 21 and JDK 24 test
suites, multi-release JAR validation, architecture checks, and checkstyle.

Defer creation of the visited annotation types set until a cache miss
occurs. This avoids allocating a HashSet for every cached annotation
mapping lookup while preserving recursive annotation handling during
mapping creation.

Signed-off-by: GT <gregjotau@gmail.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Aug 15, 2026
@sbrannen sbrannen added in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Aug 16, 2026
@sbrannen sbrannen added this to the 7.1.0-M2 milestone Aug 16, 2026

@sbrannen sbrannen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR and for the detailed benchmarking — that's great supporting evidence. 👍

You've introduced a new code path (Cache.get(Class)) for the cache-hit case, but no test exercises it directly. Right now it's only reached incidentally by tests targeting other behavior, so a regression in the cache-hit branch (e.g., returning a stale or incorrect mapping, or accidentally falling through to getOrCreate() every time) wouldn't necessarily be caught.

In light of that, please add a test in AnnotationTypeMappingsTests that calls AnnotationTypeMappings.forAnnotationType(...) twice for the same annotation type and asserts the second call returns the same cached instance (AssertJ: isSameAs()). That will pin down the cache-hit path this PR introduces.

Also, please introduce the static helper method I mentioned in the comments.

Cheers

Comment on lines +212 to +219
if (repeatableContainers == RepeatableContainers.standardRepeatables()) {
return standardRepeatablesCache.computeIfAbsent(annotationFilter,
key -> new Cache(repeatableContainers, key)).get(annotationType);
}
if (repeatableContainers == RepeatableContainers.none()) {
return noRepeatablesCache.computeIfAbsent(annotationFilter,
key -> new Cache(repeatableContainers, key)).get(annotationType);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cache selection logic is now duplicated between this method and the 4-arg forAnnotationType(...) below, with an identical standardRepeatables()/none() dispatch that differs only in whether Cache.get(annotationType) or Cache.get(annotationType, visitedAnnotationTypes) gets invoked.

Let's extract a shared helper instead, perhaps something similar to the following.

private static @Nullable Cache getCache(
		RepeatableContainers repeatableContainers, AnnotationFilter annotationFilter) {

	if (repeatableContainers == RepeatableContainers.standardRepeatables()) {
		return standardRepeatablesCache.computeIfAbsent(annotationFilter,
				key -> new Cache(repeatableContainers, key));
	}
	if (repeatableContainers == RepeatableContainers.none()) {
		return noRepeatablesCache.computeIfAbsent(annotationFilter,
				key -> new Cache(repeatableContainers, key));
	}
	return null;
}

Both overloads would then reduce to a null-check on getCache(...), which would avoid having two copies of the dispatch logic to keep in sync as this evolves.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — both overloads now go through a shared getCache(...) helper as suggested.

@sbrannen sbrannen added the status: waiting-for-feedback We need additional information before we can continue label Aug 16, 2026
Extract a shared getCache helper to avoid duplicating cache selection
logic, and add a test that subsequent lookups return the same cached
instance.

Signed-off-by: GT <gregjotau@gmail.com>
@gregjotau

Copy link
Copy Markdown
Author

Thanks for the review.

Addressed in f0a932b:

  • Extracted the shared getCache(RepeatableContainers, AnnotationFilter) helper so the standardRepeatables() / none() dispatch lives in one place.
  • Added forAnnotationTypeWhenCalledTwiceReturnsCachedInstance in AnnotationTypeMappingsTests, which calls forAnnotationType(...) twice and asserts the second result isSameAs the first.

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Aug 16, 2026
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: feedback-provided Feedback has been provided type: enhancement A general enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants