Skip to content

ICU-23321 ResourceCache: replace synchronized trie with ConcurrentHashMap#3900

Merged
markusicu merged 1 commit intounicode-org:mainfrom
jabagawee:ICU-23321-resource-cache
Mar 20, 2026
Merged

ICU-23321 ResourceCache: replace synchronized trie with ConcurrentHashMap#3900
markusicu merged 1 commit intounicode-org:mainfrom
jabagawee:ICU-23321-resource-cache

Conversation

@jabagawee
Copy link
Copy Markdown
Contributor

@jabagawee jabagawee commented Mar 13, 2026

Replace the custom synchronized trie in ICUResourceBundleReader.ResourceCache with ConcurrentHashMap, eliminating a heavily contended lock on the resource loading path. This is split out from the batch 2 PR (forthcoming) because it replaces a data structure rather than just changing a synchronization idiom, and warrants separate review.

Background

ResourceCache.get() is synchronized and is called on every resource bundle access — getString(), getStringV2(), getAlias(), getArray(), getTable(). On a multi-threaded server, this monitor serializes all resource lookups across all threads.

The custom trie was introduced in ICU 54.1 (June 2014, ICU-10932, b3a6ea1) as a memory optimization for Android. It uses int[]/Object[] arrays to avoid Integer autoboxing and HashMap.Node per-entry overhead. The JIRA discussion focuses on memory optimization; the choice of synchronized vs alternatives is not discussed.

Why the trie loses on memory

The trie's per-entry savings (no autoboxing, no Node objects) are real but small. What kills it is sparse power-of-2 array allocations. Measured via reflection across 44 loaded caches (avg 221 entries each):

Metric Trie ConcurrentHashMap
Memory per cache (221 entries) 39 KB 13 KB
Slot utilization 5.2% ~75%

The trie allocates a 128-slot root and ~65 child Levels of 64 slots each, but only 221 slots contain data. 94.8% of allocated slots are empty.

Benchmark results

Full-stack throughput (NumberFormat.getInstance(), which exercises ULocale.getDefaultSimpleCacheResourceCache):

Threads synchronized trie ConcurrentHashMap
1 543 ops/ms 505 ops/ms
32 2,074 ops/ms 4,051 ops/ms

ReadWriteLock was also benchmarked as a middle-ground alternative. It underperforms both — readLock() still does a CAS on a shared state word, making read-read contention on that counter the new bottleneck.

Benchmark code: https://gist.github.com/jabagawee/54710d21ca98c1e6a6d307b5229bbd21

Known tradeoff: autoboxing

ConcurrentHashMap<Integer, Object> autoboxes int keys on every get(). Resource offsets are typically outside the Integer cache range (-128..127), so each lookup allocates a short-lived Integer. Accepted because:

  1. Lock contention savings dominate (2x throughput at 32 threads)
  2. JIT can optimize escape-analyzed Integer allocations
  3. A primitive-keyed map (e.g., Eclipse Collections IntObjectHashMap) would require a new dependency
  4. The old trie's memory waste (39 KB vs 13 KB) was worse than the autoboxing overhead

Changes

  • Replace ResourceCache internals with ConcurrentHashMap<Integer, Object>
  • get() becomes lock-free
  • putIfAbsent() uses CHM's compute() to atomically handle cleared SoftReferences (plain putIfAbsent() cannot replace a non-null but dead SoftReference entry)
  • Remove synchronized from both methods
  • Remove the Level/keys/values trie structure (~170 lines deleted)
  • Add ResourceCacheConcurrencyTest — concurrent resource bundle lookups from 16 threads, gated on -DICU.exhaustive=5

References

  • Java Concurrency in Practice (Goetz et al.), Section 5.2.1 "ConcurrentHashMap"
  • Effective Java (Bloch, 3rd ed.), Item 81 "Prefer concurrency utilities to wait and notify"

Checklist

  • Required: Issue filed: ICU-23321
  • Required: The PR title must be prefixed with a JIRA Issue number. Example: "ICU-NNNNN Fix xyz"
  • Required: Each commit message must be prefixed with a JIRA Issue number. Example: "ICU-NNNNN Fix xyz"
  • Issue accepted (done by Technical Committee after discussion)
  • Tests included, if applicable
  • API docs and/or User Guide docs changed or added, if applicable
  • Approver: Feel free to merge on my behalf

@markusicu
Copy link
Copy Markdown
Member

That guy who wrote b3a6ea1 thought he made some clever changes... :-/

@jabagawee
Copy link
Copy Markdown
Contributor Author

Yeah I anticipated that feedback, which is why I split it out into its own PR. I'm willing to keep the original implementation?

@jabagawee
Copy link
Copy Markdown
Contributor Author

I can still make some improvements to the lock contention if we stick to the trie data structure, so I'd just revive another branch of mine that tinkered with the existing code without changing the implementation wholesale.

@markusicu
Copy link
Copy Markdown
Member

Yeah I anticipated that feedback, which is why I split it out into its own PR. I'm willing to keep the original implementation?

Sorry for my confusing comment. You clearly understand Java multi-threaded performance better than me (that guy...), and have done much more thorough testing. I will review this PR, and I have asked the team for a second pair of eyes.

Copy link
Copy Markdown
Member

@markusicu markusicu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks very good to me!

PR description:

putIfAbsent() uses CHM's atomic putIfAbsent()

... but it's using CHM.compute(). Please fix.

Anyone else want to chime in? @mihnita @gvictor @vladcioricagoogle ?

@jabagawee jabagawee requested a review from markusicu March 18, 2026 21:03
@markusicu
Copy link
Copy Markdown
Member

lgtm, tnx!

let's wait a day or so to give others a chance to chime in.

@mihnita mihnita self-requested a review March 19, 2026 15:45
Copy link
Copy Markdown
Contributor

@mihnita mihnita left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Copy link
Copy Markdown
Member

@markusicu markusicu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do it.
@jabagawee do you want to do the squashing honors?

…hMap

Replace the custom synchronized trie in ICUResourceBundleReader.ResourceCache
with ConcurrentHashMap, eliminating a heavily contended lock on the resource
loading path.

ResourceCache.get() was synchronized and called on every resource bundle
access (getString, getAlias, getArray, getTable). The custom trie (ICU-10932,
2014) optimized memory via int[]/Object[] arrays to avoid Integer autoboxing,
but its sparse power-of-2 allocations waste 94.8% of slots (39KB vs 13KB per
cache with ConcurrentHashMap).

Benchmark (NumberFormat.getInstance full stack):
  1 thread:  543 -> 505 ops/ms (slight regression from autoboxing)
  32 threads: 2,074 -> 4,051 ops/ms (2x improvement from lock elimination)

Uses ConcurrentHashMap.compute() in the put path to atomically handle cleared
SoftReferences (plain putIfAbsent cannot replace a non-null but dead entry).
@jabagawee jabagawee force-pushed the ICU-23321-resource-cache branch from cdd139a to cf94873 Compare March 20, 2026 15:28
@jira-pull-request-webhook
Copy link
Copy Markdown

Hooray! The files in the branch are the same across the force-push. 😃

~ Your Friendly Jira-GitHub PR Checker Bot

@jabagawee
Copy link
Copy Markdown
Contributor Author

done!

@markusicu markusicu merged commit daa3f49 into unicode-org:main Mar 20, 2026
17 checks passed
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.

3 participants