Bagikan melalui


Tumpukan objek besar pada sistem Windows

Pengumpul sampah .NET (GC) membagi objek menjadi objek kecil dan besar. Ketika objek besar, beberapa atributnya menjadi lebih signifikan daripada jika objeknya kecil. Misalnya, memadatkannya—yaitu, menyalinnya ke memori di tempat lain di tumpukan—bisa jadi mahal. Karena itu, pengumpul sampah menempatkan objek besar di tumpukan objek besar (LOH). Artikel ini membahas hal yang memenuhi syarat sebuah objek sebagai objek besar, bagaimana objek besar dikumpulkan, dan implikasi performa seperti apa yang diterapkan objek besar.

Penting

Artikel ini membahas tumpukan objek besar di .NET Framework dan .NET Core yang hanya berjalan di sistem Windows. Artikel ini tidak mencakup LOH yang berjalan pada implementasi .NET di platform lain.

Bagaimana sebuah objek berakhir di LOH

Jika sebuah objek berukuran lebih dari atau sama dengan 85.000 byte, objek tersebut dianggap sebagai objek besar. Jumlah ini ditentukan oleh penyetelan performa. Ketika permintaan alokasi objek adalah untuk 85.000 byte atau lebih, runtime mengalokasikannya pada tumpukan objek besar.

Untuk memahami artinya, ada baiknya untuk memeriksa beberapa dasar tentang pengumpul sampah.

Pengumpul sampah adalah pengumpul generasi. Pengumpul ini memiliki tiga generasi: generasi 0, generasi 1, dan generasi 2. Alasan untuk memiliki tiga generasi adalah bahwa, dalam aplikasi yang disetel dengan baik, sebagian besar objek mati di gen0. Misalnya, dalam aplikasi server, alokasi yang terkait dengan setiap permintaan akan mati setelah permintaan selesai. Permintaan alokasi dalam penerbangan akan membuatnya menjadi gen1 dan mati di sana. Pada dasarnya, gen1 bertindak sebagai penyangga antara area objek muda dan area objek berumur panjang.

Objek yang baru dialokasikan membentuk objek generasi baru dan secara implisit merupakan koleksi generasi 0. Namun, jika objek berukuran besar, objek akan masuk ke timbunan objek besar (LOH), yang kadang kala disebut sebagai generasi 3. Generasi 3 adalah generasi fisik yang dikumpulkan secara logis sebagai bagian dari generasi 2.

Objek besar adalah milik generasi 2 karena mereka dikumpulkan hanya selama pengumpulan generasi 2. Ketika satu generasi dikumpulkan, semua generasi mudanya juga dikumpulkan. Misalnya, ketika generasi 1 GC dikumpulkan, generasi 1 dan 0 akan dikumpulkan. Dan ketika GC generasi 2 dikumpulkan, seluruh tumpukan akan dikumpulkan. Karena alasan ini, GC generasi 2 juga disebut GC penuh. Artikel ini mengacu pada GC generasi 2, bukan GC penuh, tetapi istilahnya dapat dipertukarkan.

Generasi memberikan tampilan logis terkait tumpukan GC. Secara fisik, objek hidup dalam segmen tumpukan terkelola. Segmen tumpukan terkelola adalah bongkahan memori yang disimpan GC dari OS dengan memanggil fungsi VirtualAlloc atas nama kode terkelola. Ketika CLR dimuat, GC mengalokasikan dua segmen tumpukan awal: satu untuk objek kecil (tumpukan objek kecil, atau SOH), dan satu untuk objek besar (tumpukan objek besar).

Permintaan alokasi kemudian dipenuhi dengan menempatkan objek terkelola pada segmen tumpukan terkelola ini. Jika objek kurang dari 85.000 byte, objek diletakkan di segmen untuk SOH; jika tidak, objek diletakkan di segmen LOH. Segmen diterapkan (dalam potongan yang lebih kecil) karena semakin banyak objek yang dialokasikan ke dalamnya. Untuk SOH, objek yang bertahan dari GC dipromosikan ke generasi berikutnya. Objek yang bertahan dari koleksi generasi 0 sekarang dianggap sebagai objek generasi 1, dan seterusnya. Namun, objek yang bertahan pada generasi tertua masih dianggap berada pada generasi tertua. Dengan kata lain, objek yang selamat dari generasi 2 adalah objek generasi 2; dan yang selamat dari LOH adalah objek LOH (yang dikumpulkan dengan gen2).

Kode pengguna hanya dapat mengalokasikan di generasi 0 (objek kecil) atau LOH (objek besar). Hanya GC yang dapat "mengalokasikan" objek di generasi 1 (dengan mempromosikan objek yang selamat dari generasi 0) dan generasi 2 (dengan mempromosikan objek yang selamat dari generasi 1).

Saat pengumpulan sampah dipicu, GC menelusuri objek langsung dan memadatkannya. Tetapi karena pemadatan itu mahal, GC menyapu LOH; hal ini membuat daftar gratis dari objek mati yang dapat digunakan kembali nanti untuk memenuhi permintaan alokasi objek besar. Objek mati yang berdekatan dibuat menjadi satu objek bebas.

.NET Core dan .NET Framework (mulai dari .NET Framework 4.5.1) menyertakan properti GCSettings.LargeObjectHeapCompactionMode yang memungkinkan pengguna menentukan bahwa LOH harus dipadatkan selama GC pemblokiran penuh berikutnya. Dan di masa mendatang, .NET mungkin memutuskan untuk memadatkan LOH secara otomatis. Artinya, jika Anda mengalokasikan objek besar dan ingin memastikan objek tersebut tidak bergerak, Anda tetap harus menyematkannya.

Gambar 1 mengilustrasikan skenario dimana GC membentuk generasi 1 setelah GC generasi pertama 0 dimana Obj1 dan Obj3 mati, dan membentuk generasi 2 setelah GC generasi pertama 1 dimana Obj2 dan Obj5 mati. Perhatikan bahwa ini dan angka-angka berikut hanya untuk tujuan ilustrasi; mereka berisi sangat sedikit objek untuk menunjukkan dengan lebih baik apa yang terjadi di tumpukan. Pada kenyataannya, biasanya lebih banyak objek terlibat dalam GC.

Figure 1: A gen 0 GC and a gen 1 GC
Gambar 1: GC generasi 0 dan generasi 1.

Gambar 2 menunjukkan bahwa setelah GC generasi 2 yang melihat bahwa Obj1 dan Obj2 mati, GC membentuk ruang bebas bersebelahan dari memori yang dulunya ditempati oleh Obj1 dan Obj2, yang kemudian digunakan untuk memenuhi permintaan alokasi untuk Obj4. Spasi setelah objek terakhir, Obj3, hingga akhir segmen juga dapat digunakan untuk memenuhi permintaan alokasi.

Figure 2: After a gen 2 GC
Gambar 2: Setelah GC generasi 2

Jika tidak ada cukup ruang kosong untuk mengakomodasi permintaan alokasi objek yang besar, GC pertama-tama akan mencoba memperoleh lebih banyak segmen dari OS. Jika gagal, ini akan memicu GC generasi 2 dengan harapan membebaskan beberapa ruang.

Selama GC generasi 1 atau generasi 2, pengumpul sampah melepaskan segmen yang tidak memiliki objek langsung di dalamnya kembali ke OS dengan memanggil fungsi VirtualFree. Spasi setelah objek hidup terakhir hingga akhir segmen dinonaktifkan (kecuali pada segmen sementara di mana gen0/gen1 hidup, di mana pengumpul sampah menyimpan beberapa komitmen karena aplikasi Anda akan langsung mengalokasikan di dalamnya). Dan ruang kosong tetap diterapkan meskipun diatur ulang, artinya OS tidak perlu menulis data di dalamnya kembali ke disk.

Karena LOH hanya dikumpulkan selama GC generasi 2, segmen LOH hanya dapat dibebaskan selama GC tersebut. Gambar 3 mengilustrasikan skenario di mana pengumpul sampah melepaskan satu segmen (segmen 2) kembali ke OS dan membatalkan penerapan lebih banyak ruang pada segmen yang tersisa. Jika perlu menggunakan ruang yang dibatalkan penerapannya di akhir segmen untuk memenuhi permintaan alokasi objek yang besar, memori akan diterapkan lagi. (Untuk penjelasan tentang penerapan/penolakan, lihat dokumentasi untuk VirtualAlloc.)

Figure 3: LOH after a gen 2 GC
Gambar 3: LOH setelah GC generasi 2

Kapan objek besar dikumpulkan?

Secara umum, GC terjadi di bawah salah satu dari tiga kondisi berikut:

  • Alokasi melebihi generasi 0 atau ambang objek besar.

    Ambang batas adalah properti dari satu generasi. Ambang batas untuk satu generasi ditetapkan saat pengumpul sampah mengalokasikan objek ke dalamnya. Ketika ambang batas terlampaui, GC dipicu pada generasi tersebut. Saat mengalokasikan objek kecil atau besar, Anda masing-masing mengonsumsi ambang generasi 0 dan LOH. Ketika pengumpul sampah mengalokasikan ke generasi 1 dan 2, pengumpul sampah akan menghabiskan ambang batasnya. Ambang batas ini disetel secara dinamis saat program berjalan.

    Ini adalah kasus yang umum; sebagian besar GC terjadi karena alokasi pada tumpukan terkelola.

  • Metode GC.Collect dipanggil.

    GC.Collect() tanpa parameter dipanggil atau kelebihan beban lain diteruskan GC.MaxGeneration sebagai argumen, LOH dikumpulkan bersama dengan tumpukan terkelola lainnya.

  • Sistem dalam situasi memori rendah.

    Ini terjadi ketika pengumpul sampah menerima pemberitahuan memori tinggi dari OS. Jika pengumpul sampah berpikir bahwa melakukan GC generasi 2 akan produktif, pengumpul sampah akan memicunya.

Implikasi performa LOH

Alokasi pada tumpukan objek besar memengaruhi performa dengan cara berikut.

  • Biaya alokasi.

    CLR membuat jaminan bahwa memori untuk setiap objek baru yang diberikannya akan dihapus. Ini berarti biaya alokasi objek besar didominasi oleh pembersihan memori (kecuali jika memicu GC). Jika dibutuhkan dua siklus untuk menghapus satu byte, dibutuhkan 170.000 siklus untuk menghapus objek besar terkecil. Menghapus memori objek 16-MB pada mesin 2-GHz membutuhkan waktu sekitar 16 ms. Itu biaya yang cukup besar.

  • Biaya pengumpulan.

    Karena LOH dan generasi 2 dikumpulkan bersama, jika salah satu ambang batas terlampaui, pengumpulan generasi 2 akan dipicu. Jika pengumpulan generasi 2 dipicu karena LOH, generasi 2 tidak akan jauh lebih kecil setelah GC. Jika tidak banyak data pada generasi 2, dampaknya akan minim. Tetapi jika generasi 2 besar, dapat menyebabkan masalah performa jika banyak GC generasi 2 dipicu. Jika banyak objek besar dialokasikan secara sementara dan Anda memiliki SOH yang besar, Anda bisa menghabiskan terlalu banyak waktu untuk mengerjakan GC. Selain itu, biaya alokasi dapat benar-benar bertambah jika Anda terus mengalokasikan dan melepaskan objek yang sangat besar.

  • Elemen array dengan jenis referensi.

    Objek yang sangat besar di LOH biasanya berupa array (sangat jarang memiliki objek instan yang sangat besar). Jika elemen array kaya akan referensi, maka akan timbul biaya yang tidak ada jika elemen tidak kaya referensi. Jika elemen tidak berisi referensi apa pun, pengumpul sampah tidak perlu menelusuri array sama sekali. Misalnya, jika Anda menggunakan array untuk menyimpan node dalam pohon biner, salah satu cara untuk mengimplementasikannya adalah dengan merujuk ke node kanan dan kiri node dengan node yang sebenarnya:

    class Node
    {
       Data d;
       Node left;
       Node right;
    };
    
    Node[] binary_tr = new Node [num_nodes];
    

    Jika num_nodes besar, pengumpul sampah harus melalui setidaknya dua referensi per elemen. Pendekatan alternatifnya adalah dengan menyimpan indeks node kanan dan kiri:

    class Node
    {
       Data d;
       uint left_index;
       uint right_index;
    } ;
    

    Daripada mereferensikan data node kiri sebagai left.d, Anda dapat mereferensikan sebagai binary_tr[left_index].d. Dan pengumpul sampah tidak perlu melihat referensi apa pun untuk node kiri dan kanan.

Dari ketiga faktor tersebut, dua yang pertama biasanya lebih signifikan daripada yang ketiga. Oleh karena itu, sebaiknya Anda mengalokasikan kumpulan objek besar yang digunakan kembali, bukan mengalokasikan yang sementara.

Mengumpulkan data performa untuk LOH

Sebelum mengumpulkan data performa untuk area tertentu, Anda harus sudah melakukan hal berikut:

  1. Menemukan bukti bahwa Anda harus melihat area ini.
  2. Menghabiskan area lain yang Anda ketahui tanpa menemukan apa pun yang dapat menjelaskan masalah performa yang Anda lihat.

Untuk informasi selengkapnya tentang dasar-dasar memori dan CPU, lihat blog Memahami masalahnya sebelum mencoba mencari solusi.

Anda dapat menggunakan alat berikut untuk mengumpulkan data tentang performa LOH:

Penghitung kinerja Memori CLR .NET

Penghitung kinerja Memori CLR .NET biasanya merupakan langkah pertama yang baik dalam menyelidiki masalah performa (meskipun kami sarankan Anda menggunakan peristiwa ETW). Cara yang sering digunakan untuk melihat penghitung kinerja adalah dengan Pemantauan Performa (perfmon.exe). Pilih Tambahkan (Ctrl + A) untuk menambahkan penghitung menarik untuk proses yang Anda pedulikan. Anda dapat menyimpan data penghitung kinerja ke file log.

Dua penghitung berikut dalam kategori .NET CLR Memory relevan untuk LOH:

  • # Koleksi Gen 2

    Menampilkan berapa kali GC generasi 2 telah terjadi sejak proses dimulai. Penghitung bertambah pada akhir pengumpulan generasi 2 (juga disebut pengumpulan sampah penuh). Penghitung ini menampilkan nilai pengamatan terakhir.

  • Ukuran Tumpuk Objek Besar

    Menampilkan ukuran saat ini, dalam byte, termasuk ruang kosong, dari LOH. Penghitung ini diperbarui di akhir pengumpulan sampah, bukan di setiap alokasi.

Screenshot that shows adding counters in Performance Monitor.

Anda juga dapat mengkueri penghitung kinerja secara terprogram menggunakan PerformanceCounter kelas . Untuk LOH, tentukan ".NET CLR Memory" sebagai CategoryName dan "Ukuran Tumpukan Objek Besar" sebagai CounterName.

PerformanceCounter performanceCounter = new()
{
    CategoryName = ".NET CLR Memory",
    CounterName = "Large Object Heap size",
    InstanceName = "<instance_name>"
};

Console.WriteLine(performanceCounter.NextValue());

Adalah umum untuk mengumpulkan penghitung secara terprogram sebagai bagian dari proses pengujian rutin. Ketika Anda menemukan penghitung dengan nilai yang tidak biasa, gunakan cara lain untuk mendapatkan data yang lebih rinci untuk membantu penyelidikan.

Catatan

Kami menyarankan agar Anda menggunakan peristiwa ETW alih-alih penghitung kinerja, karena ETW memberikan informasi yang jauh lebih kaya.

Aktivitas ETW

Pengumpul sampah menyediakan rangkaian peristiwa ETW yang kaya untuk membantu Anda memahami apa yang dilakukan tumpukan dan alasannya. Postingan blog berikut menunjukkan cara mengumpulkan dan memahami peristiwa GC dengan ETW:

Untuk mengidentifikasi GC generasi 2 yang berlebihan yang disebabkan oleh alokasi LOH sementara, lihat kolom Alasan Pemicu untuk GC. Untuk pengujian sederhana yang hanya mengalokasikan objek besar sementara, Anda dapat mengumpulkan informasi tentang peristiwa ETW dengan perintah PerfView berikut:

perfview /GCCollectOnly /AcceptEULA /nogui collect

Hasilnya adalah seperti ini:

Screenshot that shows ETW events in PerfView.

Seperti yang Anda lihat, semua GC adalah GC generasi 2, dan semuanya dipicu oleh AllocLarge, yang berarti bahwa mengalokasikan objek besar memicu GC ini. Kita tahu bahwa alokasi ini bersifat sementara karena kolom % Tingkat Kelangsungan Hidup LOH menyatakan 1%.

Anda dapat mengumpulkan peristiwa ETW tambahan yang memberi tahu Anda siapa yang mengalokasikan objek besar ini. Baris perintah berikut:

perfview /GCOnly /AcceptEULA /nogui collect

mengumpulkan peristiwa AllocationTick, yang dipicu kira-kira setiap alokasi senilai 100rb. Dengan kata lain, peristiwa dipicu setiap kali sebuah objek besar dialokasikan. Anda kemudian dapat melihat salah satu tampilan GC Heap Alloc, yang menunjukkan kepada Anda callstacks yang mengalokasikan objek besar:

Screenshot that shows a garbage collector heap view.

Seperti yang Anda lihat, ini adalah pengujian yang sangat sederhana yang hanya mengalokasikan objek besar dari metode Main-nya.

Debugger

Jika semua yang Anda miliki hanyalah dump memori dan Anda perlu melihat objek apa yang sebenarnya ada di LOH, Anda dapat menggunakan ekstensi debugger SoS yang disediakan oleh .NET.

Catatan

Perintah penelusuran kesalahan yang disebutkan di bagian ini berlaku untuk debugger Windows.

Berikut ini adalah contoh output dari analisis LOH:

0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
sdgeneration 1 starts at 0x013e1b6c
generation 2 starts at 0x013e1000
ephemeral segment allocation context: none
segment   begin allocated     size
0018f2d0 790d5588 790f4b38 0x0001f5b0(128432)
013e0000 013e1000 013e35f8 0x000025f8(9720)
Large object heap starts at 0x023e1000
segment   begin allocated     size
023e0000 023e1000 033db630 0x00ffa630(16754224)
033e0000 033e1000 043cdf98 0x00fecf98(16699288)
043e0000 043e1000 05368b58 0x00f87b58(16284504)
Total Size 0x2f90cc8(49876168)
------------------------------
GC Heap Size 0x2f90cc8(49876168)
0:003> !dumpheap -stat 023e1000 033db630
total 133 objects
Statistics:
MT   Count   TotalSize Class Name
001521d0       66     2081792     Free
7912273c       63     6663696 System.Byte[]
7912254c       4     8008736 System.Object[]
Total 133 objects

Ukuran tumpukan LOH adalah (16,754,224 + 16,699,288 + 16,284,504) = 49,738,016 byte. Antara alamat 023e1000 dan 033db630, 8.008.736 byte ditempati oleh array objek System.Object, 6.663.696 byte ditempati oleh array objek System.Byte, dan 2.081.792 byte ditempati oleh ruang kosong.

Terkadang, debugger menunjukkan bahwa ukuran total LOH kurang dari 85.000 byte. Hal ini terjadi karena runtime itu sendiri menggunakan LOH untuk mengalokasikan beberapa objek yang lebih kecil dari objek yang besar.

Karena LOH tidak dipadatkan, terkadang LOH dianggap sebagai sumber fragmentasi. Fragmentasi berarti:

  • Fragmentasi tumpukan terkelola, yang ditunjukkan oleh jumlah ruang kosong di antara objek yang dikelola. Di SoS, perintah !dumpheap –type Free menampilkan jumlah ruang kosong di antara objek yang dikelola.

  • Fragmentasi ruang alamat memori virtual (VM), yang merupakan memori yang ditandai sebagai MEM_FREE. Anda bisa mendapatkannya dengan menggunakan berbagai perintah debugger di windbg.

    Contoh berikut menunjukkan fragmentasi di ruang VM:

    0:000> !address
    00000000 : 00000000 - 00010000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    00010000 : 00010000 - 00002000
    Type     00020000 MEM_PRIVATE
    Protect 00000004 PAGE_READWRITE
    State   00001000 MEM_COMMIT
    Usage   RegionUsageEnvironmentBlock
    00012000 : 00012000 - 0000e000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    … [omitted]
    -------------------- Usage SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Pct(Busy)   Usage
    701000 (   7172) : 00.34%   20.69%   : RegionUsageIsVAD
    7de15000 ( 2062420) : 98.35%   00.00%   : RegionUsageFree
    1452000 (   20808) : 00.99%   60.02%   : RegionUsageImage
    300000 (   3072) : 00.15%   08.86%   : RegionUsageStack
    3000 (     12) : 00.00%   00.03%   : RegionUsageTeb
    381000 (   3588) : 00.17%   10.35%   : RegionUsageHeap
    0 (       0) : 00.00%   00.00%   : RegionUsagePageHeap
    1000 (       4) : 00.00%   00.01%   : RegionUsagePeb
    1000 (       4) : 00.00%   00.01%   : RegionUsageProcessParametrs
    2000 (       8) : 00.00%   00.02%   : RegionUsageEnvironmentBlock
    Tot: 7fff0000 (2097088 KB) Busy: 021db000 (34668 KB)
    
    -------------------- Type SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    7de15000 ( 2062420) : 98.35%   : <free>
    1452000 (   20808) : 00.99%   : MEM_IMAGE
    69f000 (   6780) : 00.32%   : MEM_MAPPED
    6ea000 (   7080) : 00.34%   : MEM_PRIVATE
    
    -------------------- State SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    1a58000 (   26976) : 01.29%   : MEM_COMMIT
    7de15000 ( 2062420) : 98.35%   : MEM_FREE
    783000 (   7692) : 00.37%   : MEM_RESERVE
    
    Largest free region: Base 01432000 - Size 707ee000 (1843128 KB)
    

Fragmentasi VM lebih umum terjadi karena objek besar sementara yang mengharuskan pengumpul sampah untuk sering memperoleh segmen tumpukan terkelola baru dari OS dan melepaskan yang kosong kembali ke OS.

Untuk memverifikasi apakah LOH menyebabkan fragmentasi VM, Anda dapat mengatur breakpoint di VirtualAlloc dan VirtualFree untuk melihat siapa yang memanggil mereka. Misalnya, untuk melihat siapa yang mencoba mengalokasikan potongan memori virtual yang lebih besar dari 8 MB dari OS, Anda dapat mengatur breakpoint seperti ini:

bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"

Perintah ini masuk ke debugger dan menampilkan tumpukan panggilan hanya jika VirtualAlloc dipanggil dengan ukuran alokasi lebih besar dari 8 MB (0x800000).

CLR 2.0 menambahkan fitur yang disebut VM Hoarding yang dapat berguna untuk skenario di mana segmen (termasuk pada tumpukan objek besar dan kecil) sering diperoleh dan dirilis. Untuk menentukan VM Hoarding, Anda menentukan tanda mulai yang disebut STARTUP_HOARD_GC_VM melalui API hosting. Sebagai ganti melepaskan segmen kosong kembali ke OS, CLR membatalkan penerapan memori pada segmen ini dan menempatkannya pada daftar siaga. (Perhatikan bahwa CLR tidak melakukan ini untuk segmen yang terlalu besar.) CLR kemudian menggunakan segmen tersebut untuk memenuhi permintaan segmen baru. Lain kali aplikasi Anda membutuhkan segmen baru, CLR menggunakannya dari daftar siaga ini jika dapat menemukan yang cukup besar.

VM hoarding juga berguna untuk aplikasi yang ingin mempertahankan segmen yang telah diperolehnya, seperti beberapa aplikasi server yang merupakan aplikasi dominan yang berjalan di sistem, untuk menghindari pengecualian kehabisan memori.

Kami sangat menyarankan Anda untuk menguji aplikasi Anda dengan hati-hati saat menggunakan fitur ini guna memastikan aplikasi Anda memiliki penggunaan memori yang cukup stabil.