Setiap += membuat publisher menyimpan referensi ke subscriber di dalam daftar pemanggilannya. Selama referensi itu ada, objek subscriber tidak memenuhi syarat garbage collection, sekalipun variabel yang memegang subscriber sudah keluar dari scope. Subscriber yang lupa melepas langganan pada publisher berumur panjang inilah sumber memory leak klasik di aplikasi .NET.

Rantai referensinya dapat diikuti satu per satu: daftar pemanggilan publisher menampung delegate, delegate menampung objek target, dan target adalah objek subscriber. Garbage collector menganggap objek yang masih dirujuk sebagai objek hidup, sehingga subscriber bertahan di memori selama publisher bertahan. Gejalanya bukan exception, melainkan konsumsi memori yang terus bertambah seiring aplikasi berjalan.

Membuktikan Referensi yang Tertahan #

csharp
class ServerNotifikasi
{
    private Action _notifikasiTerkirim;

    public int JumlahHandler =>
        _notifikasiTerkirim is null ? 0 : _notifikasiTerkirim.GetInvocationList().Length;

    public event Action NotifikasiTerkirim
    {
        add { _notifikasiTerkirim += value; }
        remove { _notifikasiTerkirim -= value; }
    }

    public void Kirim() => _notifikasiTerkirim?.Invoke();
}

class DashboardStok : IDisposable
{
    private readonly ServerNotifikasi _server;

    public DashboardStok(ServerNotifikasi server)
    {
        _server = server;
        _server.NotifikasiTerkirim += Tampilkan;
        Console.WriteLine("DashboardStok berlangganan NotifikasiTerkirim");
    }

    private void Tampilkan() => Console.WriteLine("Dashboard memperbarui angka stok");

    public void Dispose()
    {
        _server.NotifikasiTerkirim -= Tampilkan;
        Console.WriteLine("DashboardStok melepas langganan lewat Dispose");
    }
}

Skenario uji memakai WeakReference agar kita dapat mengamati objek tanpa ikut memegang referensi:

csharp
ServerNotifikasi serverLama = new();
WeakReference referensiLama = BuatDashboard(serverLama, unsubscribe: false);
Console.WriteLine($"Handler pada publisher: {serverLama.JumlahHandler}");
KumpulkanSampah();
Console.WriteLine($"Dashboard lama masih dirujuk: {referensiLama.IsAlive}");

ServerNotifikasi serverBaru = new();
WeakReference referensiBaru = BuatDashboard(serverBaru, unsubscribe: true);
Console.WriteLine($"Handler pada publisher: {serverBaru.JumlahHandler}");
KumpulkanSampah();
Console.WriteLine($"Dashboard baru masih dirujuk: {referensiBaru.IsAlive}");

static WeakReference BuatDashboard(ServerNotifikasi server, bool unsubscribe)
{
    DashboardStok dashboard = new(server);
    if (unsubscribe)
    {
        dashboard.Dispose();
    }
    return new WeakReference(dashboard);
}

static void KumpulkanSampah()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}

Output yang Dihasilkan:

text
DashboardStok berlangganan NotifikasiTerkirim
Handler pada publisher: 1
Dashboard lama masih dirujuk: True
DashboardStok berlangganan NotifikasiTerkirim
DashboardStok melepas langganan lewat Dispose
Handler pada publisher: 0
Dashboard baru masih dirujuk: False

Bedah Hasil Uji #

  1. Skenario pertama: Dashboard dibuat tanpa Dispose, lalu variabel lokal keluar scope. Handler tetap berjumlah 1 dan IsAlive bernilai True; publisher masih memegang referensi dashboard, sehingga garbage collection tidak menyentuhnya.
  2. Skenario kedua: Dashboard memanggil Dispose yang berisi -=. Handler turun ke 0 dan IsAlive bernilai False; objek dashboard kini layak dibersihkan.
  3. Method BuatDashboard menempatkan pembuatan objek di method terpisah agar variabel lokal benar-benar keluar dari scope sebelum pengecekan.
  4. Method KumpulkanSampah menjalankan GC.Collect dua kali dengan WaitForPendingFinalizers di antaranya, sehingga hasil pengamatan deterministik tanpa debugger.

Angka pada output tidak membutuhkan debugger: JumlahHandler dibaca langsung dari publisher, dan WeakReference.IsAlive menjawab status hidup atau tidaknya objek setelah garbage collection dipaksa. Dua alat sederhana ini cukup untuk menguji kebocoran di unit test maupun log aplikasi.