Skip to content

Custom Jackson deserializers re-introspect nested types on every call — allocation hotspot in JdbcOAuth2AuthorizationService and Spring Session JDBC paths (much amplified by Jackson 3) #19223

@aschepp

Description

@aschepp

Describe the bug

Several custom ValueDeserializer / JsonDeserializer implementations under org.springframework.security.jackson.*, org.springframework.security.jackson2.*, org.springframework.security.oauth2.*.jackson.* and org.springframework.security.oauth2.*.jackson2.* resolve their nested deserializers inside deserialize(...) on every invocation rather than once per deserializer lifetime. Because Jackson's DeserializerCache._findCachedDeserializer short-circuits whenever _hasCustomHandlers(type) returns true (see databind #735) — and the polymorphic value types Spring Security passes in (Map<String, Object>, Set<String>, List<GrantedAuthority>, Object for principal/details) trigger exactly that condition — the lookup is never cached, so every call re-runs full POJO introspection (POJOPropertiesCollectorAnnotatedMethodCollector._addMemberMethods → reflective generic-signature parsing).

Affected classes (the ones I traced via JFR and verified with the benchmark below; others in the same packages likely follow the same pattern):

Jackson 3 (tools.jackson):

  • org.springframework.security.jackson.UsernamePasswordAuthenticationTokenDeserializer
  • org.springframework.security.oauth2.server.authorization.jackson.OAuth2AuthorizationRequestDeserializer
  • org.springframework.security.oauth2.client.jackson.OAuth2AuthorizationRequestDeserializer
  • org.springframework.security.oauth2.client.jackson.ClientRegistrationDeserializer

Jackson 2 (com.fasterxml.jackson) — same architectural issue, lower per-call cost — these are @Deprecated(forRemoval = true) so most likely out of scope for a fix:

  • org.springframework.security.jackson2.UsernamePasswordAuthenticationTokenDeserializer
  • org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationRequestDeserializer
  • org.springframework.security.oauth2.client.jackson2.OAuth2AuthorizationRequestDeserializer
  • org.springframework.security.oauth2.client.jackson2.ClientRegistrationDeserializer

These deserializers run on hot paths:

  • Every /oauth2/token authorization-code redemption: JdbcOAuth2AuthorizationService.findById / findByTokenJsonMapperOAuth2AuthorizationRowMapper reads the oauth2_authorization.attributes JSON, which contains an OAuth2AuthorizationRequest and a polymorphic Principal.
  • Every authenticated request that loads a session through Spring Session JDBC: UsernamePasswordAuthenticationTokenDeserializer.deserialize rebuilds the SecurityContext.

The dominant JFR allocation stack on a production Spring Boot 4 / Spring Security 7 service under steady traffic ends in AnnotatedMethodCollector._addMemberMethods and is reached via JsonNodeUtils.findValue → DeserializationContext.readTreeAsValue → DeserializerCache._createAndCacheValueDeserializer (the cache-bypass branch). A second equivalent stack starts from UsernamePasswordAuthenticationTokenDeserializer.deserialize whenever Spring Session JDBC loads a session. Together these account for ~25 % of total JVM allocation in a representative 60-second JFR sample of the deployment that triggered this investigation.

Why now: this is amplified by the Jackson 3 migration, but the architectural issue predates it

A microbenchmark on main (JDK 25.0.3) using a JSON fixture that mirrors a representative oauth2_authorization.attributes payload (auth-code grant, one scope, a custom UserDetails principal with three fields, one FactorGrantedAuthority, a WebAuthenticationDetails; ~1.7 KB serialized) was run against both the Jackson 2 path (com.fasterxml.jackson + SecurityJackson2Modules) and the Jackson 3 path (tools.jackson + SecurityJacksonModules), without any other code change:

Jackson 2 (existing baseline) Jackson 3 (current default) Ratio
Allocation per mapper.readValue 28,906 bytes 105,982 bytes 3.67× worse on Jackson 3
Latency per call 11,384 ns 34,121 ns 3.00× slower on Jackson 3
Total allocation, 50,000 iters 1.45 GB 5.30 GB
Total wall time, 50,000 iters 569 ms 1,706 ms

The same JsonNodeUtils.findValue → ObjectMapper.convertValue / DeserializationContext.readTreeAsValue → DeserializerCache.findValueDeserializer path exists in both, and both fail to cache the deserializer for these polymorphic container types — the cache bypass is by design and has been in Jackson since 2.x. What changed is the per-miss cost: Jackson 3's introspection (POJOPropertiesCollector.collectAll, generic-signature parsing) is ~3–4× heavier than Jackson 2's. Deployments on Jackson 2 were paying this cost too, but at a level small enough to absorb invisibly; the Spring Boot 4 bump to Jackson 3 pushed it across the threshold where it becomes a visible production GC hotspot — in one observed case, recurring OOMs.

Potential improvement headroom

A local experiment (in a private fork) modified the four Jackson 3 deserializers above to resolve their nested deserializers exactly once instead of on every call. The microbenchmark on the same fixture and JVM, identical methodology:

Jackson 3 — main Jackson 3 — local experiment Reduction
Allocation per mapper.readValue 105,982 bytes 11,769 bytes −88.9 %
Latency per call 34,121 ns 6,074 ns −82.2 %
Total allocation, 50,000 iters 5.3 GB 588 MB
Total wall time, 50,000 iters 1,706 ms 303 ms

After the experimental change the per-call profile no longer shows AnnotatedMethodCollector._addMemberMethods / POJOPropertiesCollector.collectAll / generic-signature parsing in the hot stack — i.e. the introspection cost is paid once per deserializer construction, not per call. All existing tests in spring-security-core, spring-security-oauth2-client, and spring-security-oauth2-authorization-server pass under the change.

I'm not proposing a specific implementation as part of this report — the maintainers are better placed to choose the approach (e.g. lifecycle-callback pre-resolution, alternate helper APIs in JsonNodeUtils, a structural change to the affected deserializers, etc.). The numbers above are intended to show the size of the achievable headroom on Jackson 3, not to advocate for one technique over another.

To Reproduce

  1. Have any application backed by JdbcOAuth2AuthorizationService (or Spring Session JDBC with UsernamePasswordAuthenticationToken principals) on Spring Boot 4.0.x / Spring Security 7.0.x.

  2. Build a JsonMapper configured with SecurityJacksonModules.getModules(...) (the standard configuration used by the JDBC services above).

  3. Serialize a representative attributes-style Map<String, Object> containing both an OAuth2AuthorizationRequest and an authenticated UsernamePasswordAuthenticationToken, then deserialize it repeatedly:

    String json = mapper.writeValueAsString(attributes);
    for (int i = 0; i < N; i++) {
        mapper.readValue(json, Map.class);
    }
  4. Measure thread-local allocation with com.sun.management.ThreadMXBean.getThreadAllocatedBytes, or attach async-profiler -e alloc / JFR (settings=profile) and inspect allocation hot spots. Each invocation allocates ~100 KiB on Jackson 3 (~29 KiB on Jackson 2); the dominant stacks all end in AnnotatedMethodCollector._addMemberMethods reached from a Spring Security custom deserializer.

Self-contained microbenchmarks for both Jackson 2 and Jackson 3 (one file each, synthetic identifiers only) are included in the sample below.

Expected behavior

The cost of resolving these nested deserializers should be amortized across calls — the per-call allocation profile should not include POJO introspection (AnnotatedMethodCollector._addMemberMethods, POJOPropertiesCollector.collectAll, generic-signature parsing) for the Set<String>, Map<String, Object>, List<GrantedAuthority> and Object types these deserializers consume.

Concretely, after a fix, allocation per mapper.readValue for a row-shaped payload like the one used here should be on the order of single-digit kilobytes rather than ~100 KB (Jackson 3) / ~29 KB (Jackson 2). The local experiment described above reaches ~12 KB on Jackson 3.

The implementation approach is at the maintainers' discretion.

Sample

Minimal reproducer — two self-contained Java files (one per Jackson version) on a branch off upstream/main, no production-code changes. All identifiers are synthetic (UUID.randomUUID() / 127.0.0.1 / example.com):

Run with:

./gradlew :spring-security-oauth2-authorization-server:test \
    --tests "*OAuth2AuthorizationRequestDeserializerAllocationBenchmark" \
    --tests "*OAuth2AuthorizationRequestDeserializerJackson2AllocationBenchmark" \
    -i

Each benchmark prints alloc_per_call_bytes and ns_per_call to stdout — before/after comparison is a single line of output difference.

Environment

  • Spring Security: 7.0.5 (also reproduces on main at HEAD)
  • Spring Boot: 4.0.6 (production), main for benchmark
  • Jackson: 3.1.3 (transitive from Spring Boot 4.0.6) and 2.x (comparison baseline)
  • JDK: Eclipse Temurin 25.0.3 (also reproduces on Temurin 21)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions