Dela via


Den stora objekthögen på Windows-system

.NET-skräpinsamlaren (GC) delar upp objekt i små och stora objekt. När ett objekt är stort blir vissa av dess attribut mer betydande än om objektet är litet. Till exempel kan det vara dyrt att komprimera den – det vill sa att kopiera den i minnet någon annanstans på heapen . På grund av detta placerar skräpinsamlaren stora objekt på den stora objekthögen (LOH). I den här artikeln beskrivs vad som kvalificerar ett objekt som ett stort objekt, hur stora objekt samlas in och vilken typ av prestanda som stora objekt medför.

Viktigt!

I den här artikeln beskrivs den stora objekthögen i .NET Framework och .NET Core som endast körs på Windows-system. Den omfattar inte den LOH som körs på .NET-implementeringar på andra plattformar.

Hur ett objekt hamnar på LOH

Om ett objekt är större än eller lika med 85 000 byte i storlek anses det vara ett stort objekt. Det här talet fastställdes av prestandajustering. När en objektallokeringsbegäran är för 85 000 eller fler byte allokerar körningen den på den stora objekthögen.

För att förstå vad detta innebär är det användbart att undersöka några grunderna om skräpinsamlaren.

Skräpinsamlaren är en generationsinsamlare. Den har tre generationer: generation 0, generation 1 och generation 2. Anledningen till att ha tre generationer är att de flesta objekt dör i gen0 i en väljusterad app. I en serverapp bör till exempel allokeringarna som är associerade med varje begäran dö när begäran har slutförts. Begäranden om allokering under flygning kommer att göra det till gen1 och dö där. I huvudsak fungerar gen1 som en buffert mellan unga objektområden och långlivade objektområden.

Nyligen allokerade objekt utgör en ny generation av objekt och är implicit generation 0-samlingar. Men om de är stora objekt går de på den stora objekthögen (LOH), som ibland kallas för generation 3. Generation 3 är en fysisk generation som samlas in logiskt som en del av generation 2.

Stora objekt tillhör generation 2 eftersom de endast samlas in under en samling av generation 2. När en generation samlas in samlas även alla dess yngre generationer in. När till exempel en GC av generation 1 inträffar samlas både generation 1 och 0 in. Och när en generation 2 GC inträffar samlas hela heapen in. Därför kallas även en GC av generation 2 för en fullständig GC. Den här artikeln refererar till generation 2 GC i stället för fullständig GC, men villkoren är utbytbara.

Generationer ger en logisk vy över GC-heapen. Fysiskt finns objekt i hanterade heapsegment. Ett hanterat heapsegment är ett minnessegment som GC reserverar från operativsystemet genom att anropa funktionen VirtualAlloc för den hanterade kodens räkning. När CLR läses in allokerar GC två inledande heapsegment: ett för små objekt (den lilla objekthögen eller SOH) och ett för stora objekt (den stora objekthögen).

Allokeringsbegäranden uppfylls sedan genom att hanterade objekt placeras i dessa hanterade heapsegment. Om objektet är mindre än 85 000 byte placeras det i segmentet för SOH. annars placeras det på ett LOH-segment. Segment checkas in (i mindre segment) när fler och fler objekt allokeras till dem. För SOH befordras objekt som överlever en GC till nästa generation. Objekt som överlever en generation 0-samling betraktas nu som objekt i generation 1 och så vidare. Objekt som överlever den äldsta generationen anses dock fortfarande vara i den äldsta generationen. Med andra ord är överlevande från generation 2 objekt i generation 2; och överlevande från LOH är LOH-objekt (som samlas in med gen2).

Användarkod kan bara allokeras i generation 0 (små objekt) eller LOH (stora objekt). Endast GC kan "allokera" objekt i generation 1 (genom att främja överlevande från generation 0) och generation 2 (genom att främja överlevande från generation 1).

När en skräpinsamling utlöses spårar GC genom de levande objekten och komprimerar dem. Men eftersom komprimering är dyrt sveper GC LOH; det gör en fri lista av döda objekt som kan återanvändas senare för att uppfylla stora objektallokeringsbegäranden. Intilliggande döda objekt görs till ett fritt objekt.

.NET Core och .NET Framework (från och med .NET Framework 4.5.1) innehåller egenskapen GCSettings.LargeObjectHeapCompactionMode som gör att användarna kan ange att LOH ska komprimeras under nästa fullständiga blockerings-GC. Och i framtiden kan .NET besluta att komprimera LOH automatiskt. Det innebär att om du allokerar stora objekt och vill se till att de inte flyttas bör du fortfarande fästa dem.

Bild 1 illustrerar ett scenario där GC bildar generation 1 efter första generationens GC där Obj1 och Obj3 är döda, och det bildar generation 2 efter första generationens GC där Obj2 och Obj5 är döda. Observera att detta och följande siffror endast är i illustrationssyfte. de innehåller mycket få objekt för att bättre visa vad som händer på högen. I verkligheten är många fler objekt vanligtvis involverade i en GC.

Figure 1: A gen 0 GC and a gen 1 GC
Bild 1: En generation 0 och en generation 1 GC.

Bild 2 visar att efter en generation 2 GC som såg att Obj1 och Obj2 är döda, bildar GC sammanhängande ledigt utrymme ur minnet som tidigare upptas av Obj1 och Obj2, som sedan användes för att uppfylla en allokeringsbegäran för Obj4. Utrymmet efter det sista objektet , Obj3till slutet av segmentet kan också användas för att uppfylla allokeringsbegäranden.

Figure 2: After a gen 2 GC
Bild 2: Efter en generation 2 GC

Om det inte finns tillräckligt med ledigt utrymme för att hantera stora objektallokeringsbegäranden försöker GC först hämta fler segment från operativsystemet. Om det misslyckas utlöser det en GC av generation 2 i hopp om att frigöra utrymme.

Under en generation 1 eller generation 2 GC släpper skräpinsamlaren segment som inte har några levande objekt på sig tillbaka till operativsystemet genom att anropa funktionen VirtualFree. Utrymme efter att det sista liveobjektet till slutet av segmentet har återtagits (förutom i det tillfälliga segmentet där gen0/gen1 finns, där skräpinsamlaren håller vissa bekräftade eftersom ditt program allokeras i det direkt). Och de lediga utrymmena förblir bekräftade även om de återställs, vilket innebär att operativsystemet inte behöver skriva data i dem tillbaka till disken.

Eftersom LOH endast samlas in under generation 2 GCs kan LOH-segmentet endast frigöras under en sådan GC. Bild 3 visar ett scenario där skräpinsamlaren släpper tillbaka ett segment (segment 2) till operativsystemet och frigör mer utrymme i de återstående segmenten. Om det behöver använda det frigörda utrymmet i slutet av segmentet för att uppfylla stora objektallokeringsbegäranden, checkar det in minnet igen. (En förklaring av incheckning/återtagande finns i dokumentationen för VirtualAlloc.)

Figure 3: LOH after a gen 2 GC
Bild 3: LOH efter en generation 2 GC

När samlas ett stort objekt in?

I allmänhet sker en GC under något av följande tre villkor:

  • Allokeringen överskrider tröskelvärdet för generering 0 eller stort objekt.

    Tröskelvärdet är en egenskap för en generation. Ett tröskelvärde för en generation anges när skräpinsamlaren allokerar objekt till den. När tröskelvärdet överskrids utlöses en GC för den generationen. När du allokerar små eller stora objekt använder du generering 0 respektive LOH:s tröskelvärden. När skräpinsamlaren allokeras till generation 1 och 2 förbrukar den sina tröskelvärden. Dessa tröskelvärden justeras dynamiskt när programmet körs.

    Detta är det typiska fallet; de flesta GCs sker på grund av allokeringar på den hanterade heapen.

  • Metoden GC.Collect anropas.

    Om den parameterlösa GC.Collect() metoden anropas eller en annan överlagring skickas GC.MaxGeneration som ett argument samlas LOH in tillsammans med resten av den hanterade heapen.

  • Systemet är i låg minnessituation.

    Detta inträffar när skräpinsamlaren får ett meddelande om högt minne från operativsystemet. Om skräpinsamlaren tror att det är produktivt att göra en GC av generation 2 utlöses en.

Prestandakonsekvenser för LOH

Allokeringar på den stora objekthögen påverkar prestanda på följande sätt.

  • Allokeringskostnad.

    CLR garanterar att minnet för varje nytt objekt som det ger ut rensas. Det innebär att allokeringskostnaden för ett stort objekt domineras av minnesrensning (såvida det inte utlöser en GC). Om det tar två cykler att rensa en byte krävs 170 000 cykler för att rensa det minsta stora objektet. Det tar cirka 16 ms att rensa minnet för ett 16 MB-objekt på en 2 GHz-dator. Det är en ganska stor kostnad.

  • Insamlingskostnad.

    Eftersom LOH och generation 2 samlas in, utlöses en samling av generation 2 om något av tröskelvärdena överskrids. Om en generation 2-samling utlöses på grund av LOH är generation 2 inte nödvändigtvis mycket mindre efter GC. Om det inte finns mycket data i generation 2 har detta minimal påverkan. Men om generation 2 är stor kan det orsaka prestandaproblem om många GCs av andra generationen utlöses. Om många stora objekt allokeras tillfälligt och du har en stor SOH kan du ägna för mycket tid åt att göra GCs. Dessutom kan allokeringskostnaden verkligen summeras om du fortsätter att allokera och släppa riktigt stora objekt.

  • Matriselement med referenstyper.

    Mycket stora objekt på LOH är vanligtvis matriser (det är mycket ovanligt att ha ett instansobjekt som är riktigt stort). Om elementen i en matris är referensrika medför det en kostnad som inte finns om elementen inte är referensrika. Om elementet inte innehåller några referenser behöver skräpinsamlaren inte gå igenom matrisen alls. Om du till exempel använder en matris för att lagra noder i ett binärt träd är ett sätt att implementera det att referera till en nods högra och vänstra nod av de faktiska noderna:

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

    Om num_nodes är stor måste skräpinsamlaren gå igenom minst två referenser per element. En annan metod är att lagra indexet för höger- och vänsternoderna:

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

    I stället för att referera till den vänstra nodens data som left.drefererar du till dem som binary_tr[left_index].d. Och skräpinsamlaren behöver inte titta på några referenser för den vänstra och högra noden.

Av de tre faktorerna är de två första vanligtvis mer betydande än den tredje. Därför rekommenderar vi att du allokerar en pool med stora objekt som du återanvänder i stället för att allokera tillfälliga objekt.

Samla in prestandadata för LOH

Innan du samlar in prestandadata för ett visst område bör du redan ha gjort följande:

  1. Hittade bevis för att du borde titta på det här området.
  2. Uttömda andra områden som du känner till utan att hitta något som kan förklara prestandaproblemet du såg.

Mer information om grunderna i minne och processor finns i bloggen Förstå problemet innan du försöker hitta en lösning.

Du kan använda följande verktyg för att samla in data om LOH-prestanda:

Prestandaräknare för .NET CLR-minne

Prestandaräknare för .NET CLR-minne är vanligtvis ett bra första steg för att undersöka prestandaproblem (även om vi rekommenderar att du använder ETW-händelser). Ett vanligt sätt att titta på prestandaräknare är med Prestandaövervakaren (perfmon.exe). Välj Lägg till (Ctrl + A) för att lägga till intressanta räknare för processer som du bryr dig om. Du kan spara prestandaräknarens data i en loggfil.

Följande två räknare i kategorin .NET CLR Memory är relevanta för LOH:

  • # Gen 2-samlingar

    Visar antalet gånger generation 2 GCs har inträffat sedan processen startades. Räknaren ökas i slutet av en samling av generation 2 (kallas även för en fullständig skräpinsamling). Den här räknaren visar det senast observerade värdet.

  • Storlek på stor objekthög

    Visar den aktuella storleken, i byte, inklusive ledigt utrymme, för LOH. Den här räknaren uppdateras i slutet av en skräpinsamling, inte vid varje allokering.

Screenshot that shows adding counters in Performance Monitor.

Du kan också köra frågor mot prestandaräknare programmatiskt med hjälp av PerformanceCounter klassen. För LOH anger du ".NET CLR Memory" som CategoryName och "Large Object Heap size" som CounterName.

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

Console.WriteLine(performanceCounter.NextValue());

Det är vanligt att samla in räknare programmatiskt som en del av en rutintestningsprocess. När du upptäcker räknare med värden som inte är vanliga använder du andra metoder för att få mer detaljerade data för att hjälpa till med undersökningen.

Kommentar

Vi rekommenderar att du använder ETW-händelser i stället för prestandaräknare, eftersom ETW ger mycket mer information.

ETW-händelser

Skräpinsamlaren tillhandahåller en omfattande uppsättning ETW-händelser som hjälper dig att förstå vad heapen gör och varför. Följande blogginlägg visar hur du samlar in och förstår GC-händelser med ETW:

Om du vill identifiera överdrivna GC:er av generation 2 som orsakas av tillfälliga LOH-allokeringar kan du titta på kolumnen Utlösarorsak för grupprincipobjekt. För ett enkelt test som endast allokerar tillfälliga stora objekt kan du samla in information om ETW-händelser med följande PerfView-kommando :

