Skip to content

[Java.Interop] Avoid locks for cached JNI member lookups - #12377

Merged
jonathanpeppers merged 1 commit into
mainfrom
jonathanpeppers-cache-jni-method-ids
Aug 17, 2026
Merged

jonathanpeppers merged 1 commit into
mainfrom
jonathanpeppers-cache-jni-method-ids

Conversation

@jonathanpeppers

@jonathanpeppers jonathanpeppers commented Aug 13, 2026 •

Copy link
Copy Markdown
Member

Description

JniPeerMembers cached JNI method and field IDs in dictionaries protected by
monitor locks. Every generated binding invocation paid that lock cost even
after the JNI member had already been resolved.

Use ConcurrentDictionary for the instance/static method and field caches,
plus subclass constructor dispatch. Cached reads no longer enter a monitor,
while first field resolution remains serialized and existing method
replacement, fallback, exception, and lifetime behavior is preserved.

This also adds an instrumentation-only Android BenchmarkDotNet app for
measuring JNI invocation changes on-device.

Related to #11885.

Performance

Pixel 5, .NET 11 CoreCLR:

Path Before After Change
Cached virtual JNI invocation 221.4 ns 180.7 ns -18%

Pixel 5 MAUI/CoreCLR cold startup, 40 counterbalanced pairs built from
identical restored packages:

APK Mean startup
Base 835.62 ms
Changed 832.85 ms

Paired changed-minus-base delta: -2.77 ms, with a 95% confidence
interval of -7.61 to +2.06 ms.

Both APKs are exactly 19,278,879 bytes, so this change has no measurable
APK size impact. apkdiff reports a 1,952-byte decrease in uncompressed
shared-library payload, but that is generated-output/layout variation rather
than a size improvement attributable to this change: Java.Interop.dll grew
by 512 bytes while the app's R2R image shrank by 2,048 bytes, and ZIP
alignment/padding eliminates the internal difference in the final APK.

Testing

  • Java.Interop managed build

  • JniPeerMembersTests: 8 passed, 1 skipped

  • Android BenchmarkDotNet instrumentation run

  • 40-pair counterbalanced MAUI/CoreCLR cold-start comparison

  • apkdiff package comparison

  • Useful description of why the change is necessary

  • Links to related issues

  • Unit tests

Copilot AI lite review requested due to automatic review settings August 13, 2026 16:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR improves Java.Interop JNI member lookup performance by removing monitor-lock overhead from cached method/field ID access in JniPeerMembers, and adds an Android on-device BenchmarkDotNet instrumentation app to measure JNI invocation changes.

Changes:

  • Replace lock-protected Dictionary caches with ConcurrentDictionary caches for JNI instance/static method and field lookups.
  • Preserve serialized first-time field resolution while making cached reads lock-free.
  • Add a new instrumentation-only Android BenchmarkDotNet app and wire it into the test solution.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
Xamarin.Android-Tests.slnx Adds the new Android benchmarks project to the tests solution.
tests/Android.Benchmarks/README.md Documents how to run the on-device BenchmarkDotNet instrumentation benchmarks.
tests/Android.Benchmarks/JniMethodInfoBenchmarks.cs Adds benchmarks comparing cached JNI invocation vs generated binding calls.
tests/Android.Benchmarks/BenchmarkInstrumentation.cs Implements the Android instrumentation entry point that runs BenchmarkDotNet in-process.
tests/Android.Benchmarks/AndroidManifest.xml Provides a minimal manifest for the instrumentation-only benchmark app.
tests/Android.Benchmarks/Android.Benchmarks.csproj Defines the Android benchmark app project and BenchmarkDotNet dependency.
external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniPeerMembersTests.cs Updates test reflection expectations to match ConcurrentDictionary caches.
external/Java.Interop/src/Java.Interop/Java.Interop/JniPeerMembers.JniStaticMethods.cs Switches static method cache to ConcurrentDictionary and removes monitor locking on cached reads.
external/Java.Interop/src/Java.Interop/Java.Interop/JniPeerMembers.JniStaticFields.cs Switches static field cache to ConcurrentDictionary and adjusts the synchronization strategy.
external/Java.Interop/src/Java.Interop/Java.Interop/JniPeerMembers.JniInstanceMethods.cs Switches instance method + subclass-constructor caches to ConcurrentDictionary and removes monitor locking on cached reads.
external/Java.Interop/src/Java.Interop/Java.Interop/JniPeerMembers.JniInstanceFields.cs Switches instance field cache to ConcurrentDictionary and adjusts the synchronization strategy.

Comment thread tests/Android.Benchmarks/Android.Benchmarks.csproj
Use ConcurrentDictionary with static GetOrAdd factories for the
JniPeerMembers instance and static method and field caches, plus subclass
constructor dispatch. Cached JNI member lookups no longer enter monitor
locks, while existing remapping, fallback, exception, and lifetime behavior
is preserved.

Add an instrumentation-only Android BenchmarkDotNet app for measuring JNI
member lookup and invocation changes on-device.

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: 45264ff0-e493-4402-a76f-576afd88ee70
@jonathanpeppers
jonathanpeppers force-pushed the jonathanpeppers-cache-jni-method-ids branch from 66f4b4e to 5d91fc5 Compare August 13, 2026 16:37
@jonathanpeppers
jonathanpeppers merged commit 9b471e9 into main Aug 17, 2026
44 checks passed
@jonathanpeppers
jonathanpeppers deleted the jonathanpeppers-cache-jni-method-ids branch August 17, 2026 16:33
@jonathanpeppers
jonathanpeppers restored the jonathanpeppers-cache-jni-method-ids branch August 17, 2026 18:29
simonrozsival pushed a commit that referenced this pull request Aug 19, 2026
## Summary

- replace the monitor-protected `TypeManager` activation-constructor cache with a deliberately small `ConcurrentDictionary`
- use one stateful `GetOrAdd` call while preserving constructor trimming annotations without suppressions
- add an Android in-process BenchmarkDotNet benchmark for the exact cached `CreateProxy` path

Follow-up to #12377.

## Results

Measured on a Pixel 10 running Android 16 with CoreCLR:

| Check | Baseline | Changed | Difference |
| --- | ---: | ---: | ---: |
| Cached `CreateProxy` benchmark | 1,435.996 ns | 1,341.401 ns | -94.594 ns (-6.59%) |
| MAUI cold-start mean, 40 paired runs | 371.750 ms | 369.925 ms | -1.825 ms |
| Final APK size | 19,262,495 bytes | 19,262,495 bytes | 0 bytes |

The benchmark uses an unregistered JNI `jstring` global reference so each iteration exercises normal managed activation without duplicate-peer detection. The cold-start paired changed-minus-base 95% confidence interval is [-7.842, +4.192] ms, indicating no measurable startup regression.

`apkdiff` reports only a 32-byte uncompressed increase in `libassembly-store.so`; this does not change final APK size. Extracted `Mono.Android.dll` remains 1,913,344 bytes.

## Validation

- Release SDK build, with no new trim warnings
- `ReflectionCreateProxy` device fixture: 3 passed, 0 failed
- Android in-process BenchmarkDotNet run
- controlled MAUI/CoreCLR APK build with the baseline package lock
- 40 counterbalanced AB/BA cold-start pairs
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants