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:
- Apakah memori yang bertahan setelah GC terus bertambah
- Tipe apa yang bertambah
- 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.
flowchart LR
accTitle: Peta pengetahuan cara membedakan menunggu GC dan memory leak di .NET
accDescr: Diagram 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 gcroot
dotnet_garbage_collection["garbage collection .NET (GC)"]
dotnet_memory_leak["memory leak .NET (retensi yang tidak disengaja)"]
dotnet_counters["dotnet-counters"]
dotnet_gcdump["dotnet-gcdump"]
dotnet_dump["dotnet-dump"]
gcroot_command["perintah gcroot"]
gc_heap["heap GC (heap terkelola)"]
dumpheap_command["perintah dumpheap -stat"]
dotnet_trace["dotnet-trace"]
gen2_heap["Gen 2 (heap generasi ke-2)"]
total_allocated_memory["Total Allocated (alokasi kumulatif)"]
working_set_rss["Working Set / RSS"]
induced_gc["GC paksa (Induced Collection)"]
static_collection_leak["retensi lewat koleksi static"]
unbounded_cache["cache tanpa batas dan tanpa kedaluwarsa"]
event_subscription_leak["kebocoran event subscription (tanpa unsubscribe)"]
timer_disposal_leak["kebocoran Timer yang tidak di-dispose"]
idisposable_leak["kebocoran IDisposable yang tidak di-dispose"]
di_lifetime_mismatch["kekeliruan lifetime DI"]
large_object_heap["LOH (Large Object Heap)"]
dotnet_framework[".NET Framework"]
dotnet[".NET (Core dan setelahnya)"]
dotnet_garbage_collection -->|"diverifikasi dengan"| dotnet_counters
dotnet_memory_leak -->|"diverifikasi dengan"| dotnet_gcdump
dotnet_memory_leak -->|"diverifikasi dengan"| dotnet_dump
dotnet_memory_leak -->|"diverifikasi dengan"| gcroot_command
gc_heap -->|"diverifikasi dengan"| dumpheap_command
dotnet_memory_leak -.->|"diverifikasi dengan"| dotnet_trace
dotnet_memory_leak -->|"diverifikasi dengan"| gen2_heap
total_allocated_memory -->|"tidak disarankan"| dotnet_memory_leak
working_set_rss -->|"tidak disarankan"| dotnet_memory_leak
induced_gc -->|"tidak disarankan"| dotnet_memory_leak
static_collection_leak -.->|"dapat menyebabkan"| dotnet_memory_leak
unbounded_cache -.->|"dapat menyebabkan"| dotnet_memory_leak
event_subscription_leak -.->|"dapat menyebabkan"| dotnet_memory_leak
timer_disposal_leak -.->|"dapat menyebabkan"| dotnet_memory_leak
idisposable_leak -.->|"dapat menyebabkan"| dotnet_memory_leak
di_lifetime_mismatch -.->|"dapat menyebabkan"| dotnet_memory_leak
large_object_heap -.->|"dapat menyebabkan"| dotnet_memory_leak
dotnet_counters -->|"sebaiknya didahului"| dotnet_gcdump
dotnet_gcdump -->|"sebaiknya didahului"| dotnet_dump
dotnet_counters -->|"tidak kompatibel"| dotnet_framework
dotnet_dump -->|"tidak kompatibel"| dotnet_framework
dotnet_garbage_collection -->|"menggunakan"| gc_heap
dotnet_garbage_collection -->|"mensyaratkan"| dotnet
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.
flowchart TB
accTitle: Hubungan metrik memori proses, heap managed, dan Working Set
accDescr: Diagram 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 fisik
PROC["Memori yang dipakai proses"] --> MANAGED["Sisi managed<br/>Cakupan yang terlihat di GC Heap Size"]
PROC --> NATIVE["Sisi native<br/>Cakupan yang tidak muncul di GC Heap Size"]
MANAGED --> G01["Gen 0 / Gen 1<br/>Alokasi berumur pendek"]
MANAGED --> G2["Gen 2<br/>Objek berumur panjang yang bertahan"]
MANAGED --> LOH["LOH<br/>Objek besar 85,000 byte atau lebih"]
MANAGED --> POH["POH<br/>Objek yang di-pin"]
NATIVE --> STK["Stack thread"]
NATIVE --> JITC["Kode hasil JIT, assembly yang sudah dimuat"]
NATIVE --> INTEROP["Buffer P/Invoke, COM, pustaka eksternal"]
MANAGED -.dihitung ke.-> COMMIT["Private Bytes / Commit<br/>Memori committed milik proses"]
NATIVE -.dihitung ke.-> COMMIT
COMMIT -.hanya porsi yang ada di memori fisik.-> WS["Working Set / RSS"]
Dua hal yang perlu dipegang dari diagram ini.
- Yang terlihat di
dumpheaphanya sisi managed. Jika yang naik adalah sisi native, menatap heap sepanjang hari pun pelakunya tidak akan muncul. - 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.
- Sering membuat objek besar
- Menahan objek besar dalam waktu lama
- 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 operasiSystem.Char[]atau instansStringraksasa bertambah- Tidak kembali setelah pemrosesan gambar, PDF, Excel, ZIP, kriptografi, atau kompresi
- Array dari
ArrayPool<T>.Renttidak 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 after — idle-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>atauDictionary<TKey,TValue>yang bertambah - Apakah
Taskatau async state machine yang bertambah - Apakah
TimeratauCancellationTokenSourceyang 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
ReportCachesingleton - 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.AllocHGlobalNativeMemory.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
FullatauHeapmembesar 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.
- Jangan menilai dari Working Set / RSS saja
- Lihat GC Heap, Gen 2, LOH, dan jumlah GC dengan
dotnet-counters - Bandingkan saat beban, setelah beban berhenti, dan dengan selisih waktu
- Lihat tipe yang bertambah dengan
dotnet-gcdumpataudotnet-dump - Lihat sumber referensi dengan
gcroot - Periksa static, cache, event, Timer, lifetime DI, konteks asinkron
- 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
- Satu set kode sampel artikel ini (pustaka, demo, unit test) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
- .NET Framework: Performance counters
- .NET Framework: SOS.dll (SOS Debugging Extension)
Artikel terkait
Artikel terbaru dengan tag yang sama untuk mendalami topik-topik terdekat.
Proksi perusahaan dan aplikasi Windows — merapikan resolusi proksi di WinINET, WinHTTP, dan .NET
Peramban tembus, tetapi hanya aplikasi bisnis yang tidak melewati proksi perusahaan. Penyebabnya biasanya ketidaksesuaian pengaturan prok...
Praktik terbaik multithreading di lapangan: edisi .NET — apa yang diputuskan sebelum menambah thread
Rangkuman praktis aturan desain yang mencegah kode multithread .NET/C# sesekali crash atau macet: bertumpu pada Task alih-alih membuat th...
Memakai WMI/CIM dari C# dan PowerShell — panduan praktis pengambilan info perangkat keras, pemantauan proses, dan kueri jarak jauh
Cara standar mengambil nomor seri PC, memantau ruang disk, dan mendeteksi proses yang start adalah WMI/CIM. Artikel ini menjelaskan pemak...
Kedalaman I/O Windows (bagian 5) — interior NTFS: memahami sistem file lewat MFT
Bagian 5 seri yang menjelaskan interior NTFS dengan gambar. Menata MFT dan rekaman file, beberapa data stream (Zone.Identifier), hard lin...
Kedalaman I/O Windows (bagian 4) — Cache Manager: kapan WriteFile Anda sampai ke disk
Bagian 4 seri yang menjelaskan Cache Manager Windows dengan gambar. Menata cache yang diimplementasikan sebagai file mapping, read-ahead ...
Topik terkait
Halaman-halaman ini menempatkan topik dalam konteks layanan dan keputusan yang lebih luas.
Topik teknis Windows
Portal tentang pengembangan Windows, investigasi bug, dan pemanfaatan aset yang ada.
Layanan yang terkait dengan topik ini
Artikel ini berkaitan langsung dengan layanan berikut.
Pengembangan aplikasi Windows
Aplikasi bisnis, integrasi perangkat, dan alat komunikasi, dari kebutuhan hingga pengembangan.
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.