Skip to content

[Critical Android Bug] Android views leak via JNI Global Reference even after DisconnectHandlers() and explicit peer Dispose() #12516

Description

@jozefkosecky

Description

Description

When repeatedly displaying and destroying a ContentView that contains custom View<T> handlers (specifically a ViewHandler<TVirtual, TPlatform> whose TPlatform is a custom LinearLayout subclass), the native Android View instances are never garbage-collected, even after:

  1. Recursive MauiView.DisconnectHandlers() from the root
  2. Explicit Java.Lang.Object.Dispose() on the platform view (deferred via Handler.MainLooper.PostDelayed to avoid layout-pass race conditions)
  3. View.setBackground(null) to break Drawable callback cycles
  4. Multiple GC.Collect() + GC.WaitForPendingFinalizers() cycles
  5. ART full GC via adb shell am send-trim-memory <pkg> COMPLETE

Eclipse MAT analysis of the heap dump shows that each leaked native view has GC root: JNI Global, indicating the Mono runtime peer is never released — even when Dispose() was demonstrably called (verified via diagnostic Interlocked counters: scheduled == completed).

Reproduction steps

  1. Create a ViewHandler<TElement, CustomLinearLayout> where CustomLinearLayout : LinearLayout is a custom Java type registered via [Register].
  2. In ConnectHandler, inflate the platform view and obtain inner views via _root.GetChildAt(i) as ImageView.
  3. Open a ContentView page that creates ~15-20 instances of this handler in a Grid.
  4. Close the page (via standard MAUI navigation), call view.DisconnectHandlers() recursively, run GC.Collect().
  5. Repeat 10×.
  6. Force a full ART GC: adb shell am send-trim-memory <pkg> COMPLETE.
  7. Take a heap dump: adb shell am dumpheap <pkg> /data/local/tmp/heap.hprof.

Expected behavior

After DisconnectHandlers() + GC + Force ART GC, the native Java View instances should be eligible for collection. dumpsys meminfo Views count should return to baseline.

Actual behavior

After 10 cycles + Force GC:

  • dumpsys meminfo Views: 1098 (baseline ~200, so ~900 zombie native Views)
  • Heap dump shows ~170 zombie CustomLinearLayout instances with JNI Global GC root path
  • Each zombie holds ~5-10 child views (MaterialTextView, AppCompatImageView, etc.) via mChildren
  • Per-cycle leak: ~86 native Views, +1.5 MB PSS

Diagnostic evidence

Live count of managed objects (Interlocked counters in ctor/finalizer)

After 10 cycles:

  • Form (ContentView): live oscillates 1↔2 ✓ correctly GC'd
  • EditableForm (ContentView): live oscillates 2↔4 ✓
  • Custom toolbar ContentView: live oscillates 0↔1 ✓
  • All managed C# objects ARE finalized correctly.

Eclipse MAT — Path to GC Roots (excluding weak/soft/phantom refs) for one zombie CustomLinearLayout:

CustomLinearLayout @ 0x12d502e8 GC root: JNI Global
↓ mChildren View[]
└ MaterialTextView @ 0x...
└ MaterialTextView @ 0x...
└ AppCompatImageView @ 0x...

The only GC root path is JNI Global — meaning Mono runtime holds a global JNI reference that was never released.

Histogram of leaked instances after 10 cycles + Force GC

ClassInstance count
android.graphics.RenderNode1414
android.view.ViewAnimationHostBridge1414
androidx.appcompat.widget.AppCompatBackgroundHelper626
androidx.appcompat.widget.AppCompatTextHelper455
androidx.emoji2.viewsintegration.EmojiTextViewHelper (5 variants)454 each
com.google.android.material.textview.MaterialTextView342
crc6431bef388e7f7f9bd.CustomLinearLayout (custom platform view)171
mono.android.view.View_OnFocusChangeListenerImplementor280

Diagnostic counter proof that Dispose() was called