perfview /GCCollectOnly /AcceptEULA /nogui collect

Resultatet är ungefär så här:

Screenshot that shows ETW events in PerfView.

Som du ser är alla GC:er generation 2-GCs, och de utlöses alla av AllocLarge, vilket innebär att allokeringen av ett stort objekt utlöste den här GC:n. Vi vet att dessa allokeringar är tillfälliga eftersom kolumnen LOH Survival Rate % säger 1 %.

Du kan samla in ytterligare ETW-händelser som talar om vem som allokerade dessa stora objekt. Följande kommandorad:

perfview /GCOnly /AcceptEULA /nogui collect

samlar in en AllocationTick-händelse som utlöses ungefär var 100 000:e allokering. Med andra ord utlöses en händelse varje gång ett stort objekt allokeras. Du kan sedan titta på en av GC Heap Alloc-vyerna, som visar de anropstackar som allokerade stora objekt:

Screenshot that shows a garbage collector heap view.

Som du ser är detta ett mycket enkelt test som bara allokerar stora objekt från metoden Main .

Ett felsökningsprogram

Om allt du har är en minnesdump och du behöver titta på vilka objekt som faktiskt finns på LOH kan du använda SoS-felsökningstillägget som tillhandahålls av .NET.

Kommentar

De felsökningskommandon som nämns i det här avsnittet gäller för Windows-felsökarna.

Följande visar exempelutdata från analys av 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

LOH-heapstorleken är (16 754 224 + 16 699 288 + 16 284 504) = 49 738 016 byte. Mellan adresserna 023e1000 och 033db630 används 8 008 736 byte av en matris med System.Object objekt, 6 663 696 byte används av en matris med System.Byte objekt och 2 081 792 byte används av ledigt utrymme.

Ibland visar felsökningsprogrammet att loh-storleken är mindre än 85 000 byte. Detta beror på att själva körningen använder LOH för att allokera vissa objekt som är mindre än ett stort objekt.

Eftersom LOH inte är komprimerad, ibland loh tros vara källan till fragmentering. Fragmentering innebär:

  • Fragmentering av den hanterade heapen, vilket indikeras av mängden ledigt utrymme mellan hanterade objekt. I SoS !dumpheap –type Free visar kommandot mängden ledigt utrymme mellan hanterade objekt.

  • Fragmentering av adressutrymmet för virtuellt minne (VM), vilket är det minne som har markerats som MEM_FREE. Du kan hämta den med hjälp av olika felsökningskommandon i windbg.

    I följande exempel visas fragmentering i det virtuella datorutrymmet:

    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)
    

Det är vanligare att se VM-fragmentering som orsakas av tillfälliga stora objekt som kräver att skräpinsamlaren ofta hämtar nya hanterade heapsegment från operativsystemet och släpper tomma objekt tillbaka till operativsystemet.

För att kontrollera om LOH orsakar VM-fragmentering kan du ange en brytpunkt på VirtualAlloc och VirtualFree för att se vem som anropade dem. Om du till exempel vill se vem som försökte allokera virtuella minnessegment som är större än 8 MB från operativsystemet kan du ange en brytpunkt så här:

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

Det här kommandot bryter sig in i felsökningsprogrammet och visar endast anropsstacken om VirtualAlloc anropas med en allokeringsstorlek som är större än 8 MB (0x800000).

CLR 2.0 har lagt till en funktion med namnet VM Hoarding som kan vara användbar för scenarier där segment (inklusive på de stora och små objekthögarna) ofta hämtas och släpps. Om du vill ange VM Hoarding anger du en startflagga som anropas STARTUP_HOARD_GC_VM via värd-API:et. I stället för att frigöra tomma segment tillbaka till operativsystemet frigör CLR minnet i dessa segment och placerar dem i en väntelista. (Observera att CLR inte gör detta för segment som är för stora.) CLR använder senare dessa segment för att uppfylla nya segmentbegäranden. Nästa gång din app behöver ett nytt segment använder CLR en från den här väntelistan om den kan hitta ett som är tillräckligt stort.

VM-lagring är också användbart för program som vill behålla de segment som de redan har förvärvat, till exempel vissa serverappar som är de dominerande apparna som körs i systemet, för att undvika undantag som inte är minnesfria.

Vi rekommenderar starkt att du noggrant testar programmet när du använder den här funktionen för att säkerställa att programmet har en ganska stabil minnesanvändning.