Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

[.NET 10 / .NET 11 RC2] Native JNI control blocks leak after peer finalization

未关闭
#13,290 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

@simonrozsival 已经在做这个了。

开始于 2026年10月28日。

  • #13292 来自 @simonrozsival —— 未关闭

评估

难度
4/5
预计耗时
3-5 天
新手友好度
35/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
android, csharp
领域
backend, mobile

调研方向

Read AndroidRuntime.cs around AndroidValueManager.FinalizePeer and JavaMarshalRegisteredPeers.cs around FinalizePeer, then compare their cleanup order with JavaObject.cs and JavaException.cs. Trace how Finalized() and reference clearing affect control block release, including the unregistered disposal path in JniRuntime.JniValueManager.cs. Done means invalid control blocks are released through finalization and unregistered disposal, while callback reattachment and exception paths remain safe.

由索引模型根据 Issue 内容生成。

描述

Area: App Runtime needs-triage
Android framework version

net10.0-android with Mono, and net11.0-android with CoreCLR (.NET 11 RC2).

Affected platform version
Component .NET 10 .NET 11 RC2
SDK 10.0.402 11.0.100-rc.2.26504.105
Android pack 36.1.118 37.2.0-rc.2.84
Runtime Mono 10.0.13 CoreCLR 11.0.0-rc.2.26504.105
Mono.Android 36.1.1.117, db080b91dbe7947a69005a203ea98d9bb207ad04 37.2.0.84, 801f9ecbab5118b13d21cb52409e507ad4b2feb7
Java.Interop 10.0.0.0, 8e8291c9ef45b4bbc28b09456dc5888eb80af77b 11.0.0.0, 801f9ecbab5118b13d21cb52409e507ad4b2feb7
Device Android 15 ARM64 emulator Android 15 ARM64 emulator

Source analysis uses release/10.0.1xx at 49516ae50dffb0421b7faf5f60f9df9cc220743d and the installed RC2 framework's release/11.0.1xx-rc2 revision 801f9ecbab5118b13d21cb52409e507ad4b2feb7. Loaded assembly identities and runtime versions were checked in the app. NativeAOT has not been tested; the .NET 11 RC2 SDK rejects Android builds with UseMonoRuntime=true with NETSDK1242.

Description

A plain .NET for Android application that repeatedly creates temporary Android.Graphics.Rect wrappers accumulates native allocated memory after collection and completed finalization. Managed and Java live memory remain approximately stable, and JNI global-reference counts do not increase.

The retained allocation is the JniObjectReferenceControlBlock associated with each wrapper. Its ARM64 payload is 32 bytes in the tested .NET 10 build and 16 bytes in .NET 11 RC2. Separate allocate/free calibrations measured allocator-accounted totals of approximately 48 and 32 bytes per allocation, respectively.

Expected: Once a peer is finalized and its JNI reference is invalid, its native control block is released.

Actual: Finalization completes with an invalid JNI reference and the disposed-state flag set, but the control block remains allocated. On both framework versions this was also observed with naturally finalized Java.Lang.Object and Java.Lang.Throwable subclasses, covering the JavaObject and JavaException implementations.

Cleanup-order defect

In AndroidValueManager.FinalizePeer, the invalid-reference branch executes:

RemovePeer (value, (IntPtr) value.JniIdentityHashCode);
value.SetPeerReference (new JniObjectReference ());
value.Finalized ();

However, JavaObject.SetPeerReference only frees the control block when the supplied reference is invalid and Disposed is already set. Finalized() sets that flag afterward, without retrying the release. JavaException has the same behavior.

In .NET 11 RC2, the default CoreCLR JavaMarshalValueManager delegates to JavaMarshalRegisteredPeers.FinalizePeer. Both branches clear the reference before calling Finalized(), while the RC2 JavaObject implementation still requires the disposed-state flag before freeing the block.

Diagnostic observations after natural finalization, reproduced on both versions:

  • The finalization callback ran, the peer reference was invalid, and the disposed-state flag was set, but the control-block pointer was nonzero.
  • Calling Dispose() afterward did not release it because disposal returns early for an invalid reference.
  • Clearing the reference again released the block.

History points to the native control-block introduction, which moved previously GC-owned peer fields into a separately allocated native block.

Diagnostic A/B and fix constraints

A private .NET 10 diagnostic APK moved Finalized() before SetPeerReference(default). This removed the allocation-proportional native growth. Comparison of the packaged managed method bodies confirmed that only AndroidValueManager.FinalizePeer changed; the Java.Interop binary was identical. The RC2 tests used the installed framework binaries without this experimental change.

The reorder is not a complete fix: a Dispose(false) callback can reconstruct a valid peer reference. The existing sequence preserves that reference, while the reordered unconditional clear erases it. This was reproduced for both object and exception wrappers.

A correction should release still-invalid control blocks at lifecycle completion, including exception paths, while preserving callback semantics and reattached references.

Related unregistered-disposal path

The following sequence also retains the control block:

var rectangle = new Android.Graphics.Rect (1, 2, 3, 4);
rectangle.UnregisterFromRuntime ();
rectangle.DisposeUnlessReferenced ();

DisposePeerUnlessReferenced reaches the private disposal helper without setting Disposed. That helper clears the reference and suppresses finalization, leaving no later cleanup opportunity. This path was reproduced on both framework versions and remains leaky in the .NET 10 finalizer-reorder experiment.

Steps to Reproduce
  1. Create a plain net10.0-android or net11.0-android app using the corresponding versions above. No MAUI or additional application dependencies are needed. For RC2, use the default CoreCLR runtime and set SupportedOSPlatformVersion to at least 24.

  2. For the exact .NET 10 runtime match, add this item to the project. Omit it for .NET 11 RC2:

    <ItemGroup>
      <FrameworkReference Update="Microsoft.NETCore.App" RuntimeFrameworkVersion="10.0.13" />
    </ItemGroup>
    
  3. Add the helper below. Run await Task.Run (() => PeerCleanupRepro.RunAsync (dispose: false)); from the activity, keeping the app visible and the device awake.

  4. Restart the process and repeat with dispose: true.

  5. Compare Android.OS.Debug.NativeHeapAllocatedSize at batches 5 and 20, rather than PSS/RSS. Forced collections here are diagnostic, not a production workaround.

using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Java.Interop;

static class PeerCleanupRepro {
	public static async Task RunAsync (bool dispose)
	{
		await Task.Delay (2_000);
		AllocateBatch (2_000, dispose);
		await DrainAsync ();
		Sample (0);

		for (int batch = 1; batch <= 20; batch++) {
			AllocateBatch (5_000, dispose);
			await DrainAsync ();
			Sample (batch);
		}
	}

	[MethodImpl (MethodImplOptions.NoInlining)]
	static void AllocateBatch (int count, bool dispose)
	{
		for (int i = 0; i < count; i++) {
			var rectangle = new Android.Graphics.Rect (i, 0, i + 1, 1);
			if (dispose)
				rectangle.Dispose ();
			GC.KeepAlive (rectangle);
		}
	}

	static async Task DrainAsync ()
	{
		for (int pass = 0; pass < 3; pass++) {
			GC.Collect ();
			GC.WaitForPendingFinalizers ();
			JniEnvironment.Runtime.ValueManager.WaitForGCBridgeProcessing ();
			GC.Collect ();
			GC.WaitForPendingFinalizers ();
			await Task.Delay (150);
		}
	}

	static void Sample (int batch)
	{
		Android.Util.Log.Info ("PeerCleanupRepro",
			$"batch={batch} native={Android.OS.Debug.NativeHeapAllocatedSize} " +
			$"managed={GC.GetTotalMemory (false)} " +
			$"grefs={JniEnvironment.Runtime.GlobalReferenceCount}");
	}
}
Did you find any workaround?

Explicit Dispose() avoids the natural-finalization leak for wrappers with known ownership and lifetime. It is not a general workaround for temporary wrappers created inside bindings or framework code.

Relevant log output

Measured native allocated deltas between batches 5 and 20, representing 75,000 additional wrappers in the allocating scenarios:

.NET 10 / Mono

Scenario Stock framework Diagnostic finalizer reorder
Natural finalization +3,452,528 bytes +1,472 bytes
Explicit Dispose() +132,208 bytes +12,880 bytes
No-allocation control, same checkpoints +140,656 bytes +6,704 bytes
Unregister, then DisposeUnlessReferenced() +3,719,360 bytes +3,609,728 bytes

Three stock runs reproduced the finalization growth. In the matched stock finalization run, managed live memory changed by +17,968 bytes, Java live memory by +5,184 bytes, and JNI global-reference counts remained constant. The controls show some native measurement/background-allocation noise, but not the allocation-proportional growth of the two leaking paths.

.NET 11 RC2 / CoreCLR

Two complete runs used the same allocation/drain harness and the installed RC2 framework binaries:

Scenario Run 1 native delta Run 2 native delta
Natural finalization +1,482,320 bytes +1,294,480 bytes
Explicit Dispose() -1,844,096 bytes -1,829,088 bytes
No-allocation control, same checkpoints -249,920 bytes -115,408 bytes
Unregister, then DisposeUnlessReferenced() +568,160 bytes +562,416 bytes

The whole-process native counter has large downward jumps, including in the controls, so these endpoint deltas are not exact leaked-payload totals. The median adjacent-batch increases in the two leaking modes were approximately 32.5-32.6 native bytes per additional wrapper, compared with less than 1 byte per wrapper for explicit disposal.

More decisively, both RC2 runs reproduced the pointer-level diagnostics above: naturally finalized object and exception wrappers retained their 16-byte control blocks; Dispose() did not release them; clearing the reference again did. The unregistered-disposal path also retained its block with the disposed-state flag unset. Java live memory and JNI reference counts were unchanged between batches 5 and 20.

主要语言
C#
星标
2.1k
派生
579
平均合并
1 天 16 小时
30 天内合并 PR
253

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 有 Pull Request 模板
  • 没有贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

dotnet/android 的其他 Issue

查看 dotnet/android 的全部 Issue

相似的 Issue

更多 C# Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。