[
https://issues.apache.org/jira/browse/WW-5539?focusedWorklogId=1031535&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1031535
]
ASF GitHub Bot logged work on WW-5539:
--------------------------------------
Author: ASF GitHub Bot
Created on: 21/Jul/26 18:56
Start Date: 21/Jul/26 18:56
Worklog Time Spent: 10m
Work Description: lukaszlenart opened a new pull request, #1799:
URL: https://github.com/apache/struts/pull/1799
Fixes [WW-5539](https://issues.apache.org/jira/browse/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$Node`→`HashMap$TreeNode`
`ClassCastException` thrown from concurrent structural modification during
resize.
**`XWorkConverter`** — `getConverter()` 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.
**`DefaultActionValidatorManager`** — `getValidators()` 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 real `computeIfAbsent`, storing a sentinel for negative results so the
builder runs exactly once per class whether or not the class has 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.
## 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`.
## Testing
Full core module: 3014 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.
Issue Time Tracking
-------------------
Worklog Id: (was: 1031535)
Remaining Estimate: 0h
Time Spent: 10m
> Concurrency performance enhancements
> ------------------------------------
>
> Key: WW-5539
> URL: https://issues.apache.org/jira/browse/WW-5539
> Project: Struts 2
> Issue Type: Improvement
> Components: Core
> Affects Versions: 6.7.4, 7.0.3
> Reporter: Kusal Kithul-Godage
> Assignee: Lukasz Lenart
> Priority: Minor
> Fix For: 7.3.0
>
> Time Spent: 10m
> Remaining Estimate: 0h
>
> We've identified some code which can be made more performant by improving the
> locking mechanism in the following classes:
> * XWorkConverter
> * DefaultActionValidatorManager
> * StrutsTypeConverterHolder
--
This message was sent by Atlassian Jira
(v8.20.10#820010)