Skip to content

[Java.Interop] Defer exception creation in TryLoadClassWithFallback to fix global ref leak#1410

Merged
jonathanpeppers merged 2 commits intomainfrom
fix-checkjni-findclass-abort
Apr 21, 2026
Merged

[Java.Interop] Defer exception creation in TryLoadClassWithFallback to fix global ref leak#1410
jonathanpeppers merged 2 commits intomainfrom
fix-checkjni-findclass-abort

Conversation

@simonrozsival
Copy link
Copy Markdown
Member

@simonrozsival simonrozsival commented Apr 21, 2026

Summary

Fixes the JNI global reference leak introduced by #1407 by deferring managed exception creation in TryLoadClassWithFallback.

Root Cause

PR #1407 added a Class.forName() fallback to the UTF-8 FindClass path, sharing TryLoadClassWithFallback between the string and ReadOnlySpan<byte> overloads.

The problem: TryLoadClassWithFallback eagerly calls GetExceptionForThrowable on the FindClass failure throwable before trying Class.forName(). This creates a full JavaException managed object which:

  1. Promotes the JNI local ref to a global ref (via ConstructPeer)
  2. Calls getMessage(), getCause() (recursively creating more JavaExceptions with their own global refs), and getStackTrace()
  3. All of this happens even when Class.forName() succeeds immediately after, or when throwOnError=false

Before #1407, the UTF-8 path never entered this code, so no managed exceptions were created for failed FindClass calls. After #1407, every UTF-8 class lookup failure eagerly creates (and disposes) managed exceptions with global refs — causing the global ref churn and OOM observed in Debug mode on Android.

Fix

Defer managed exception creation: try Class.forName() first using only raw JNI operations, and only materialize the managed exception if we actually need to throw.

Code path Before (old) After (new)
Class.forName() succeeds Create JavaException + global ref, then Dispose() Delete local ref only — no managed exception, no global ref
throwOnError=false (both fail) Create JavaException + global ref, then Dispose() Delete local ref only — no managed exception, no global ref
throwOnError=true (both fail) Create JavaException + throw Same — create JavaException + throw

Testing

  • All 667 Java.Interop-Tests pass, including all 16 UTF-8 FindClass tests added in Fix UTF-8 JniType class lookup fallback #1407.
  • Added two new regression tests (TryFindClass_Utf8_DoesNotLeakGlobalRefs, TryFindClass_String_DoesNotLeakGlobalRefs) that assert GlobalReferenceCount is unchanged after a TryFindClass call for a non-existent class.

Copilot AI review requested due to automatic review settings April 21, 2026 07:11
Copy link
Copy Markdown

Copilot AI left a comment

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 updates JniEnvironment.Types class lookup logic to avoid creating managed JavaException instances (and thus JNI global refs) unless an exception must actually be thrown, addressing a global reference leak/churn scenario in the UTF-8 FindClass fallback path.

Changes:

  • Defer managed exception materialization in TryLoadClassWithFallback until throwOnError=true and all fallback attempts fail.
  • Convert dot-separated class names to JNI slash form before calling raw FindClass (string and UTF-8 overload paths).
  • Refactor UTF-8 TryFindClass to share a pointer-based helper and perform dot-to-slash conversion with stack/heap buffer as needed.

Comment thread src/Java.Interop/Java.Interop/JniEnvironment.Types.cs
…o avoid unnecessary global refs

Previously, TryLoadClassWithFallback eagerly called GetExceptionForThrowable
before trying Class.forName(), creating a managed JavaException (with a JNI
global ref, getMessage(), getCause(), getStackTrace()) even when Class.forName()
would succeed immediately after. This was especially costly on the UTF-8
FindClass path introduced in PR #1407, which now falls back through
Class.forName() where it previously did not.

The fix defers managed exception creation: try Class.forName() first using only
raw JNI operations, and only materialize the managed exception if we actually
need to throw. In the common case (Class.forName succeeds or throwOnError=false),
no managed exception is created and no global ref is allocated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival force-pushed the fix-checkjni-findclass-abort branch from 7c16994 to 40dacc5 Compare April 21, 2026 07:25
…refs

Adds regression tests for both UTF-8 and string TryFindClass overloads,
verifying that repeated lookups of non-existent classes do not leak JNI
global references. These tests exercise the TryLoadClassWithFallback code
path (throwOnError=false) that was the source of the leak.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers jonathanpeppers merged commit 69c9daa into main Apr 21, 2026
2 checks passed
@jonathanpeppers jonathanpeppers deleted the fix-checkjni-findclass-abort branch April 21, 2026 13:43
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