Skip to content

WW-5539 Concurrency performance enhancements#1799

Draft
lukaszlenart wants to merge 18 commits into
mainfrom
WW-5539
Draft

WW-5539 Concurrency performance enhancements#1799
lukaszlenart wants to merge 18 commits into
mainfrom
WW-5539

Conversation

@lukaszlenart

@lukaszlenart lukaszlenart commented Jul 21, 2026

Copy link
Copy Markdown
Member

Fixes WW-5539

Three core classes had coarse locks compensating for caches that were not thread-safe. This makes the caches genuinely concurrent and deletes the locks.

What was wrong

StrutsTypeConverterHolder — a container singleton whose four HashMap/HashSet caches were read with no lock at all by XWorkConverter.lookup() while being written elsewhere. This is a real data race, not just contention. The new test reproduces it against the unfixed code: a lost registration, and a HashMap$NodeHashMap$TreeNode ClassCastException thrown from concurrent structural modification during resize.

XWorkConvertergetConverter() held synchronized (clazz). Locking on a Class object is a globally visible monitor that any unrelated library can also lock, and it serialised every conversion for a given action class, including pure cache hits. registerConverter/registerConverterNotFound were synchronized methods on the singleton, though they only ever excluded each other — the readers in lookup() were never covered.

DefaultActionValidatorManagergetValidators() was synchronized on the singleton, so every validated request in the application serialised on it. The lock also covered the per-request Validator construction loop, which operates on per-request objects and never needed mutual exclusion.

Approach

Concurrent collections, then the locks come out. Where a computation is expensive and provably safe to guard, computeIfAbsent; where it is not, check-then-act with cheap idempotent duplicate work.

TypeConverterHolder gains one default method, computeMappingIfAbsent(Class, Function), so third-party implementations of this SPI keep working unchanged. StrutsTypeConverterHolder overrides it with a get → build → putIfAbsent, storing a sentinel for negative results so a class with no mapping is cached as decisively as one with a mapping — the no-mapping case is the common one for ordinary actions, and the builder walks the whole class hierarchy doing classpath lookups and reflection.

The builder is deliberately not run inside ConcurrentHashMap.computeIfAbsent. It reaches ObjectFactory.buildConverter, which instantiates and (under SpringObjectFactory) autowires arbitrary user-supplied TypeConverter classes — constructors, @PostConstruct, afterPropertiesSet. Running that under a CHM bin lock risks IllegalStateException: Recursive update or a self-deadlock if any of it re-enters conversion, and DefaultConversionAnnotationProcessor already demonstrates that the builder calls back into the holder. The trade is that concurrent first access may build more than once; every caller still converges on a single shared cached map, and the build is idempotent.

Measurements

Throughput, 3s measured after 20k warm-up iterations, on the same machine, before vs after. Second of two runs each; both runs agreed closely.

DefaultActionValidatorManager.getValidators:

threads before (ops/s) after (ops/s) ratio
1 11,100 11,300 1.0x
4 10,400 41,866 4.0x
16 10,700 77,366 7.2x
64 12,100 77,700 6.4x

The before column is the signature of a global lock: flat at ~10k ops/s no matter how many threads you add. This is the headline change.

XWorkConverter.getConverter:

threads before (ops/s) after (ops/s) ratio
1 26,342,966 94,234,166 3.6x
4 13,362,366 341,507,566 25.6x
16 12,643,300 850,345,433 67x
64 12,257,333 847,833,400 69x

Treat the converter magnitudes with suspicion — a warm cache hit is a single map lookup whose result the harness discards, so the JIT can optimise aggressively and the absolute figures are inflated. The trustworthy signal is the shape: before, throughput falls from 26M to 13M as soon as a second thread appears (negative scaling under synchronized (clazz)); after, it scales with thread count. The single-threaded 3.6x is the uncontended-lock cost, which is real now that biased locking is gone from modern JVMs.

The benchmark harness was throwaway and is not included in this PR.

Behaviour changes

  • XWorkConverter.buildConverterMapping (protected) no longer stores its result; storage is owned by computeMappingIfAbsent. conditionalReload writes back its own rebuild.
  • StrutsTypeConverterHolder.addDefaultMapping now ignores a null converter with a warning instead of storing it. ConcurrentHashMap forbids null values, and storing one previously left the holder inconsistent — containsDefaultMapping true while getDefaultMapping returned null. No in-tree caller can pass null today.
  • addNoMapping storing the sentinel replaces any mapping previously cached for that class. This matches the pre-7.3.0 observable behaviour, where such a class was short-circuited before its cached mapping was ever read. putIfAbsent was considered and rejected: it would leave a stale mapping being served after a failed build.
  • XWorkConverter.registerConverter/registerConverterNotFound are no longer synchronized.
  • TypeConverterHolder.getMapping, addMapping and containsNoMapping are deprecated, superseded by computeMappingIfAbsent. addNoMapping stays — it is still the right primitive for recording a build failure, and folding it in would mean swallowing Throwable inside the SPI.

Binary compatibility was deliberately preserved. StrutsTypeConverterHolder.unknownMappings keeps its original protected HashSet<String> declaration as a deprecated, unused vestige: retyping it changed the field descriptor, so a subclass compiled against 7.2.0 would have hit NoSuchFieldError on upgrade without recompiling. Real storage moved to a private concurrent set.

Known benign race, deliberately not closed

lookup() reads containsUnknownMapping then containsDefaultMapping as two separate atomic calls. The pair is not atomic, so a converter registered between them yields a stale null for that one call, self-correcting on the next lookup. Adding a lock would reintroduce the contention this PR removes, on the hottest read path, to close a window that resolves itself. Note that against the old unsynchronised HashMap this same interleaving was outright unsafe.

Relatedly, lookup() deliberately keeps check-then-act rather than using computeIfAbsent: its resolver lookupSuper() reads getDefaultMapping() recursively while walking the class hierarchy, and a recursive read inside ConcurrentHashMap.computeIfAbsent is forbidden — it deadlocks or throws IllegalStateException: Recursive update.

Also fixed here

DefaultValidatorFactory.validators is a plain HashMap read via lookupRegisteredValidatorType on every validator construction. That read used to be serialised by DefaultActionValidatorManager's lock; removing the lock exposed it, and ValidatorFactory.registerValidator is public API that writes to it. Changed to ConcurrentHashMap — otherwise this PR would have pushed the very hazard it removes down one layer.

Testing

Full core module: 3026 tests, 0 failures, 0 errors. Full multi-module reactor: BUILD SUCCESS. No pre-existing test needed modification.

New concurrency tests use 16 threads released simultaneously via CountDownLatch, asserting on collected results rather than on timing. They can demonstrate a race but never prove its absence — a green run on a strongly-ordered machine is weak evidence, and the primary correctness argument is the reasoning about the data structures: reads on concurrent collections, both computeIfAbsent call sites verified not to re-enter their own maps, and the remaining races benign and self-correcting.

Follow-ups (not in this PR)

  • Classloader pinning. mappings holds strong Class references in a container singleton, keeping webapp classloaders alive across hot redeploy. A lifetime/correctness issue rather than a locking one, and fixing it changes cache semantics — deserves its own ticket.
  • Removing the deprecated methods in a later release.
  • The FIXME lukaszlenart in TypeConverterHolder about merging unknownMappings into noMapping is a semantic consolidation, untouched here.

lukaszlenart and others added 18 commits July 21, 2026 16:30
Design for removing coarse locks from XWorkConverter,
DefaultActionValidatorManager and StrutsTypeConverterHolder in favour of
concurrent collections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five tasks derived from the approved design: make StrutsTypeConverterHolder
concurrent, add the computeMappingIfAbsent SPI method, remove the locks from
XWorkConverter and DefaultActionValidatorManager, then benchmark and raise
the PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
buildConverterMapping no longer stores its result, so reload mode would
have rebuilt from disk on every request without ever caching.
The holder is a container singleton whose HashMaps were read without any
lock by XWorkConverter.lookup() while being written elsewhere, risking
lost updates and torn reads during resize.

Null TypeConverters are now ignored with a warning rather than stored,
since ConcurrentHashMap forbids null values and a null converter left the
holder in an inconsistent state.
The method exercised only the unknown-mapping cache, not noMapping.
Adds an atomic build-once-and-cache operation so callers no longer need
check-then-act around the class mapping cache, and deprecates the three
primitives it subsumes: getMapping, addMapping and containsNoMapping.

The method is a default method delegating to those primitives, so
third-party TypeConverterHolder implementations keep working unchanged.
ConcurrentHashMap.computeIfAbsent stores nothing when the mapping function
returns null, so every concurrent caller re-ran the builder for a class
with no conversion mapping - the common case for an ordinary action, and
the exact thundering herd this method exists to prevent.

Negative results now store a sentinel in the same map, so the builder runs
once per class either way. getMapping and containsNoMapping translate the
sentinel, preserving their existing contracts.
Storing the no-mapping sentinel deliberately replaces any mapping cached
for the class, matching the pre-7.3.0 effective behaviour where such a
class was short-circuited before its cached mapping was ever read.
putIfAbsent would instead serve a stale mapping after a failed build.

Also asserts the sentinel translation in getMapping directly, and stops
the interface javadoc promising a specific empty-map instance that
implementations are not required to return.
The behaviour was documented only on the Struts implementation, but
addNoMapping stays a non-deprecated SPI primitive that third parties both
call and implement, so the contract belongs on the interface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getConverter() synchronized on the Class object being converted, which is
a globally visible monitor any other library may contend on, and which
serialised every conversion for a given action class including cache
hits. It now delegates to TypeConverterHolder#computeMappingIfAbsent.

registerConverter and registerConverterNotFound drop their synchronized
modifier; they are single delegations to a concurrent map, and the lock
never covered the readers in lookup() in any case.

buildConverterMapping no longer stores its result - storage is owned by
computeMappingIfAbsent.
getValidators() was synchronized on the singleton manager, so every
validated request in the application serialised on it - and the lock
covered the per-request Validator construction loop, which operates on
per-request objects and never needed mutual exclusion.

Both caches become ConcurrentHashMap and cached config lists are wrapped
unmodifiable, since several threads now iterate them concurrently.
The test computed its expected count with a getValidators call before
starting the threads, which warmed the cache and left all 16 workers on
the fast path - never exercising first-build contention, the race the
test is named for.

Also drops an unused import and awaits executor termination.
Restores the protected unknownMappings field verbatim as a deprecated,
unused vestige: retyping it changed the field descriptor, so a subclass
compiled against 7.2.0 would have hit NoSuchFieldError on upgrade without
recompiling. Real storage moves to a private concurrent set.

Also stops conditionalReload running for negative-cached classes, which
had been costing a failed classloader resource scan per property per
request in devMode, and restores the unknown-mapping clearing that the
null-converter guard was skipping.
Four correctness fixes surfaced in PR review of the concurrent-collections
refactor:

- StrutsTypeConverterHolder.addDefaultMapping: restore put-before-remove
  ordering. The inverted order let a concurrent XWorkConverter.lookup
  observe (unknown=false, default=false), sending it into lookupSuper()
  and letting it overwrite the more specific converter being registered.
- StrutsTypeConverterHolder.computeMappingIfAbsent: stop building inside a
  ConcurrentHashMap bin lock. The builder reaches ObjectFactory.buildConverter,
  which can autowire arbitrary user TypeConverters; running that under a CHM
  bin lock risked a recursive-update exception or self-deadlock. Callers now
  only get the guarantee that they converge on the same cached instance, not
  that the builder runs exactly once - documented on the interface and
  reflected in the concurrency tests.
- DefaultValidatorFactory.validators: switch to ConcurrentHashMap now that
  DefaultActionValidatorManager.getValidators is no longer synchronized,
  so runtime registerValidator() calls no longer race unsynchronized reads
  of a plain HashMap.
- XWorkConverter.conditionalReload: route empty devMode rebuilds through
  addNoMapping instead of addMapping, so an empty reload result is stored
  as the NO_MAPPING sentinel rather than a plain empty map that would
  silently disable further reloads for the class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SonarCloud's quality gate failed at 59.8% coverage on new code (need
>=80%). Adds tests for the specific lines JaCoCo identified as
uncovered, without touching production code:

- TypeConverterHolder.computeMappingIfAbsent's default method body
  (the SPI compatibility fallback for third-party holders that predate
  7.3.0 and don't override it) - new TypeConverterHolderTest against a
  minimal non-overriding implementation.
- StrutsTypeConverterHolder.getMapping/containsNoMapping's remaining
  non-sentinel branch.
- XWorkConverter.conditionalReload's reloadingConfigs==true path (both
  the addMapping and addNoMapping outcomes), buildConverterMappingUnchecked's
  checked-to-IllegalStateException wrapping, and getConverter's
  catch(Throwable) negative-caching.
- DefaultActionValidatorManager's else-if(reloadingConfigs) cache
  rebuild, loadFile's checkFile&&fileNeedsReloading re-parse, and
  buildValidatorConfigs' already-checked short-circuit.

TypeConverterHolder.java and StrutsTypeConverterHolder.java are now at
0 missed lines/branches. XWorkConverter.java and
DefaultActionValidatorManager.java have all requested target lines
covered; remaining misses are pre-existing, unrelated gaps left alone
per scope. Full core suite: 3026 tests (3015 + 11 new), 0
failures/errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add since/forRemoval attributes to the 7 @deprecated elements on
  TypeConverterHolder.getMapping/addMapping/containsNoMapping and
  StrutsTypeConverterHolder's overrides plus the unknownMappings field
  (java:S6355).
- Add the missing @deprecated Javadoc tag to the three
  StrutsTypeConverterHolder overrides, pointing at
  computeMappingIfAbsent as the replacement (java:S1123).
- Remove the unused throws Exception from
  testGetConverterBuildsMappingExactlyOncePerClass (java:S1130).
- Document why StubFileManager.setReloadingConfigs/monitorFile are
  intentionally empty no-ops (java:S1186).
- Rename a local variable that shadowed the converter field in
  testConditionalReloadRebuildsEmptyMappingAndStoresItViaAddNoMapping
  (java:S1117).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
javac treats [removal] as a category separate from [deprecation], so
marking the three primitives forRemoval left four warnings behind: the
deliberate addMapping call in conditionalReload, and the three overrides
that must exist for as long as the interface declares them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant