[Java.Interop] Lazily allocate peer member caches - #12701
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The new concurrency test should ensure JniPeerMembers.Dispose(members) runs via try/finally to avoid leaking state when assertions fail.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Lite
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniPeerMembersTests.cs — members is disposed only at the end of the test, so any assertion… |
What changed in this PR
This PR updates Java.Interop’s JniPeerMembers.JniInstanceMethods to lazily allocate the per-type instance method cache on first use, using Volatile.Read + Interlocked.CompareExchange for safe publication, and adds unit test coverage for deferred allocation and concurrent first access.
Changes:
- Make
JniInstanceMethods.InstanceMethodslazily allocated instead of eagerly created. - Publish the cache safely using
Volatile.ReadandInterlocked.CompareExchange, and detach it atomically during disposal. - Add tests verifying deferred allocation and concurrent first-use behavior.
| File | Description |
|---|---|
| external/Java.Interop/src/Java.Interop/Java.Interop/JniPeerMembers.JniInstanceMethods.cs | Lazily allocates and safely publishes the instance-method cache; atomically detaches it during disposal. |
| external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniPeerMembersTests.cs | Updates existing expectations for lazy allocation and adds a concurrent first-use test. |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
/review |
|
✅ Android PR Reviewer completed successfully!
|
There was a problem hiding this comment.
✅ LGTM
No blocking correctness, safety, interop, or performance issues found. The lock-free publication preserves the existing ordinal key semantics, disposal behavior remains intentionally unsupported under concurrent access, and all 44 reported checks are green.
Findings: 0 errors · 0 warnings · 1 suggestion
The allocation measurements and focused cache coverage are strong. I left one inline suggestion to make the concurrency regression test deterministic.
Generated by Android PR Reviewer for #12701 · gpt56 · 128.9 AIC · ⌖ 19 AIC · ⊞ 25.7K
Comment /review to run again
| var constructors = new JniMethodInfo [16]; | ||
|
|
||
| Assert.IsNull (GetInstanceMethods (methods)); | ||
| Parallel.For (0, constructors.Length, i => constructors [i] = methods.GetConstructor ("()V")); |
There was a problem hiding this comment.
🤖 💡 Testing — Please synchronize the workers before they call GetConstructor() (for example with a Barrier) so they are guaranteed to contend on the initial null cache. Parallel.For may execute these short iterations serially or only after the first iteration has already published the dictionary, allowing a broken non-atomic initializer to pass nondeterministically.
Rule: Deterministic concurrency coverage

Summary
GetOrCreate<TKey, TValue>()helperResults
A Release trimmed CoreCLR arm64 app created from
dotnet new maui --sample-contentwas measured through first window focus on a Samsung A16.Current
maineagerly allocates approximately 1,347 peer cache dictionaries (~274 KiB at the measured 208 B per empty dictionary). With this change, first launch allocated 273 dictionaries (~55 KiB), avoiding about 1,074 dictionary allocations / 218 KiB gross managed allocation.Repeated process-cold launches allocated 271–273 dictionaries (average 272):
main(eager)The eager counts differ by one because one managed-subclass constructor path creates an additional
JniInstanceMethodshelper; only primaryJniPeerMembersinstances own the field/static caches.The earlier instance-method-only version avoided about 67 dictionaries (~14 KiB); most savings come from deferring the other four caches.
Startup timing
A final all-cache A/B used 64 paired process-cold launches in four package-ID crossover blocks on the Samsung A16 (Release trimmed CoreCLR arm64, speed-compiled packages, animations disabled, alternating order, 27.6–28.0 °C):
main(47eabaf)The paired delta was −11.55 ms (−0.52%), favoring this PR, but was not statistically significant: 95% t interval [−28.06, +4.97] ms, bootstrap interval [−27.98, +4.16] ms, with 36/64 wins and one tie.
The honest conclusion is no measurable startup regression or improvement. Reduced managed allocation and retained heap are the demonstrated benefits.
Concurrent
Dispose()and cache access remains unsupported, as before.Interlocked.Exchange()atomically detaches each cache visible to disposal but is not a disposed-state guard for stale references.Retained startup heap
Two startup GC dumps per revision were collected after first window focus from the same Release trimmed CoreCLR arm64
dotnet new maui --sample-contentapp on Samsung A16.dotnet-gcdump collectinduced a Gen2 GC, so values are live retained objects.The targeted cache dictionary counts were 1,141/1,126 on current main versus 276/274 with this PR, avoiding 865/852 retained dictionaries. The entire retained
ConcurrentDictionarydelta is exactly these Java.Interop caches.Useful cache contents were unchanged:
JniMethodInfocounts were 818/815 for both revisions,JniFieldInfowas 36 for both, and method, field, and subclass node counts were unchanged. The dumps show removal of empty containers only.Tests
JniPeerMembersTests: 12 passed, 1 skippedJava.Interop-Tests: 711 passed, 6 skipped, 0 failed