WW-5539 Concurrency performance enhancements#1799
Draft
lukaszlenart wants to merge 18 commits into
Draft
Conversation
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>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 fourHashMap/HashSetcaches were read with no lock at all byXWorkConverter.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 aHashMap$Node→HashMap$TreeNodeClassCastExceptionthrown from concurrent structural modification during resize.XWorkConverter—getConverter()heldsynchronized (clazz). Locking on aClassobject 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/registerConverterNotFoundweresynchronizedmethods on the singleton, though they only ever excluded each other — the readers inlookup()were never covered.DefaultActionValidatorManager—getValidators()wassynchronizedon the singleton, so every validated request in the application serialised on it. The lock also covered the per-requestValidatorconstruction 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.TypeConverterHoldergains onedefaultmethod,computeMappingIfAbsent(Class, Function), so third-party implementations of this SPI keep working unchanged.StrutsTypeConverterHolderoverrides it with aget→ 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 reachesObjectFactory.buildConverter, which instantiates and (underSpringObjectFactory) autowires arbitrary user-suppliedTypeConverterclasses — constructors,@PostConstruct,afterPropertiesSet. Running that under a CHM bin lock risksIllegalStateException: Recursive updateor a self-deadlock if any of it re-enters conversion, andDefaultConversionAnnotationProcessoralready 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: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: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 bycomputeMappingIfAbsent.conditionalReloadwrites back its own rebuild.StrutsTypeConverterHolder.addDefaultMappingnow ignores anullconverter with a warning instead of storing it.ConcurrentHashMapforbids null values, and storing one previously left the holder inconsistent —containsDefaultMappingtrue whilegetDefaultMappingreturned null. No in-tree caller can pass null today.addNoMappingstoring 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.putIfAbsentwas considered and rejected: it would leave a stale mapping being served after a failed build.XWorkConverter.registerConverter/registerConverterNotFoundare no longersynchronized.TypeConverterHolder.getMapping,addMappingandcontainsNoMappingare deprecated, superseded bycomputeMappingIfAbsent.addNoMappingstays — it is still the right primitive for recording a build failure, and folding it in would mean swallowingThrowableinside the SPI.Binary compatibility was deliberately preserved.
StrutsTypeConverterHolder.unknownMappingskeeps its originalprotected HashSet<String>declaration as a deprecated, unused vestige: retyping it changed the field descriptor, so a subclass compiled against 7.2.0 would have hitNoSuchFieldErroron upgrade without recompiling. Real storage moved to a private concurrent set.Known benign race, deliberately not closed
lookup()readscontainsUnknownMappingthencontainsDefaultMappingas two separate atomic calls. The pair is not atomic, so a converter registered between them yields a stalenullfor 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 unsynchronisedHashMapthis same interleaving was outright unsafe.Relatedly,
lookup()deliberately keeps check-then-act rather than usingcomputeIfAbsent: its resolverlookupSuper()readsgetDefaultMapping()recursively while walking the class hierarchy, and a recursive read insideConcurrentHashMap.computeIfAbsentis forbidden — it deadlocks or throwsIllegalStateException: Recursive update.Also fixed here
DefaultValidatorFactory.validatorsis a plainHashMapread vialookupRegisteredValidatorTypeon every validator construction. That read used to be serialised byDefaultActionValidatorManager's lock; removing the lock exposed it, andValidatorFactory.registerValidatoris public API that writes to it. Changed toConcurrentHashMap— 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, bothcomputeIfAbsentcall sites verified not to re-enter their own maps, and the remaining races benign and self-correcting.Follow-ups (not in this PR)
mappingsholds strongClassreferences 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.FIXME lukaszlenartinTypeConverterHolderabout mergingunknownMappingsintonoMappingis a semantic consolidation, untouched here.