FSK - EnhancedBtn dispose scheduled, total=156
...
FSK - EnhancedBtn dispose completed, total=156

156 platform-view peers explicitly called Java.Lang.Object.Dispose(), yet 171 of them remain in the heap with JNI Global GC root.

Workarounds attempted (none reduced leak)

AttemptResult
view.DisconnectHandlers() recursively from rootNo effect on count
_root.Background = null (break Drawable.callback cycle)No effect
_image.SetImageDrawable(null)No effect
_root.OnFocusChangeListener = nullNo effect
_root.Dispose() synchronously in DisconnectHandlerCrashes with JNI ERROR (deleted reference) race condition with pending layout pass
_root.Dispose() via View.PostDelayed(200ms)Sometimes never executes (runnable cancelled when view detached from window)
_root.Dispose() via Handler(Looper.MainLooper).PostDelayed(200ms)Confirmed runs (verified via counter) — but zombie count unchanged
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); after disconnectNo effect

The fact that Dispose() runs successfully but the JNI Global Ref persists suggests Mono.Android.ManagedValueManager (or similar internal cache) holds a strong reference to the Mono peer outside the public Dispose() contract.

Environment

  • .NET version: 10.0
  • Microsoft.Maui.Controls: 10.0.11
  • TargetFramework: net10.0-android
  • Android API: 34 (emulator + physical Pixel 6 Pro)
  • SupportedOSPlatformVersion: 24.0

Heap dump

heap.hprof is attached (51 MB). Filter MAT histogram for crc6431bef388e7f7f9bd.CustomLinearLayout to see the leaked custom platform view; right-click → Path to GC Roots → exclude weak/soft/phantom shows JNI Global as the only GC root.

Impact

  • Memory grows ~1.5 MB per page open/close cycle in our app.
  • After ~100 cycles the app reaches risky memory pressure on low-end devices.
  • Occasional JNI ERROR (deleted reference) crashes occur during teardown when the JNI table is heavily fragmented.

How to capture the diagnostic files (for reproduction)

memory_watch.txt — per-second dumpsys meminfo

Run this PowerShell loop in parallel with the app:

$package="com.package.xyz"# replace with your package$out="$env:USERPROFILE\Desktop\memory_watch.txt"Remove-Item$out-ErrorAction SilentlyContinue
while ($true) {
"===================="|Tee-Object$out-Append
Get-Date|Tee-Object$out-Append
adb shell dumpsys meminfo $package|Select-String"TOTAL PSS|TOTAL RSS|Native Heap|Dalvik Heap|Java Heap|Graphics|Views|Activities|AppContexts|Objects"|ForEach-Object { $_.Line } |Tee-Object$out-Append
Start-Sleep-Seconds 1
}

Stop the loop (Ctrl+C) once the 10-cycle reproduction is complete.

heap.hprof — Java heap dump after Force ART GC

$package="com.package.xyz"# replace with your package# Step 1: send the app to background so we can request COMPLETE-level trim
adb shell input keyevent KEYCODE_HOME
Start-Sleep-Seconds 2# Step 2: force a full ART GC (frees everything reachable only via dead refs)
adb shell am send-trim-memory $package COMPLETE
Start-Sleep-Seconds 5# Step 3: dump the Java heap and pull it locally
adb shell am dumpheap $package/data/local/tmp/heap.hprof
adb pull /data/local/tmp/heap.hprof
# Optional: confirm Views count after Force GC
adb shell dumpsys meminfo $package|Select-String"Views:"

The dump captured this way reflects the post-GC steady state — anything still in the heap is genuinely retained, not waiting for collection. Open heap.hprof in Eclipse MAT (older versions may need hprof-conv heap.hprof heap_std.hprof from Android SDK platform-tools first).

Attached diagnostic files

  • memory_watch.txt — output of a script that runs dumpsys meminfo <pkg> once per second over the whole reproduction window. Each block contains TOTAL PSS, Native Heap, Java Heap, and Views count. Shows the linear growth of Views (~86 per cycle) and PSS (~1.5 MB per cycle) across 10 open/close cycles, with no recovery after Force GC.

  • heap.zip — zipped Android heap.hprof (51 MB → ~12 MB compressed) captured after the 10-cycle reproduction + adb shell am send-trim-memory <pkg> COMPLETE (full ART GC). Open with Eclipse MAT (must run hprof-conv first if using older MAT versions). Histogram filter crc6431bef388e7f7f9bd.CustomLinearLayout shows the 186 leaked custom platform-view instances; right-click → Merge Shortest Paths to GC Roots → exclude weak/soft/phantom references reproduces the screenshot above.

Image

Eclipse MAT — Path to GC Roots for CustomLinearLayout instances (excluding weak/soft/phantom refs).
All 186 zombie instances have JNI Global as the GC root, holding MaterialTextView and other child views via mChildren.

Minimal reproduction code

The following sanitized excerpts (extracted from our production app) are sufficient to reproduce the leak in a fresh dotnet new maui project. Drop these files into the appropriate folders, register the handler in MauiProgram.cs, then navigate back-and-forth between two pages.

1. Controls/EnhancedButton.cs (MAUI cross-platform control)

usingMicrosoft.Maui.Controls;namespaceJniLeakRepro.Controls;// Custom Button subclass with extra bindable properties (icon image + text + description).// Concrete properties trimmed for brevity.publicclassEnhancedButton:Button{publicstaticreadonlyBindablePropertyImageSourceNameProperty=BindableProperty.Create(nameof(ImageSourceName),typeof(string),typeof(EnhancedButton),"");publicnewstringImageSourceName{get=>(string)GetValue(ImageSourceNameProperty);set=>SetValue(ImageSourceNameProperty,value);}}

2. Platforms/Android/CustomLinearLayout.cs (Java/native subclass)

usingAndroid.Content;usingAndroid.Util;usingAndroid.Widget;namespaceJniLeakRepro.Platforms.Android;// Java type generated as ACW: crc<hash>.CustomLinearLayout// Hash prefix is what shows up in Eclipse MAT histogram.publicclassCustomLinearLayout:LinearLayout{publicCustomLinearLayout(Context?context):base(context){}publicCustomLinearLayout(Context?context,IAttributeSetattrs):base(context,attrs){}}

3. Platforms/Android/Resources/layout/EnhancedButton.axml

<?xml version="1.0" encoding="utf-8"?>
<JniLeakRepro.Platforms.Android.CustomLinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"android:orientation="vertical"android:layout_width="match_parent"android:layout_height="match_parent">
<ImageViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:id="@+id/image"/>
<TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:id="@+id/text"/>
<TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:id="@+id/description"/>
</JniLeakRepro.Platforms.Android.CustomLinearLayout>

4. Platforms/Android/EnhancedButtonHandler.cs (the handler that leaks)

usingAndroid.Content;usingAndroid.Views;usingAndroid.Widget;usingJniLeakRepro.Controls;usingJniLeakRepro.Platforms.Android;usingMicrosoft.Maui.Handlers;namespaceJniLeakRepro.Platforms.Android;publicclassEnhancedButtonHandler:ViewHandler<EnhancedButton,CustomLinearLayout>{privateImageView?_image;privateTextView?_text;privateTextView?_description;privateCustomLinearLayout?_root;publicstaticreadonlyIPropertyMapper<EnhancedButton,EnhancedButtonHandler>Mapper=newPropertyMapper<EnhancedButton,EnhancedButtonHandler>(ViewMapper){[nameof(EnhancedButton.Text)]=(h,b)=>h.ApplyText(),};publicEnhancedButtonHandler():base(Mapper){}protectedoverrideCustomLinearLayoutCreatePlatformView(){varinflater=Context.GetSystemService(Context.LayoutInflaterService)asLayoutInflater??thrownewInvalidOperationException();_root=(CustomLinearLayout)inflater.Inflate(Resource.Layout.EnhancedButton,null)!;return_root;}protectedoverridevoidConnectHandler(CustomLinearLayoutplatformView){base.ConnectHandler(platformView);// Obtain inner views via GetChildAt - these are auto-wrapped by Mono peers._image=_root!.GetChildAt(0)asImageView;_text=_root!.GetChildAt(1)asTextView;_description=_root!.GetChildAt(2)asTextView;}protectedoverridevoidDisconnectHandler(CustomLinearLayoutplatformView){base.DisconnectHandler(platformView);// Per MAUI docs base.DisconnectHandler nulls handler.PlatformView,// but Mono peer for `_root` keeps its JNI Global Ref.// Adding `_root.Dispose()` here causes JNI race-crash with pending layout pass.}voidApplyText(){if(_text!=null)_text.Text=VirtualView?.Text??"";}}

5. MauiProgram.cs (handler registration)

builder.ConfigureMauiHandlers(handlers =>{handlers.AddHandler<EnhancedButton,EnhancedButtonHandler>();});

6. MainPage.xaml (host page with cyclic navigation)

<ContentPagexmlns="http://schemas.microsoft.com/dotnet/2021/maui"xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"xmlns:c="clr-namespace:JniLeakRepro.Controls"x:Class="JniLeakRepro.MainPage">
<Gridx:Name="Root"RowDefinitions="*,Auto"Padding="20">
<Gridx:Name="ButtonHost" Grid.Row="0"RowSpacing="4"ColumnDefinitions="*,*,*,*,*"RowDefinitions="*,*,*,*"/>
<Button Grid.Row="1"Text="Open then close 10x"Clicked="OnRunCycles"/>
</Grid>
</ContentPage>

7. MainPage.xaml.cs (reproduces the leak)

usingMicrosoft.Maui.Platform;// for DisconnectHandlers()usingJniLeakRepro.Controls;namespaceJniLeakRepro;publicpartialclassMainPage:ContentPage{publicMainPage()=>InitializeComponent();asyncvoidOnRunCycles(objectsender,EventArgse){for(intcycle=0;cycle<10;cycle++){// Add 20 EnhancedButtons (simulating opening a "doklad" page)for(inti=0;i<20;i++){varbtn=newEnhancedButton{Text=$"Btn {i}"};Grid.SetRow(btn,i/5);Grid.SetColumn(btn,i%5);ButtonHost.Children.Add(btn);}awaitTask.Delay(300);// Disconnect all handlers + clear (simulating closing the "doklad")foreach(varchildinButtonHost.Children.OfType<View>().ToList())child.DisconnectHandlers();ButtonHost.Children.Clear();GC.Collect();GC.WaitForPendingFinalizers();GC.Collect();awaitTask.Delay(300);}}}

After running OnRunCycles once and then forcing ART full GC + heap dump (commands above), Eclipse MAT will show ~180+ leaked crc<hash>.CustomLinearLayout instances all rooted at JNI Global, with no path back to managed application code.

Suggested fix direction

Either:

  1. Java.Lang.Object.Dispose() should reliably remove the peer from JniRuntime.JniValueManager.RegisteredInstances and call JNIEnv.DeleteGlobalRef.
  2. MauiContext.Services (or whichever per-page IServiceProvider holds handler references) should be disposed when the page is destroyed, so its strong references to platform views are released.
  3. ViewHandler.DisconnectHandler() should also dispose the _platformView peer, not only null the field.

Steps to Reproduce

No response

Link to public reproduction project repository

No response

Version with bug

10.0.20

Is this a regression from previous behavior?

Not sure, did not test other versions

Last version that worked well

Unknown/Other

Affected platforms

Android

Affected platform versions

All Android versions, that I tried

Did you find any workaround?

No response

Relevant log output

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions