Membedakan menunggu GC dan memory leak di .NET — prosedur praktis mengamati, membandingkan, dan membuktikan pertumbuhan memori

· Diperbarui pada: · · .NET, CSharp, GC, MemoryLeak, Diagnostics, dotnet-counters, dotnet-dump, Operasi, Pemanfaatan aset yang sudah ada

1. Hal yang perlu dipahami lebih dulu

Saat mengoperasikan aplikasi .NET, ada kalanya pemakaian memori naik perlahan.

Task Manager atau top menunjukkan memori proses bertambah. Pemakaian memori container juga naik. Grafik Working Set atau RSS di pemantauan terus menanjak ke kanan.

Melihat keadaan itu, godaan pertamanya adalah berpikir “ini memory leak.” Namun di .NET, naiknya memori proses dan adanya memory leak bukan hal yang sama.

.NET punya garbage collection. Memori tidak dikembalikan ke OS pada detik objek menjadi tidak dibutuhkan. GC berjalan sambil melihat pola alokasi, ambang heap, tekanan memori, generasi, dan kondisi workload.

Akibatnya, keadaan seperti berikut bisa muncul.

  • Objek yang sudah tidak dibutuhkan belum di-GC
  • GC sudah jalan, tetapi Working Set proses tidak langsung turun
  • Memori naik sekali karena akses pertama, JIT, cache, atau connection pool, lalu stabil
  • Heap managed stabil, tetapi memori native, thread, socket, atau pustaka pemrosesan gambar yang bertambah
  • Objek yang seharusnya sudah tidak dibutuhkan masih direferensikan dari suatu tempat

Artikel ini membahas cara membedakan kasus terakhir — leak yang sungguhan. Yang perlu dilihat bukan sekadar pemakaian memori, melainkan tiga hal ini:

  1. Apakah memori yang bertahan setelah GC terus bertambah
  2. Tipe apa yang bertambah
  3. Siapa yang mereferensikan objek itu

Investigasi memory leak di .NET adalah pekerjaan yang tidak berhenti di “memorinya naik”, melainkan sampai “objek tipe ini yang bertambah, dan masih direferensikan dari root ini.”

Kode yang muncul di artikel ini dipublikasikan di GitHub sebagai satu set sampel yang bisa dibangun dan dijalankan (pustaka pola leak tipikal, demo yang mengamati perbedaan antara menunggu GC dan objek yang bertahan, serta unit test yang memverifikasi retensi dan pengumpulan dengan WeakReference).

dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)

Peta pengetahuan artikel ini

Peningkatan memori pada aplikasi .NET terbagi menjadi kasus di mana garbage collection belum mengumpulkannya, dan kasus di mana referensi benar-benar tetap hidup karena koleksi static, cache tanpa batas, event subscription yang tidak di-unsubscribe, Timer yang tidak di-dispose, IDisposable yang tidak di-dispose, kekeliruan lifetime DI, dan sejenisnya. Untuk membedakannya, pertama-tama periksa tren heap GC, Gen 2, dan LOH dengan dotnet-counters; penting untuk tidak menilai hanya dari peningkatan Total Allocated yang merupakan nilai kumulatif atau Working Set dari sudut pandang OS. Berikutnya bandingkan Count dan Size per tipe sebelum dan sesudah beban dengan dotnet-gcdump untuk mengidentifikasi tipe yang bertambah, lalu telusuri sampai siapa yang terus mereferensikan objek itu dengan perintah dumpheap dan gcroot pada dotnet-dump. Meskipun memori turun dengan GC paksa, penyebab mendasarnya tidak hilang, dan itu bukan penanganan yang boleh dimasukkan ke produksi.

Peta pengetahuan cara membedakan menunggu GC dan memory leak di .NETDiagram yang menunjukkan hubungan untuk memilah apakah peningkatan memori di .NET adalah menunggu GC atau memory leak yang sesungguhnya, dengan dotnet-counters, dotnet-gcdump, dotnet-dump, dan gcrootdiverifikasi dengandiverifikasi dengandiverifikasi dengandiverifikasi dengandiverifikasi dengandiverifikasi dengandiverifikasi dengantidak disarankantidak disarankantidak disarankandapat menyebabkandapat menyebabkandapat menyebabkandapat menyebabkandapat menyebabkandapat menyebabkandapat menyebabkansebaiknya didahuluisebaiknya didahuluitidak kompatibeltidak kompatibelmenggunakanmensyaratkangarbage collection .NET (GC)memory leak .NET (retensi yang tidak disengaja)dotnet-countersdotnet-gcdumpdotnet-dumpperintah gcrootheap GC (heap terkelola)perintah dumpheap -statdotnet-traceGen 2 (heap generasi ke-2)Total Allocated (alokasi kumulatif)Working Set / RSSGC paksa (Induced Collection)retensi lewat koleksi staticcache tanpa batas dan tanpa kedaluwarsakebocoran event subscription (tanpa unsubscribe)kebocoran Timer yang tidak di-disposekebocoran IDisposable yang tidak di-disposekekeliruan lifetime DILOH (Large Object Heap).NET Framework.NET (Core dan setelahnya)

Pada diagram, garis utuh menunjukkan relasi yang selalu berlaku dan garis putus-putus menunjukkan relasi bersyarat (syaratnya ada pada penjelasan masing-masing relasi di halaman rincian). Daftar lengkap relasi (total 23, beserta bukti dan tingkat kepastian) serta definisi konsep utama dikumpulkan di halaman rincian peta pengetahuan (dalam bahasa Jepang). Data: JSON-LD / Turtle

2. Samakan dulu arti “memory leak”

Memory leak di .NET tidak hanya berbentuk “lupa melepaskan memori yang sudah dialokasikan” seperti di C atau C++.

Di kode managed, GC-lah yang mengumpulkan objek. Apakah GC bisa mengumpulkannya ditentukan oleh apakah masih ada referensi yang dapat dijangkau ke objek itu.

Dengan kata lain, memory leak tipikal di .NET adalah seperti ini:

Secara bisnis objek itu sudah tidak dibutuhkan, tetapi masih direferensikan dari field static, cache, event, Timer, koleksi, lifetime DI, konteks asinkron, dan semacamnya, sehingga dari sudut pandang GC objek itu masih terlihat sedang dipakai.

GC itu cerdas, tetapi ia tidak tahu apakah sesuatu masih dibutuhkan secara bisnis. Jika masih direferensikan, GC menilainya hidup.

Itulah sebabnya, di .NET lebih mudah dipahami sebagai “retensi yang tidak disengaja” daripada sebagai “leak.”

Sebaliknya, keadaan berikut belum otomatis berarti memory leak.

Keadaan Alasan mengapa belum tentu leak
Working Set / RSS naik Itu memori yang OS alokasikan ke proses; tidak sama dengan jumlah objek hidup di heap managed
Total Allocated naik Itu jumlah kumulatif yang dialokasikan sejak start; selama aplikasi berjalan, pada dasarnya naik
GC Heap melonjak sesaat Objek yang belum dikumpulkan mungkin masih tersisa sampai GC berikutnya
Naik tepat setelah start Sering terjadi karena JIT, pemuatan tipe, cache awal, connection pool, ekspansi template
LOH besar Bisa pengaruh reuse array/buffer besar, fragmentasi, atau strategi pool
Memori tidak turun Meski GC sudah mengumpulkan, proses tidak selalu segera mengembalikan memori ke OS

Sebaliknya, semakin banyak keadaan berikut yang terpenuhi bersama, semakin kuat kecurigaan memory leak.

Hasil observasi Artinya
Heap setelah GC naik setiap kali operasi yang sama diulang Objek yang bertahan sedang bertambah
Ukuran Gen 2 atau LOH terus naik Objek berumur panjang, atau objek besar, yang tertinggal
Count / Size tipe yang sama naik di beberapa dump Tipe yang bertambah dapat diidentifikasi
gcroot memperlihatkan referensi dari static, event, cache, atau layanan berumur panjang Alasan GC tidak bisa mengumpulkan dapat dijelaskan
Setelah beban dihentikan, tidak kembali meski sudah cukup waktu atau setelah GC untuk verifikasi Kemungkinan besar bukan sekadar alokasi sementara

3. Pisahkan “memori yang mana” yang sedang dilihat

Kebingungan pertama dalam investigasi memori adalah tercampurnya berbagai metrik. Semuanya “memori”, tetapi artinya berbeda.

Metrik Yang dilihat Cara membacanya
Working Set / RSS Halaman proses yang berada di memori fisik Sudut pandang OS. Bukan heap GC itu sendiri
Private Bytes / Commit Memori committed yang dimiliki proses secara privat Termasuk juga memori native, stack, kode JIT, segmen GC
GC Heap Size Jumlah objek di heap managed Pintu masuk untuk melihat memori yang menjadi sasaran GC .NET
Total Allocated Jumlah kumulatif yang dialokasikan sejak start Pada dasarnya selalu naik. Jangan dipakai sendiri untuk menilai leak
Gen 0 / Gen 1 / Gen 2 Heap per generasi Yang tertinggal di Gen 2 berumur panjang
LOH Heap untuk objek besar 85,000 byte atau lebih Mudah naik karena array, string, buffer besar
POH Heap untuk objek yang di-pin Petunjuk pengaruh interop native dan pinning
Finalization Queue Objek yang menunggu finalisasi Petunjuk Dispose yang terlewat, atau finalizer yang tersumbat

Jika dipetakan metrik mana yang melihat bagian mana, kira-kira seperti ini.

Hubungan metrik memori proses, heap managed, dan Working SetDiagram yang memilah memori proses menjadi sisi managed dan native, lalu menunjukkan bahwa Private Bytes/Commit mencakup keduanya sementara Working Set/RSS hanya porsi yang berada di memori fisikdihitung kedihitung kehanya porsi yang ada di memori fisikMemori yang dipakai prosesSisi managedCakupan yang terlihat di GC Heap SizeSisi nativeCakupan yang tidak muncul di GC Heap SizeGen 0 / Gen 1Alokasi berumur pendekGen 2Objek berumur panjang yang bertahanLOHObjek besar 85,000 byte atau lebihPOHObjek yang di-pinStack threadKode hasil JIT, assembly yang sudah dimuatBuffer P/Invoke, COM, pustaka eksternalPrivate Bytes / CommitMemori committed milik prosesWorking Set / RSS

Dua hal yang perlu dipegang dari diagram ini.

  1. Yang terlihat di dumpheap hanya sisi managed. Jika yang naik adalah sisi native, menatap heap sepanjang hari pun pelakunya tidak akan muncul.
  2. Working Set dan Commit bukan hubungan bersarang. Memori yang sudah di-commit tetapi belum berada di memori fisik tidak muncul di Working Set, dan sebaliknya halaman pustaka bersama yang bukan milik privat proses kadang ikut terhitung di Working Set. Itulah sebabnya “Working Set tidak turun, jadi GC belum mengumpulkan” tidak bisa disimpulkan begitu saja.

Tidak perlu menelaah semuanya secara rinci dari awal. Pertama, pecah pertanyaannya seperti ini.

Memori proses sedang naik
  ↓
Apakah heap managed juga naik?
  ↓
Apakah jumlah yang bertahan setelah GC juga naik?
  ↓
Tipe mana yang bertambah?
  ↓
Siapa yang mereferensikannya?

Jika urutan ini dijaga, “kenaikan memori yang kelihatan buruk” dan “leak yang sungguhan” lebih sulit tercampur.

4. Alur keputusan

Dalam praktik, memilah dengan alur berikut lebih mudah dijalankan.

1. Tentukan kondisi reproduksi
   - API, layar, job, atau batch mana yang membuatnya naik
   - Berapa kali dijalankan sampai naik
   - Apa yang terjadi jika beban dihentikan

2. Lihat tren dengan dotnet-counters
   - Working Set
   - GC Heap
   - Gen 2 / LOH
   - Total Allocated
   - Jumlah GC

3. Bandingkan dengan selisih waktu
   - Tepat setelah start
   - Setelah warm-up
   - Saat beban
   - Setelah beban berhenti
   - Setelah operasi yang sama diulang N kali

4. Ambil dump dua kali atau lebih
   - before
   - after
   - jika memungkinkan, juga setelah beban berhenti

5. Cari tipe yang bertambah
   - dumpheap -stat
   - gcdump report
   - Visual Studio / PerfView

6. Periksa sumber referensi
   - gcroot
   - gchandles
   - finalizequeue

7. Tentukan diagnosis
   - Menunggu GC
   - Pertumbuhan cache yang normal
   - Memory leak managed
   - Masalah memori native
   - Fragmentasi LOH atau alokasi besar yang sementara

Yang penting adalah tidak menilai dari satu angka. Memory leak adalah “tren yang terus naik”, jadi bandingkan selisih waktu pada kondisi yang sama, bukan satu titik pengukuran.

5. Alat yang dipakai

Artikel ini terutama memakai alat berikut.

Alat Kapan dipakai
dotnet-counters Melihat tren GC dan Working Set pada proses yang sedang berjalan
dotnet-gcdump Mengambil statistik ringan objek managed yang masih hidup
dotnet-dump Menelaah heap secara rinci dan menelusuri sumber referensi dengan dumpheap dan gcroot
Visual Studio Memory Usage Ketika ingin membandingkan lewat GUI di Windows
PerfView Ketika ingin menelaah GC / heap / trace secara dalam di Windows
dotnet-trace Ketika ingin mengikuti alokasi dan event GC sepanjang waktu

Pasang dulu alat CLI-nya.

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace

Jika sudah terpasang, perbarui.

dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace

Cari proses yang akan diselidiki.

dotnet-counters ps

Pada contoh-contoh berikutnya, ID proses sasaran ditulis sebagai <PID>.

Di Linux, macOS, dan lingkungan container, alat diagnostik harus berjalan sebagai pengguna yang sama dengan proses sasaran. Tergantung lingkungan, Anda juga bisa terpengaruh oleh TMPDIR, port diagnostik, dan namespace PID container.

Jika dijalankan terhadap produksi, jangan langsung mengambil dump — verifikasi dulu beban dan dampaknya di lingkungan uji.

5.1 Jika sasaran investigasi adalah .NET Framework 4.x

dotnet-counters, dotnet-dump, dan dotnet-gcdump adalah alat yang memakai fitur diagnostik runtime .NET Core 3.0 ke atas. Jika sasaran investigasi adalah aplikasi .NET Framework 4.x, alat-alat itu tidak bisa dipakai. Kasus pemeliharaan aplikasi Windows Forms, WPF, atau ASP.NET yang sudah ada biasanya jatuh ke sini.

Penggantiannya dipikirkan dengan pemetaan ini.

Alat di artikel ini Pengganti di .NET Framework 4.x
Melihat tren dengan dotnet-counters Performance Monitor, atau lihat counter kategori .NET CLR Memory dengan Get-Counter
Membandingkan statistik tipe dengan dotnet-gcdump Dump heap GC di PerfView, atau bandingkan snapshot “Memory Usage” di Visual Studio
Mengambil dump dengan dotnet-dump collect ProcDump, “Create dump file” di Task Manager, atau mengeluarkan dump lewat pengaturan Windows Error Reporting
dumpheap / gcroot di dotnet-dump analyze Muat SOS di WinDbg dengan .loadby sos clr, lalu pakai !dumpheap -stat dan !gcroot
Mengikuti alokasi dengan dotnet-trace Pengumpulan alokasi heap GC di PerfView

Cara berpikirnya persis sama: tiga tahap “lihat tren”, “bandingkan statistik tipe dua kali”, “telusuri sumber referensi”. Yang berubah hanya alatnya.

Jika memakai Performance Monitor, counter di kategori .NET CLR Memory yang ingin dilihat lebih dulu kira-kira ini.

Counter Yang dilihat
# Bytes in all Heaps Jumlah Gen 1, Gen 2, dan LOH. Dekat dengan GC Heap Size di artikel ini
Gen 2 heap size Byte Gen 2 saat ini. Jika terus naik, curigai leak
Large Object Heap size Ukuran LOH saat ini
# Gen 2 Collections Jumlah full GC. Jika naiknya curam, curigai alokasi berlebih
% Time in GC Persentase waktu yang dihabiskan untuk GC pada siklus GC terbaru
Finalization Survivors Jumlah objek yang bertahan sambil menunggu finalisasi. Petunjuk Dispose yang terlewat
# Total committed Bytes Jumlah memori virtual yang di-commit oleh GC

Nama counter kadang ditampilkan dalam bahasa lokal. Jika tidak ketemu, jangan hanya mencari nama Inggris — cari juga nama kategori yang dilokalkan seperti .NET CLR メモリ.

Pintu masuk analisis di WinDbg adalah memuat SOS.

0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>

Di .NET Framework, perintah SOS diberi awalan !. dumpheap -stat dan gcroot yang muncul dari bab 9 ke bawah di artikel ini bisa dipakai dengan prosedur yang sama setelah dibaca sebagai !dumpheap -stat dan !gcroot.

6. Lihat tren dulu dengan dotnet-counters

Yang dilihat pertama bukan dump rinci, melainkan tren.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime

Keluaran sedikit berbeda tergantung versi .NET. Di .NET 9 ke atas, counter bisa tampil dengan nama Meter System.Runtime; di .NET 8 ke bawah, dengan nama EventCounter yang lama.

Yang terutama dilihat adalah item berikut.

Item yang dilihat Apa yang dilihat
dotnet.process.memory.working_set Memori residensial proses dari sudut pandang OS
dotnet.gc.last_collection.heap.size Ukuran heap per generasi setelah GC terbaru
dotnet.gc.last_collection.memory.committed_size Jumlah memori yang di-commit oleh GC
dotnet.gc.heap.total_allocated Jumlah alokasi kumulatif sejak start
dotnet.gc.collections Jumlah GC per generasi
dotnet.gc.pause.time Waktu jeda GC kumulatif

Boleh juga mempersempit pemantauan ke counter tertentu.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]

Jika ingin ditinjau lagi nanti, simpan ke CSV.

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output counters.csv \
  --counters System.Runtime

Pada tahap ini, yang ingin dibedakan adalah perbedaan berikut.

6.1 Hanya Total Allocated yang naik

dotnet.gc.heap.total_allocated adalah nilai kumulatif. Jika aplikasi memproses request, ia mengalokasikan objek, dan meski objek itu segera menjadi tidak dibutuhkan lalu dikumpulkan GC, jumlah alokasi kumulatif tetap naik.

Karena itu, naiknya Total Allocated saja belum berarti memory leak. Yang perlu dilihat adalah apakah alokasi itu tertinggal setelahnya.

Total Allocated: naik
GC Heap Size:    naik-turun dalam batas tertentu lalu stabil
Gen 2 / LOH:     tidak terus naik

Dalam kasus ini, itu bukan leak, melainkan aplikasi yang banyak mengalokasi.

Tindakannya bukan perbaikan leak, melainkan pengurangan alokasi, reuse buffer, meninjau pemakaian LINQ yang berlebih, pengurangan pembuatan string, meninjau pemrosesan serialisasi, dan semacamnya.

6.2 Working Set naik tetapi GC Heap stabil

Ada kalanya Working Set atau RSS naik padahal GC Heap stabil. Dalam kasus ini, belum tentu leak objek managed.

Faktor yang bisa dipertimbangkan kira-kira ini.

  • Kode hasil JIT
  • Assembly yang sudah dimuat
  • Stack thread
  • Memori pustaka native
  • Memori unmanaged seperti Marshal.AllocHGlobal
  • Buffer sisi native untuk gambar, kompresi, kriptografi, driver DB
  • Buffer internal untuk socket, file handle, SSL, HTTP/2, gRPC
  • OS yang belum reclaim halaman fisik dari proses

Dalam keadaan ini, menatap dumpheap sepanjang hari pun pelakunya kadang tidak ketemu.

Patokan penilaiannya seperti ini.

Working Set / RSS: naik
GC Heap Size:      stabil
Gen 2 / LOH:       stabil

Dalam kasus ini, curigai memori native, handle, jumlah thread, socket, dan pustaka eksternal, bukan managed heap leak .NET.

Jangan berhenti di dotnet-counters — lihat juga alat OS, metrik container, jumlah handle, jumlah thread, heap native, dan metrik pustaka eksternal.

6.3 GC Heap naik, tetapi kembali setelah beban berhenti

Naiknya GC Heap saat beban adalah hal yang wajar.

Request banyak. Objek sementara banyak. Menangani JSON besar. Membuat list atau array sementara.

Dalam kasus seperti itu, heap naik sampai GC berikutnya. Jika beban dihentikan, GC berjalan, dan heap kadang kembali.

Saat beban:          GC Heap naik
Setelah beban berhenti: GC Heap turun, atau kembali ke suatu nilai tetap
Setelah diulang:     baseline tidak terus naik

Dalam kasus ini, Anda dapat menyimpulkan “baru belum di-GC” atau “alokasi sementara yang banyak.”

Namun jika alokasi sementara saat beban terlalu banyak, jumlah GC dan pause time naik lalu menjadi masalah kinerja. Meski bukan leak, itu tetap sasaran perbaikan kinerja.

6.4 Gen 2 / LOH setelah GC terus naik

Pola yang perlu diwaspadai adalah yang ini.

Operasi yang sama diulang
  ↓
Gen 2 naik
  ↓
LOH naik
  ↓
Menghentikan beban pun tidak mengembalikannya
  ↓
Pengukuran berikutnya naik lagi

Gen 2 adalah generasi tempat objek berumur panjang. LOH adalah heap yang mudah diisi array dan string besar.

Jika bagian ini terus naik, curigai leak, cache tanpa batas, retensi buffer raksasa, event yang tidak di-unsubscribe, koleksi static, atau retensi oleh layanan berumur panjang.

Pada tahap ini, lanjut ke langkah berikutnya.

7. Cara berpikir untuk memeriksa apakah “baru menunggu GC”

Untuk melihat apakah “baru belum di-GC”, lihat keadaan setelah GC sudah punya kesempatan yang cukup untuk berjalan.

Namun jangan memasukkan GC.Collect() ke kode produksi secara gegabah.

GC.Collect() memaksa GC. Full blocking GC di semua generasi khususnya menciptakan pause time aplikasi. Dalam operasi biasa, menyerahkannya pada GC adalah aturan dasarnya.

Meski begitu, dalam investigasi kadang Anda memeriksa “apakah bertahan setelah GC paksa” di lingkungan verifikasi yang terkendali.

Di aplikasi konsol untuk verifikasi atau lingkungan reproduksi, kode seperti berikut memungkinkan Anda memeriksa keadaan setelah full GC.

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

Poinnya adalah tidak memakai ini sebagai solusi. Ini hanya untuk investigasi.

Yang ingin dikonfirmasi adalah alur ini.

Sebelum operasi
  ↓
Ulangi operasi N kali
  ↓
Hentikan beban
  ↓
Tunggu cukup lama, atau picu full GC di lingkungan verifikasi
  ↓
Apakah heap setelah GC kembali mendekati nilai sebelum operasi?

Jika kembali, kemungkinan besar menunggu GC atau alokasi sementara. Jika tidak kembali, dan baseline naik setiap kali operasi yang sama diulang, ada sesuatu yang bertahan. “Sesuatu” itu yang kemudian dicari di dump.

8. Bandingkan secara ringan dengan dotnet-gcdump

Untuk perbandingan awal, dotnet-gcdump nyaman dipakai.

dotnet-gcdump mengambil GC dump dari proses .NET yang sedang berjalan, dan dapat dipakai untuk melihat statistik per tipe di heap.

dotnet-gcdump collect --process-id <PID> --output before.gcdump

Setelah diberi beban, ambil sekali lagi.

dotnet-gcdump collect --process-id <PID> --output after.gcdump

Anda juga dapat melihat laporan sederhana dari CLI.

dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump  > after-heap.txt

Yang dilihat adalah Count dan Size per tipe.

Misalnya, jika tipe seperti berikut naik tajam di after, itu menjadi sasaran investigasi.

Size (Bytes)   Count       Type
============   =====       ====
180,000,000    2,000,000   System.String
120,000,000    1,000,000   MyApp.Models.Customer
 90,000,000       25,000   System.Byte[]

Yang penting bukan “tipe yang besar”, melainkan “tipe yang bertambah.”

System.String dan System.Byte[] muncul di peringkat atas di banyak aplikasi. Berada di atas saja tidak membuatnya pelaku.

Sudut perbandingannya seperti ini.

before → after Cara membacanya
Count hampir sama Tipe itu kemungkinan besar bukan pelaku utama
Count dan Size sama-sama naik Menjadi kandidat
Tipe MyApp.* naik Mudah dicurigai retensi di logika bisnis
System.Byte[] naik Curigai buffer, serialisasi, gambar, kompresi, HTTP, DB
System.String naik Curigai cache, log, JSON, kunci kamus, string duplikat
Task, Timer, CancellationTokenSource naik Curigai pemrosesan asinkron, Timer, pembatalan yang terlewat

dotnet-gcdump mudah dipakai sebagai pintu masuk perbandingan, tetapi pengambilannya memicu Gen 2 GC. Di lingkungan dengan heap besar atau persyaratan latensi yang ketat, perhatikan pause time dan konsumsi memori tambahan.

Di Windows, berkas .gcdump dapat dibuka dan dibandingkan di Visual Studio atau PerfView. Di lingkungan non-Windows, jalur praktisnya adalah melihat statistik tipe dengan report di CLI, lalu lanjut ke dotnet-dump untuk menelusuri sumber referensi.

9. Lihat heap dan sumber referensi dengan dotnet-dump

Setelah “tipe yang bertambah” terlihat, langkah berikutnya adalah melihat “mengapa tidak dikumpulkan.”

Untuk itu, ambil dump dengan dotnet-dump dan analisis dengan perintah SOS.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-1.dmp

Tunggu beberapa waktu, lalu ambil sekali lagi.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-2.dmp

Pengambilan dump adalah operasi berat. Dump Full / Heap khususnya berukuran besar dan membebani proses serta container. Jika mengambilnya di produksi, perhatikan waktu, kapasitas disk, batas memori container, dan kemungkinan tercampurnya data pribadi atau rahasia.

Analisis dump yang sudah diambil.

dotnet-dump analyze myapp-2.dmp

Lihat dulu statistik heap secara keseluruhan.

> dumpheap -stat

Keluaran adalah jumlah dan ukuran per tipe.

MT               Count       TotalSize Class Name
00007f...        120000      3840000   MyApp.Models.Order
00007f...        250000      8000000   System.String
00007f...         10000     40000000   System.Byte[]

Persempit ke tipe tertentu.

> dumpheap -stat -type MyApp.Models.Order

Atau saring dengan MethodTable tertentu.

> dumpheap -mt <MT>

Setelah alamat instans diketahui, selidiki sumber referensinya.

> gcroot <OBJECT_ADDRESS>

Ini bagian yang paling penting. Dengan gcroot, Anda mengonfirmasi mengapa objek itu masih hidup.

Misalnya, jalur referensi seperti berikut terlihat.

static MyApp.CustomerCache._items
  -> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
  -> MyApp.Models.Customer
  -> System.String

Dalam kasus ini, alasan GC tidak mengumpulkan sudah jelas. Customer direferensikan dari cache static, jadi dari sudut pandang GC ia masih sedang dipakai.

Baru di sini keputusan berikutnya bisa dibuat.

  • Apakah cache itu benar-benar dibutuhkan
  • Apakah ada batas
  • Apakah ada kedaluwarsa
  • Apakah desainnya membuat kunci terus bertambah
  • Apakah memakai tenant, pengguna, tanggal, request ID, dan semacamnya sebagai kunci sehingga tumbuh tanpa batas

Yang penting dalam investigasi memory leak adalah tidak berhenti di dumpheap -stat. dumpheap -stat memberi tahu “apa yang banyak”, gcroot memberi tahu “mengapa tertinggal.” Yang mengarah ke perbaikan adalah yang belakangan.

10. Tabel cepat cara membedakan

Berikut pola yang sering muncul dalam praktik.

Observasi Kemungkinan Yang dilihat berikutnya
Hanya Total Allocated yang naik Alokasi biasa, atau alokasi berlebih Allocation Rate, jumlah GC, CPU, dotnet-trace
Working Set naik tetapi GC Heap stabil Memori native, JIT, stack, retensi sisi OS Jumlah thread, jumlah handle, alat native, pustaka eksternal
GC Heap hanya naik saat beban, kembali setelah berhenti Menunggu GC, alokasi sementara Gen 2 / LOH setelah beban berhenti, jumlah GC
Gen 2 setelah GC terus naik Retensi objek berumur panjang dumpheap -stat, gcroot
LOH terus naik Array besar, buffer, fragmentasi, string raksasa System.Byte[], System.Char[], LOH, wilayah Free
System.String besar Cache string, JSON, log, kunci kamus Cari tipe sendiri yang menahan string
System.Byte[] besar Buffer, serialisasi, gambar, kompresi, komunikasi Tipe pemilik, ArrayPool yang tidak dikembalikan, interop native
Task bertambah Pemrosesan asinkron yang tidak selesai, antrean tunggu penantian async, pembatalan, channel, antrean
Timer bertambah Timer yang tidak di-Dispose Dispose, deregistrasi, layanan berumur panjang
CancellationTokenSource bertambah CTS yang tidak di-Dispose, terlalu banyak linked token Dispose, pelepasan tautan, tempat timeout dibuat
EventHandler atau delegate tertinggal Event yang tidak di-unsubscribe Selisih umur publisher / subscriber
Finalization Queue naik Dispose terlewat, finalizer tersumbat finalizequeue, thread finalizer
Pinned handle banyak Buffer yang di-pin, interop native gchandles, POH, lokasi pinning

11. Bentuk leak yang sering muncul

Ada tujuh pola, tetapi tidak perlu dibaca dari atas. Masuk dari baris yang gejalanya paling dekat dengan yang sedang Anda lihat.

Bagian Pola Gejala tipikal Indikator yang dilihat lebih dulu
11.1 Koleksi static Naik sebanding jumlah operasi, tidak kembali meski beban dihentikan Gen 2. Apakah gcroot memperlihatkan static field
11.2 Cache tanpa batas Naik sebanding waktu berjalan. Kembali setelah restart Gen 2. Jumlah entri cache dan kenaikan System.String
11.3 Event yang tidak di-unsubscribe Naik setiap kali layar atau scope dibuka lalu ditutup Count tipe ViewModel atau handler terkait. gcroot lewat delegate
11.4 Timer yang tidak di-Dispose Objek yang mestinya berumur pendek tidak hilang, callback pun terus berjalan Count System.Threading.Timer atau TimerQueueTimer
11.5 IDisposable yang tidak dilepas GC Heap stabil, tetapi jumlah handle atau memori proses naik Jumlah handle, Finalization Queue, Working Set
11.6 Retensi AsyncLocal atau konteks DTO tertinggal meski pemrosesan request sudah selesai gcroot lewat async state machine
11.7 Kekeliruan lifetime DI Naik sebanding jumlah request gcroot dari tipe singleton

Kolom “indikator yang dilihat lebih dulu” dipakai berpasangan dengan tabel cepat di bab 10. Bab 10 adalah “tabel yang mempersempit kemungkinan dari hasil observasi”, tabel ini adalah “tabel yang kembali ke indikator yang harus dicek dari polanya.”

11.1 Koleksi static

Bentuk yang paling mudah dipahami.

public static class CustomerStore
{
    private static readonly List<Customer> Customers = new();

    public static void Add(Customer customer)
    {
        Customers.Add(customer);
    }
}

Dalam kode ini, Customer yang ditambahkan ke Customers tetap ada selama proses hidup. Meski dimaksudkan sebagai penyimpanan sementara, selama masih direferensikan dari static, GC tidak mengumpulkannya.

Arah perbaikannya bergantung pada tujuan pemakaian.

  • Pasang batas
  • Pasang kedaluwarsa
  • Pakai mekanisme cache seperti MemoryCache
  • Hapus secara eksplisit
  • Hentikan static, pindahkan ke layanan dengan lifetime yang sesuai
  • Jika tujuannya persistensi, pindahkan ke DB atau penyimpanan eksternal

Yang penting bukan “static itu buruk”, melainkan memakai static dengan memahami sifat bahwa apa pun yang diletakkan di sana menjadi berumur panjang.

11.2 Cache tanpa batas

Cache memakai memori secara sengaja, jadi pertumbuhan sesuai rancangan bukan leak. Namun cache tanpa batas atau kedaluwarsa menjadi memory leak de facto.

public sealed class ReportCache
{
    private readonly Dictionary<string, Report> _cache = new();

    public Report GetOrCreate(string userId, DateTime date)
    {
        var key = $"{userId}:{date:O}";

        if (_cache.TryGetValue(key, out var report))
        {
            return report;
        }

        report = BuildReport(userId, date);
        _cache[key] = report;
        return report;
    }
}

Dalam contoh ini, jika kombinasi userId dan date terus bertambah, cache pun terus bertambah.

Yang khususnya berbahaya adalah kunci yang memuat nilai seperti berikut.

  • Request ID
  • Waktu saat ini
  • GUID
  • Session ID
  • String dari masukan pengguna yang dipakai tanpa normalisasi
  • SQL atau kondisi pencarian yang di-stringify utuh

Untuk cache, tetapkan kondisi berikut.

Kondisi Contoh
Jumlah maksimum Sampai 10.000 entri
Ukuran maksimum Sampai 256MB
Kedaluwarsa 30 menit sejak akses terakhir
Kedaluwarsa absolut 6 jam sejak dibuat
Syarat penghapusan Penghapusan tenant, penghapusan pengguna, perubahan pengaturan
Item pemantauan Jumlah, ukuran perkiraan, hit rate, jumlah eviction

Bukan “karena cache, boleh naik”, melainkan “sampai seberapa jauh boleh naik” yang harus diputuskan.

11.3 Event yang tidak di-unsubscribe

Event menjadi leak ketika publisher berumur panjang terus mereferensikan subscriber berumur pendek.

public sealed class OrderViewModel
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // update view model
    }
}

Jika OrderService adalah singleton dan OrderViewModel dibuat per layar, event OrderService terus mereferensikan OrderViewModel. Menutup layar pun tidak membebaskan ViewModel jika tidak di-unsubscribe.

Contoh perbaikannya.

public sealed class OrderViewModel : IDisposable
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    public void Dispose()
    {
        _service.OrderChanged -= OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // update view model
    }
}

Di gcroot, ini kadang terlihat sebagai referensi lewat delegate atau event handler.

Pola ini sering muncul di WPF, WinForms, layanan berumur panjang, message broker, dan event aggregator.

11.4 Timer yang tidak di-Dispose

System.Threading.Timer, PeriodicTimer, dan subscription Reactive Extensions juga tertinggal jika tidak di-Dispose.

public sealed class PollingWorker
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    private void Poll()
    {
        // polling
    }
}

Jika PollingWorker ini dimaksudkan sebagai objek sementara, rancangannya harus men-Dispose Timer.

public sealed class PollingWorker : IDisposable
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    public void Dispose()
    {
        _timer.Dispose();
    }

    private void Poll()
    {
        // polling
    }
}

Timer memegang delegate callback, dan dari situ rantai referensi kadang tersambung ke objek sasaran.

11.5 IDisposable yang tidak dilepas

IDisposable yang tidak dilepas tidak selalu terlihat sebagai leak di heap managed.

Ia bisa muncul sebagai masalah sumber daya: berkas, socket, koneksi DB, handle native, buffer.

public async Task<string> ReadAsync(string path)
{
    var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

Dalam contoh ini StreamReader menutup stream, jadi sering tidak menjadi masalah besar, tetapi kebocoran terjadi pada kode yang kepemilikannya kabur.

Dasarnya adalah membuat kepemilikan jelas dengan using / await using.

public async Task<string> ReadAsync(string path)
{
    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

Dispose yang terlewat muncul sebagai gejala seperti ini.

  • Jumlah handle naik
  • Socket bertambah
  • Berkas tidak tertutup
  • Memori native naik
  • Finalization Queue naik
  • GC Heap stabil, tetapi memori proses naik

Dalam kasus ini, dumpheap saja tidak cukup. Lihat juga handle dan socket di sisi OS, serta keadaan pustaka eksternal.

11.6 Retensi AsyncLocal atau konteks

AsyncLocal<T> nyaman, tetapi jika yang dimasukkan besar, ia bisa tertinggal lama.

Nilai kecil seperti correlation ID log jarang menjadi masalah. Namun memasukkan informasi pengguna, body request, DTO besar, atau konteks DB mengarah ke retensi yang tidak disengaja.

public static class RequestContext
{
    public static readonly AsyncLocal<RequestInfo?> Current = new();
}

Karena AsyncLocal menumpang alur asinkron, ia kadang lebih sulit ditemukan daripada field static biasa.

Simpan yang kecil dan jelas, dan pertimbangkan rancangan yang mengembalikannya ke null ketika tidak lagi dibutuhkan.

11.7 Kekeliruan lifetime DI

Di DI seperti ASP.NET Core, lifetime singleton, scoped, dan transient berbeda.

Jika singleton berumur panjang menahan data per-request, objek bisa tertinggal setelah request selesai.

public sealed class AuditBuffer
{
    private readonly List<RequestAudit> _items = new();

    public void Add(RequestAudit item)
    {
        _items.Add(item);
    }
}

Jika ini singleton, _items umurnya sama dengan aplikasi.

Jika buffering memang disengaja, rancangannya membutuhkan batas, pengiriman, penghapusan, dan backpressure. Jika hanya “nanti mungkin dilihat”, seharusnya dikeluarkan ke log atau penyimpanan eksternal.

12. LOH khususnya mudah disalahpahami

LOH adalah singkatan Large Object Heap. Di .NET, objek besar diletakkan di heap yang terpisah dari objek kecil biasa. Contoh klasiknya adalah array besar.

var buffer = new byte[1024 * 1024 * 10]; // 10MB

Tiga masalah yang sering muncul di LOH adalah sebagai berikut.

  1. Sering membuat objek besar
  2. Menahan objek besar dalam waktu lama
  3. Fragmentasi karena membuat dan membuang objek besar

Naiknya LOH belum otomatis berarti leak. Dengan rancangan yang me-reuse buffer besar, ia kadang naik sampai ukuran tertentu lalu stabil, dan meski GC sudah mengumpulkan, Working Set tidak selalu langsung turun.

Namun keadaan berikut patut dicurigai.

  • System.Byte[] naik setiap operasi
  • System.Char[] atau instans String raksasa bertambah
  • Tidak kembali setelah pemrosesan gambar, PDF, Excel, ZIP, kriptografi, atau kompresi
  • Array dari ArrayPool<T>.Rent tidak dikembalikan
  • Respons besar dimuat utuh ke memori
  • MemoryStream.ToArray() dipakai secara berlebih

Jika memakai ArrayPool<T>, selalu kembalikan yang di-rent.

var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);

try
{
    // use buffer
}
finally
{
    pool.Return(buffer);
}

Namun, mengembalikan ke pool tidak berarti memori proses langsung turun. Pool kadang menahan memori untuk reuse.

Di sini pun, yang perlu dilihat adalah: apakah terus naik, apakah ada batas, dan apakah memorinya di-reuse.

13. Cara membaca gcroot

gcroot menampilkan dari mana suatu objek direferensikan.

Root tipikal dirangkum di tabel.

Root Artinya
static field Direferensikan dari field static suatu tipe
local variable / stack Direferensikan dari stack thread yang sedang berjalan
GC handle Direferensikan lewat GCHandle, pinning, delegate, interop
finalization queue Ditahan sambil menunggu finalisasi
thread / async state machine Ditahan oleh pemrosesan asinkron yang sedang berjalan atau menunggu

Poin yang sering perlu dilihat dalam investigasi adalah selisih umur.

Objek berumur panjang
  -> objek yang mestinya berumur pendek

Jika bentuk ini muncul, itu kandidat leak.

Misalnya, yang berikut mencurigakan.

SingletonService
  -> List<RequestContext>
  -> RequestContext
  -> LargeDto

SingletonService hidup sepanjang aplikasi. Jika RequestContext per-request menumpuk di dalamnya, rancangannya perlu ditinjau ulang.

Sebaliknya, root seperti berikut bisa saja normal tergantung waktunya.

Thread stack
  -> Controller action local variable
  -> RequestDto

Jika request sedang diproses, wajar jika variabel lokal masih hidup.

Itulah sebabnya waktu pengambilan dump penting.

Ambil dump tidak hanya saat beban, tetapi juga setelah beban berhenti, setelah antrean kosong, dan setelah idle beberapa waktu — penilaian menjadi jauh lebih mudah.

14. “Turun setelah GC paksa” tidak berarti “selesai”

Selama investigasi, Anda memanggil GC.Collect() dan memori turun. Menyimpulkan “kalau begitu jalankan GC.Collect() secara berkala” adalah berbahaya.

GC paksa tidak menghapus akar masalah. Ia hanya mengumpulkan, di tempat itu, objek yang belum sempat dikumpulkan.

Jika laju alokasi yang tinggi yang bermasalah, GC paksa menaikkan pause time dan memperburuk kinerja. Jika leak sungguhan, objek yang masih punya referensi pun tidak dikumpulkan oleh GC paksa.

Yang perlu dilihat dalam investigasi adalah perbedaan berikut.

Setelah GC paksa Penilaian
Turun tajam, lalu baseline stabil Menunggu GC, atau alokasi sementara, adalah penyebab utama
Sedikit turun, tetapi lantai naik setiap pengulangan Sebagian bertahan. Kandidat leak
Hampir tidak turun Masih direferensikan, atau penyebab utama di luar heap GC
GC Heap turun tetapi Working Set tidak Kemungkinan retensi sisi OS / segmen GC / native

Sebelum menjadwalkan GC.Collect() di produksi, selalu identifikasi apa yang sebenarnya bertambah.

15. Prosedur investigasi yang dipakai di lapangan

Dari sini, disusun sebagai prosedur yang benar-benar diikuti saat investigasi.

15.1 Kunci skenario reproduksi

Pertama, kunci kondisi investigasi.

Sasaran:        /api/report/export
Operasi:        jalankan 100 kali dengan kondisi yang sama
Interval ukur:  5 detik
Jendela amati:  warm-up 5 menit + beban 10 menit + idle 5 menit
Lingkungan:     staging / Release build / pengaturan setara produksi

Investigasi memori tidak bisa dinilai sambil melakukan operasi yang berbeda setiap kali. Kunci “apa yang dilakukan ketika naik.”

15.2 Ambil baseline

Jadikan keadaan setelah warm-up sebagai baseline, bukan tepat setelah start.

Alasannya, tepat setelah start ada kenaikan sekali seperti berikut.

  • JIT
  • Pembangunan container DI
  • Pemuatan pengaturan
  • Koneksi DB pertama
  • Koneksi TLS / HTTP pertama
  • Pembuatan metadata serializer JSON
  • Inisialisasi Razor / template
  • Inisialisasi logger dan metrik

Urutannya seperti ini.

1. Start aplikasi
2. Panggil health check dan API perwakilan beberapa kali
3. Tunggu sekitar 1–5 menit
4. Ambil counters dan dump sebagai baseline

15.3 Ambil counters saat beban

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output report-export-counters.csv \
  --counters System.Runtime

Jalankan operasi reproduksi secara paralel. Yang ingin dilihat adalah bentuk grafiknya.

Bentuk yang mendekati normal:
  naik saat beban
  naik-turun karena GC
  kembali setelah beban berhenti
  baseline tidak terus naik

Bentuk yang mencurigakan:
  naik sebanding jumlah operasi
  lantai Gen 2 / LOH naik
  tidak kembali setelah beban berhenti
  lantai naik lagi pada beban berikutnya

15.4 Ambil dump dua kali

Ambil sebelum dan sesudah beban.

dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# berikan beban
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp

Jika ada kapasitas, ambil juga setelah beban berhenti.

# setelah beban berhenti, antrean kosong, dan sudah menunggu beberapa waktu
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp

Saat membandingkan, bukan hanya before dan afteridle-after juga penting.

Meski naik saat beban, jika kembali setelah idle, itu mungkin bukan leak.

15.5 Lihat tipe yang bertambah

dotnet-dump analyze after.dmp
> dumpheap -stat

Lihat sisi before dengan cara yang sama. Boleh dikerjakan secara manual — mulai dengan membandingkan tipe di peringkat atas.

Sudut yang dilihat:

  • Apakah tipe di namespace sendiri yang bertambah
  • Apakah ada tipe sendiri di balik System.String
  • Siapa yang memegang System.Byte[]
  • Apakah List<T> atau Dictionary<TKey,TValue> yang bertambah
  • Apakah Task atau async state machine yang bertambah
  • Apakah Timer atau CancellationTokenSource yang bertambah

15.6 Lihat sumber referensi

Ambil alamat objek kandidat, lalu jalankan gcroot.

> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>

Dari hasil gcroot, cari induk yang menahan.

MyApp.Services.ReportCache
  -> Dictionary<string, ReportResult>
  -> ReportResult

Sampai di sini, sasaran tinjauan kode sudah terlihat.

  • Apakah ReportCache singleton
  • Apakah ada batas
  • Apakah dihapus
  • Apakah kuncinya terus bertambah
  • Apakah ReportResult terlalu besar
  • Apakah seharusnya dialihkan ke DB atau berkas, bukan cache

16. Situasi memakai dotnet-trace

dotnet-dump adalah snapshot suatu saat, cocok untuk melihat “apa yang akhirnya tertinggal.” Di sisi lain, jika ingin melihat “kapan dan di mana alokasi besar terjadi”, pakai dotnet-trace.

Misalnya, telusuri termasuk event terkait GC.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:01:00 \
  --clrevents gc+gchandle \
  --clreventlevel informational \
  --output gc-trace.nettrace

Jika ingin sampai sampling alokasi, volume event bertambah, jadi mulai dari durasi pendek di lingkungan verifikasi.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:00:30 \
  --clrevents gc+gcsampledobjectallocationhigh \
  --clreventlevel informational \
  --output allocation-trace.nettrace

Trace membantu dari sudut yang berbeda dari dump.

Yang ingin dilihat Alat yang cocok
Apa yang tertinggal dump / gcdump
Siapa yang mereferensikan dump + gcroot
Kapan alokasi besar terjadi trace
Kapan GC terjadi counters / trace
Apakah pause time yang bermasalah counters / trace

Dalam investigasi leak, efisien jika pertama melihat “yang tertinggal” dengan dump, lalu jika perlu melihat “tempat pembuatannya” dengan trace.

17. Mengeluarkan metrik verifikasi di kode

Diagnostik yang sungguh-sungguh sebaiknya dilakukan dengan alat eksternal, tetapi menaruh log diagnostik sederhana di sisi aplikasi tetap membantu.

Misalnya, cara mengeluarkan informasi GC lewat endpoint untuk admin atau log berkala.

public static class GcDiagnostics
{
    public static object Snapshot()
    {
        var info = GC.GetGCMemoryInfo();

        return new
        {
            TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
            HeapSizeBytes = info.HeapSizeBytes,
            FragmentedBytes = info.FragmentedBytes,
            MemoryLoadBytes = info.MemoryLoadBytes,
            HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
            Gen0Collections = GC.CollectionCount(0),
            Gen1Collections = GC.CollectionCount(1),
            Gen2Collections = GC.CollectionCount(2)
        };
    }
}

Informasi ini saja tidak cukup untuk menilai leak. Namun saat gangguan, penilaian berikut menjadi lebih mudah.

  • Apakah Gen 2 naik tajam
  • Apakah HeapSize naik
  • Apakah FragmentedBytes naik
  • Apakah selisih TotalMemory dan memori proses besar
  • Apakah tren berubah setelah deployment

Jika dimasukkan ke log aplikasi, perhatikan volumenya. Diagnostik berat pada frekuensi tinggi menjadi beban tersendiri.

18. Kriteria untuk menyatakan “memory leak”

Di akhir investigasi, buat agar dapat dijelaskan dalam bentuk berikut. Sebagai kerangka laporan, kolom yang harus diisi disebutkan lebih dulu.

Kolom Isi yang ditulis
Gejala Siapa yang kesulitan. Operasi mana yang membuatnya naik, dan seberapa besar
Kondisi observasi Lingkungan, konfigurasi build, volume data, jumlah eksekusi, waktu warm-up, interval observasi, alat dan versi yang dipakai
Observasi Bagaimana nilai counters bergerak. Tulis Working Set dan GC Heap secara terpisah
Perbandingan Di dump before dan after, tipe mana yang bertambah berapa instans
Sumber referensi Jalur retensi yang terlihat di gcroot
Penyebab Rancangan kode mana yang menghasilkan retensi itu
Tindakan Apa yang diubah. Bagaimana efeknya diukur
Sisa pekerjaan Jika perlu. Hal yang tidak terjelaskan oleh observasi kali ini, dan yang dilihat berikutnya

Jangan sampai kondisi observasi terlewat tertulis — itu yang paling penting. Jika kondisinya tidak tertulis, pengukuran ulang setelah perbaikan tidak bisa dibandingkan, dan verifikasi di bab 20 kehilangan makna.

Jika diisi mengikuti pola itu, misalnya menjadi seperti berikut.

Gejala:
  Setelah /api/report/export dijalankan 100 kali, GC Heap tetap naik 300MB
  dan tidak kembali meski beban sudah dihentikan.

Kondisi observasi:
  Lingkungan staging / Release build / pengaturan dan volume data setara produksi.
  Setelah warm-up 5 menit, jalankan 100 kali dengan kondisi yang sama, lalu idle 5 menit.
  dotnet-counters dikumpulkan dengan interval 5 detik.

Observasi:
  Di dotnet-counters, heap size Gen 2 naik sebanding jumlah operasi.
  Bukan hanya Working Set — GC Heap juga naik.

Perbandingan:
  Membandingkan before.dmp dan after.dmp, MyApp.Models.ReportResult bertambah 12.000 instans.

Sumber referensi:
  gcroot menunjukkan referensi dari MyApp.Services.ReportCache._items.

Penyebab:
  ReportCache adalah singleton, kuncinya user ID + waktu saat ini,
  tanpa penghapusan, kedaluwarsa, atau batas.

Tindakan:
  Diganti dengan MemoryCache, dipasang batas ukuran dan kedaluwarsa.
  Jumlah entri cache dijadikan metrik.

Jika bisa dijelaskan sampai sini, itu bukan lagi sekadar “memorinya naik” — laporan yang menghubungkan kondisi reproduksi, nilai observasi, tipe yang bertambah, sumber referensi, penyebab, dan tindakan.

19. Hal yang perlu diperhatikan saat investigasi

19.1 Lihat dengan Release build

Debug build bisa terlihat berbeda dari operasi nyata karena pengaruh optimisasi, umur variabel lokal, dan informasi debug.

Untuk investigasi setara produksi, verifikasi dengan Release build, pengaturan yang dekat dengan operasi, dan volume data yang dekat.

19.2 Jangan menilai hanya dari tepat setelah start

Tepat setelah start, memori naik karena berbagai inisialisasi.

Ambil baseline setelah warm-up, dan lihat apakah naik dari situ.

19.3 Jangan menjadikan pelaku dari satu dump

Tipe di peringkat atas heap tidak otomatis pelaku.

System.String dan System.Byte[] terlihat besar di banyak aplikasi.

Yang penting adalah apakah bertambah dengan selisih waktu, dan siapa yang memegangnya.

19.4 Dump berisi informasi rahasia

Memory dump bisa memuat request, kredensial, connection string, data pribadi, dan data bisnis.

Tetapkan aturan lokasi penyimpanan, pembawaan ke luar, berbagi, dan penghapusan.

19.5 Di container, pengambilan dump itu sendiri berisiko

Jika batas memori container ketat, memori tambahan dan page-in karena pengambilan dump bisa membuat container OOM Kill.

Sebelum mengambil di container produksi, coba di staging, dan periksa batas, kapasitas disk, izin, serta namespace PID.

19.6 Ada juga leak di luar GC Heap

Karena ini investigasi .NET, bukan berarti semuanya muncul di heap GC.

Pada masalah seperti berikut, memori proses bisa naik padahal GC Heap stabil.

  • Pustaka native
  • P/Invoke
  • COM
  • Pemrosesan gambar
  • Pustaka kompresi
  • Pemrosesan kriptografi
  • Driver DB
  • Socket
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • Terlalu banyak thread

Dalam kasus ini, dumpheap di dotnet-dump saja tidak cukup. Anda perlu melihat diagnostik sisi OS, metrik pustaka eksternal, handle, thread, dan memori native.

19.7 Hal yang diputuskan bersama pihak terkait sebelum mengambil dump di produksi

Pengambilan dump di lingkungan produksi, sebelum menjadi operasi teknis, adalah pekerjaan yang membutuhkan koordinasi. Jika dilewati dan hanya berkata “ini investigasi, izinkan kami mengambil”, biasanya berhenti di situ.

Pertama, rapikan hal yang harus dijelaskan sebagai dampak.

  • Pengambilan dump adalah operasi berat bagi proses sasaran, dan selama pengambilan respons kadang terlihat berhenti. Seberapa lama berhentinya bergantung pada ukuran heap, kecepatan disk, dan lingkungan, jadi jalankan prosedur yang sama satu kali di staging dan ukur secara nyata.
  • Jika waktu respons berhenti lama, itu bisa memicu timeout health check, pelepasan dari load balancer, atau failover cluster.
  • Dump Full atau Heap membesar sesuai pemakaian memori proses. Periksa dulu sisa kapasitas disk di lokasi penulisan.
  • Di container, page-in yang menyertai pengambilan dump bisa melewati batas memori dan memaksa container berhenti (19.5).
  • Dump bisa memuat data pribadi atau kredensial (19.4).

Setelah itu, putuskan hal berikut sebelum mengambil.

Yang diputuskan Contoh konkret
Siapa yang menyetujui Penanggung jawab layanan dan departemen sistem informasi. Ambil kesepakatan keduanya lebih dulu
Kapan diambil Di luar jam bisnis, atau jendela ketika jeda respons sementara masih bisa diterima
Ditulis ke mana Disk lokal yang sisa kapasitasnya cukup. Jangan tulis langsung ke folder bersama
Siapa yang boleh mengakses Batasi hak akses lokasi penyimpanan hanya ke petugas investigasi
Kapan dihapus Tetapkan dan catat lebih dulu tenggat penghapusan setelah investigasi selesai
Apa yang dilakukan jika gagal Prosedur restart jika respons tidak kembali, dan siapa yang memutuskan
Verifikasi awal Jalankan prosedur yang sama satu kali di staging, catat durasi dan ukuran berkas

Saat mengajukan permintaan, lebih andal menyerahkan isi ini dalam satu lembar, bukan hanya lisan. Contoh formatnya.

Tujuan:      mengidentifikasi penyebab memori yang tidak kembali setelah /api/report/export
Metode:      mengambil dotnet-dump collect --type Heap dua kali, sebelum dan sesudah beban
Dampak:      selama pengambilan, respons proses sasaran melambat.
             nilai terukur di staging dilampirkan di lembar terpisah
Waktu:       di luar jam bisnis. waktu ketika petugas dan tim sistem informasi bisa hadir
Lokasi tulis: disk lokal server sasaran. sisa kapasitas dicek lebih dulu
Isi:         data request di memori, connection string, dan semacamnya mungkin ikut
Penanganan:  hak akses lokasi penyimpanan hanya petugas investigasi. tidak dibawa ke luar
Penghapusan: hapus paling lambat dalam 1 bulan setelah investigasi selesai, sisakan catatan penghapusan
Rollback:    restart proses jika respons tidak kembali setelah pengambilan

Jika “untuk apa”, “berapa lama berhenti”, dan “diletakkan di mana, dihapus kapan” sudah lengkap, pihak yang memutuskan lebih mudah menjawab ya atau tidak. Sebaliknya, jika ini kabur, investigasi itu sendiri berhenti.

20. Verifikasi setelah perbaikan

Setelah memperbaiki bagian yang tampak leak, ukur ulang dengan prosedur yang sama.

Sebelum perbaikan:
  Setelah 100 kali dijalankan, Gen 2 +300MB
  ReportResult +12.000 instans

Setelah perbaikan:
  Setelah 100 kali dijalankan, Gen 2 stabil dalam +20MB
  ReportResult kembali ke baseline setelah beban berhenti
  Jumlah entri cache stabil di batas 1.000

Pada verifikasi perbaikan, selalu bandingkan pada kondisi yang sama.

  • Volume data yang sama
  • Jumlah eksekusi yang sama
  • Durasi beban yang sama
  • Warm-up yang sama
  • Interval observasi yang sama
  • Alat yang sama

Investigasi memori tidak meyakinkan jika perbandingan sebelum/sesudah perbaikannya lemah.

21. Ringkasan

Ketika memori naik di .NET, jangan langsung menyatakan leak — pilah dengan urutan berikut.

  1. Jangan menilai dari Working Set / RSS saja
  2. Lihat GC Heap, Gen 2, LOH, dan jumlah GC dengan dotnet-counters
  3. Bandingkan saat beban, setelah beban berhenti, dan dengan selisih waktu
  4. Lihat tipe yang bertambah dengan dotnet-gcdump atau dotnet-dump
  5. Lihat sumber referensi dengan gcroot
  6. Periksa static, cache, event, Timer, lifetime DI, konteks asinkron
  7. Jika GC Heap stabil, curigai juga memori native dan masalah sisi OS

Perbedaan “baru belum di-GC” dan “sedang memory leak”, pada akhirnya, ditentukan oleh referensi.

Jika objek yang sudah tidak dibutuhkan tidak direferensikan, ia dikumpulkan pada saat GC berjalan. Jika yang seharusnya tidak dibutuhkan masih direferensikan, GC tidak bisa mengumpulkannya.

Dengan kata lain, tujuan investigasi adalah ini.

Apa yang bertambah.
Apakah tertinggal setelah GC mana pun.
Siapa yang mereferensikannya.
Apakah referensi itu dibutuhkan oleh rancangan.

Jika sampai di sini sudah jelas, Anda tidak lagi diombang-ambingkan oleh grafik memori, dan bisa membawanya ke titik perbaikan di kode.

Referensi

Artikel terbaru dengan tag yang sama untuk mendalami topik-topik terdekat.

Halaman-halaman ini menempatkan topik dalam konteks layanan dan keputusan yang lebih luas.

Artikel ini berkaitan langsung dengan layanan berikut.

Pertanyaan yang sering diajukan

Pertanyaan yang sering muncul dalam konsultasi tentang topik artikel ini.

Jika pemakaian memori aplikasi .NET terus naik, apakah itu memory leak?
Naiknya memori proses dan adanya memory leak bukan hal yang sama. Di .NET, GC berjalan sambil melihat pola alokasi dan ambang heap, jadi objek yang sudah tidak dibutuhkan mungkin belum di-GC, atau OS mungkin tidak segera reclaim memori setelah GC. Yang perlu dilihat ada tiga: apakah memori yang bertahan setelah GC terus bertambah, tipe apa yang bertambah, dan siapa yang masih mereferensikan objek itu.
Alat apa yang dipakai untuk menyelidiki memory leak di .NET?
Mulai dengan dotnet-counters untuk melihat tren Working Set, GC Heap, Gen 2/LOH, dan jumlah GC. Lalu ambil GC dump sebelum dan sesudah beban dengan dotnet-gcdump, bandingkan Count dan Size per tipe, dan tentukan tipe yang bertambah. Terakhir ambil heap dump dengan dotnet-dump, lalu telusuri sampai sumber referensi memakai dumpheap -stat dan gcroot agar jelas mengapa objek tidak dikumpulkan. Yang penting bukan satu angka, melainkan perbandingan selisih waktu pada kondisi yang sama.
Pola memory leak apa yang paling sering muncul di .NET?
Contoh tipikalnya: terus menambah ke koleksi static, cache tanpa batas atau kedaluwarsa, event yang tidak di-unsubscribe dari publisher berumur panjang, Timer yang tidak di-Dispose, IDisposable yang tidak dilepas, dan kekeliruan lifetime DI ketika singleton menahan data per-request. Leak di .NET lebih mudah dipahami sebagai retensi yang tidak disengaja — objek tetap direferensikan meski sudah tidak dibutuhkan — daripada sebagai lupa melepaskan memori.
Apakah menjalankan GC.Collect() secara berkala menyelesaikan masalah memori?
Tidak. GC paksa hanya mengumpulkan objek yang belum sempat dikumpulkan; ia tidak menghapus akar masalah. Jika laju alokasi yang tinggi yang bermasalah, pause time justru naik dan kinerja memburuk. Jika leak sungguhan, objek yang masih punya referensi pun tidak dikumpulkan oleh GC paksa. Memeriksa apakah objek bertahan setelah GC paksa di lingkungan verifikasi yang terkendali sah untuk investigasi, tetapi sebelum memasukkan itu ke produksi sebagai solusi, selalu identifikasi dulu apa yang bertambah.

Profil penulis

Halaman perkenalan penulis artikel.

Go Komura

Direktur KomuraSoft LLC

Berspesialisasi dalam pengembangan perangkat lunak Windows, konsultasi teknis, dan investigasi bug, terutama pada proyek dengan sistem yang sudah ada dan bug yang sulit direproduksi.

Kembali ke blog