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

Class Instance count
android.graphics.RenderNode 1414
android.view.ViewAnimationHostBridge 1414
androidx.appcompat.widget.AppCompatBackgroundHelper 626
androidx.appcompat.widget.AppCompatTextHelper 455
androidx.emoji2.viewsintegration.EmojiTextViewHelper (5 variants) 454 each
com.google.android.material.textview.MaterialTextView 342
crc6431bef388e7f7f9bd.CustomLinearLayout (custom platform view) 171
mono.android.view.View_OnFocusChangeListenerImplementor 280

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)

Attempt Result
view.DisconnectHandlers() recursively from root No effect on count
_root.Background = null (break Drawable.callback cycle) No effect
_image.SetImageDrawable(null) No effect
_root.OnFocusChangeListener = null No effect
_root.Dispose() synchronously in DisconnectHandler Crashes 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 disconnect No 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)

using Microsoft.Maui.Controls;

namespace JniLeakRepro.Controls;

// Custom Button subclass with extra bindable properties (icon image + text + description).
// Concrete properties trimmed for brevity.
public class EnhancedButton : Button
{
    public static readonly BindableProperty ImageSourceNameProperty =
        BindableProperty.Create(nameof(ImageSourceName), typeof(string), typeof(EnhancedButton), "");

    public new string ImageSourceName
    {
        get => (string)GetValue(ImageSourceNameProperty);
        set => SetValue(ImageSourceNameProperty, value);
    }
}

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

using Android.Content;
using Android.Util;
using Android.Widget;

namespace JniLeakRepro.Platforms.Android;

// Java type generated as ACW: crc<hash>.CustomLinearLayout
// Hash prefix is what shows up in Eclipse MAT histogram.
public class CustomLinearLayout : LinearLayout
{
    public CustomLinearLayout(Context? context) : base(context) { }
    public CustomLinearLayout(Context? context, IAttributeSet attrs) : 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">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/image"/>
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/text"/>
    <TextView
        android: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)

using Android.Content;
using Android.Views;
using Android.Widget;
using JniLeakRepro.Controls;
using JniLeakRepro.Platforms.Android;
using Microsoft.Maui.Handlers;

namespace JniLeakRepro.Platforms.Android;

public class EnhancedButtonHandler : ViewHandler<EnhancedButton, CustomLinearLayout>
{
    private ImageView? _image;
    private TextView? _text;
    private TextView? _description;
    private CustomLinearLayout? _root;

    public static readonly IPropertyMapper<EnhancedButton, EnhancedButtonHandler> Mapper
        = new PropertyMapper<EnhancedButton, EnhancedButtonHandler>(ViewMapper)
        {
            [nameof(EnhancedButton.Text)] = (h, b) => h.ApplyText(),
        };

    public EnhancedButtonHandler() : base(Mapper) { }

    protected override CustomLinearLayout CreatePlatformView()
    {
        var inflater = Context.GetSystemService(Context.LayoutInflaterService) as LayoutInflater
                       ?? throw new InvalidOperationException();
        _root = (CustomLinearLayout)inflater.Inflate(Resource.Layout.EnhancedButton, null)!;
        return _root;
    }

    protected override void ConnectHandler(CustomLinearLayout platformView)
    {
        base.ConnectHandler(platformView);
        // Obtain inner views via GetChildAt - these are auto-wrapped by Mono peers.
        _image = _root!.GetChildAt(0) as ImageView;
        _text = _root!.GetChildAt(1) as TextView;
        _description = _root!.GetChildAt(2) as TextView;
    }

    protected override void DisconnectHandler(CustomLinearLayout platformView)
    {
        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.
    }

    void ApplyText()
    {
        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)

<ContentPage xmlns="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">
    <Grid x:Name="Root" RowDefinitions="*,Auto" Padding="20">
        <Grid x: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)

using Microsoft.Maui.Platform; // for DisconnectHandlers()
using JniLeakRepro.Controls;

namespace JniLeakRepro;

public partial class MainPage : ContentPage
{
    public MainPage() => InitializeComponent();

    async void OnRunCycles(object sender, EventArgs e)
    {
        for (int cycle = 0; cycle < 10; cycle++)
        {
            // Add 20 EnhancedButtons (simulating opening a "doklad" page)
            for (int i = 0; i < 20; i++)
            {
                var btn = new EnhancedButton { Text = $"Btn {i}" };
                Grid.SetRow(btn, i / 5);
                Grid.SetColumn(btn, i % 5);
                ButtonHost.Children.Add(btn);
            }
            await Task.Delay(300);

            // Disconnect all handlers + clear (simulating closing the "doklad")
            foreach (var child in ButtonHost.Children.OfType<View>().ToList())
                child.DisconnectHandlers();
            ButtonHost.Children.Clear();

            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();

            await Task.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

Metadata

Metadata

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions