Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

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

オープン
#13,290 コメント 0 件 リアクション 0 件 担当者 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時間
マージ済み PR(30日)
249

環境構築

  • Dockerfile・Docker Compose ファイルなし
  • プルリクエストのテンプレートあり
  • コントリビューションガイドなし

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

dotnet/android のほかの issue

dotnet/android の issue をすべて見る

似ている issue

C# の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。