From 3cd61c7475db7868e2d2d29e6ffdff8a2bb0868b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 21 Apr 2026 08:36:08 +0200 Subject: [PATCH 1/2] [Java.Interop] Defer exception creation in TryLoadClassWithFallback to 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> --- .../Java.Interop/JniEnvironment.Types.cs | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs index 1f29d21cd..0f6f4eac1 100644 --- a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs +++ b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs @@ -114,38 +114,33 @@ static unsafe bool TryLoadClassWithFallback (JniEnvironmentInfo info, IntPtr thr { result = default; - var findClassThrown = new JniObjectReference (thrown, JniObjectReferenceType.Local); - LogCreateLocalRef (findClassThrown); - Exception? pendingException = info.Runtime.GetExceptionForThrowable (ref findClassThrown, JniObjectReferenceOptions.CopyAndDispose); - if (Class_forName.IsValid) { var __args = stackalloc JniArgumentValue [3]; __args [0] = new JniArgumentValue (classNameJavaString); __args [1] = new JniArgumentValue (true); // initialize the class __args [2] = new JniArgumentValue (info.Runtime.ClassLoader); - var c = RawCallStaticObjectMethodA (info.EnvironmentPointer, out thrown, Class_reference.Handle, Class_forName.ID, (IntPtr) __args); - if (thrown == IntPtr.Zero) { - (pendingException as IJavaPeerable)?.Dispose (); + var c = RawCallStaticObjectMethodA (info.EnvironmentPointer, out var forNameThrown, Class_reference.Handle, Class_forName.ID, (IntPtr) __args); + if (forNameThrown == IntPtr.Zero) { + // Class.forName() succeeded; discard the FindClass throwable. + JniEnvironment.References.RawDeleteLocalRef (info.EnvironmentPointer, thrown); result = new JniObjectReference (c, JniObjectReferenceType.Local); JniEnvironment.LogCreateLocalRef (result); return true; } RawExceptionClear (info.EnvironmentPointer); - - if (pendingException != null) { - JniEnvironment.References.RawDeleteLocalRef (info.EnvironmentPointer, thrown); - } else { - var loadClassThrown = new JniObjectReference (thrown, JniObjectReferenceType.Local); - LogCreateLocalRef (loadClassThrown); - pendingException = info.Runtime.GetExceptionForThrowable (ref loadClassThrown, JniObjectReferenceOptions.CopyAndDispose); - } + JniEnvironment.References.RawDeleteLocalRef (info.EnvironmentPointer, forNameThrown); } if (!throwOnError) { - (pendingException as IJavaPeerable)?.Dispose (); + JniEnvironment.References.RawDeleteLocalRef (info.EnvironmentPointer, thrown); return false; } + + // Both FindClass and Class.forName() failed; materialize a managed exception to throw. + var findClassThrown = new JniObjectReference (thrown, JniObjectReferenceType.Local); + LogCreateLocalRef (findClassThrown); + Exception? pendingException = info.Runtime.GetExceptionForThrowable (ref findClassThrown, JniObjectReferenceOptions.CopyAndDispose); if (pendingException != null) throw pendingException; From ebbdceeae6a6129530fd163d2018c809d60e302d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 21 Apr 2026 09:24:14 +0200 Subject: [PATCH 2/2] [Java.Interop] Add tests verifying TryFindClass does not leak global 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> --- .../Java.Interop/JniTypeUtf8Test.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs b/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs index ca461f7b0..c5e0fc0d6 100644 --- a/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs +++ b/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs @@ -90,6 +90,26 @@ public void TryFindClass_Utf8 () Assert.IsFalse (notFound.IsValid); } + [Test] + public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () + { + int grefsBefore = JniEnvironment.Runtime.GlobalReferenceCount; + JniEnvironment.Types.TryFindClass ("does/not/Exist"u8, out _); + int grefsAfter = JniEnvironment.Runtime.GlobalReferenceCount; + Assert.AreEqual (grefsBefore, grefsAfter, + "TryFindClass for non-existent classes should not leak global references"); + } + + [Test] + public void TryFindClass_String_DoesNotLeakGlobalRefs () + { + int grefsBefore = JniEnvironment.Runtime.GlobalReferenceCount; + JniEnvironment.Types.TryFindClass ("does/not/Exist", out _); + int grefsAfter = JniEnvironment.Runtime.GlobalReferenceCount; + Assert.AreEqual (grefsBefore, grefsAfter, + "TryFindClass for non-existent classes should not leak global references"); + } + [Test] public void GetMethodID_Utf8_MatchesStringOverload () {