You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@struts.apache.org by "ASF GitHub Bot (Jira)" <ji...@apache.org> on 2026/07/21 18:57:00 UTC

[jira] [Work logged] (WW-5539) Concurrency performance enhancements

     [ 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)