Tas de grands objets sur les systèmes Windows

Le récupérateur de mémoire (GC) .NET divise les objets en petits et grands objets. Quand un objet est grand, certains de ses attributs prennent plus d’importance que s’il est petit. Son compactage, par exemple, (c’est-à-dire sa copie en mémoire ailleurs sur le tas) peut coûter cher. Pour cette raison, le récupérateur de mémoire place les objets volumineux sur le tas d’objets volumineux (LOH). Cet article décrit ce qui permet de qualifier un objet de volumineux, comment ces objets volumineux sont collectés et quelle est leur implication sur les performances.

Important

Cet article décrit le tas d’objets volumineux dans le .NET Framework et .NET Core exécutés sur les systèmes Windows uniquement. Elle ne couvre pas le LOH exécuté sur des implémentations de .NET sur d’autres plateformes.

Comment un objet termine sur le LOH

Si un objet a une taille supérieure ou égale à 85 000 octets, il est considéré comme volumineux. Ce chiffre a été déterminé par le réglage des performances. Quand une demande d’allocation d’objet est de 85 000 octets ou plus, le runtime l’alloue sur le tas de grands objets.

Pour comprendre ce que cela signifie, examinons quelques notions de base sur le récupérateur de mémoire.

Le récupérateur de mémoire est un collecteur générationnel. Il a trois générations : génération 0, génération 1 et génération 2. La raison de ces trois générations est que la plupart des objets meurent dans la génération 0 (dans une application optimisée). Par exemple, dans une application serveur, les allocations associées à chaque demande doivent mourir une fois la demande terminée. Les demandes d’allocation en cours passent en génération 1 et y meurent. La génération 1 joue, pour ainsi dire, le rôle de tampon entre les zones d’objets jeunes et les zones d’objets qui vivent déjà depuis un certain temps.

Les objets nouvellement alloués forment une nouvelle génération d’objets et sont implicitement des collections de génération 0. Toutefois, s’il s’agit d’objets volumineux, ils vont sur le tas d’objets volumineux (LOH), parfois appelé génération 3. La génération 3 est une génération physique qui est logiquement collectée dans le cadre de la génération 2.

Les grands objets appartiennent à la génération 2 parce qu’ils sont nettoyés uniquement lors d’un nettoyage de la génération 2. Quand une génération est nettoyée, ses générations plus jeunes sont également nettoyées. Par exemple, pendant le nettoyage de la mémoire (GC, Garbage Collection) de la génération 1, les générations 1 et 0 sont toutes deux nettoyées. De la même façon, pendant le GC de la génération 2, le tas tout entier est nettoyé. Pour cette raison, un GC de la génération 2 est également appelé GC complet. Cet article fait référence au GC de la génération 2 et non au GC complet, mais les termes sont interchangeables.

Les générations fournissent une vue logique du tas du récupérateur de mémoire. Physiquement, les objets vivent dans des segments de tas managés. Un segment de tas managé est un bloc de mémoire que le récupérateur de mémoire réserve sur le système d’exploitation en appelant la fonction VirtualAlloc pour le compte du code managé. Quand le CLR est chargé, le récupérateur de mémoire alloue deux segments de tas initiaux : un pour les petits objets (le tas de petits objets ou SOH) et un pour les objets volumineux (le tas d’objets volumineux ou LOH).

Les demandes d’allocation sont alors traitées en plaçant des objets managés sur ces segments de tas managés. Si l’objet est inférieur à 85 000 octets, il est placé sur un segment SOH, sinon, sur un segment LOH. Les segments sont réservés (en blocs plus petits) à mesure que leur nombre d’objets alloués augmente. Pour le SOH, les objets qui survivent à un GC sont promus à la génération suivante. Les objets qui survivent à un nettoyage de la génération 0 sont considérés comme des objets de génération 1, et ainsi de suite. Toutefois, les objets qui survivent à la plus vieille génération sont toujours considérés comme des objets de cette génération. En d’autres termes, les survivants de la génération 2 sont des objets de la génération 2 et les survivants du LOH sont des objets du LOH (qui sont nettoyés avec la génération 2).

Le code d’utilisateur peut seulement allouer dans la génération 0 (petits objets) ou le LOH (grands objets). Seul le récupérateur de mémoire peut « allouer » des objets dans la génération 1 (en promouvant les survivants de la génération 0) et la génération 2 (en promouvant les survivants de la génération 1).

Quand un nettoyage de la mémoire est déclenché, le récupérateur de mémoire repère les objets en vie et les compacte. Parce que le compactage coûte cher, le récupérateur de mémoire balaye le LOH et dresse une liste des objets morts qui peuvent être réutilisés plus tard pour répondre aux demandes d’allocation des grands objets. Les objets morts adjacents sont transformés en un seul objet libre.

Le .NET Framework (à partir de .NET Framework 4.5.1) et .NET Core intègrent la propriété GCSettings.LargeObjectHeapCompactionMode qui permet aux utilisateurs de spécifier que le LOH doit être compacté au prochain GC bloquant complet. Par la suite, .NET peut décider de compacter le LOH automatiquement. Donc, si vous allouez des objets volumineux et voulez garantir qu’ils ne bougent pas, vous devez quand même les épingler.

La figure 1 illustre un scénario dans lequel le récupérateur de mémoire forme la génération 1 après le premier GC de la génération 0 où Obj1 et Obj3 sont morts, et forme la génération 2 après le premier GC de la génération 1 où Obj2 et Obj5 sont morts. Notez que cette figure et les suivantes sont uniquement à titre d’illustration. Elles contiennent très peu d’objets pour mieux montrer ce qui se passe sur le tas. En réalité, un GC implique généralement bien plus d’objets.

Figure 1: A gen 0 GC and a gen 1 GC
Figure 1 : GC des générations 0 et 1.

La figure 2 montre qu’après qu’un GC de la génération 2 a vu que Obj1 et Obj2 étaient morts, le récupérateur de mémoire forme un espace libre contigu dans la mémoire qui était auparavant occupée par Obj1 et Obj2, lequel est ensuite utilisé pour répondre à une demande d’allocation concernant Obj4. L’espace entre le dernier objet Obj3 et la fin du segment peut aussi être utilisé pour répondre aux demandes d’allocation.

Figure 2: After a gen 2 GC
Figure 2 : Après un GC de la génération 2

Si l’espace libre est insuffisant pour répondre aux demandes d’allocation des grands objets, le récupérateur de mémoire tente d’acquérir d’autres segments du système d’exploitation. En cas d’échec, il déclenche un GC de la génération 2 pour tenter de libérer l’espace.

Pendant un GC de la génération 1 ou 2, le récupérateur de mémoire libère les segments qui n’ont pas d’objet en vie et les rend au système d’exploitation en appelant la fonction VirtualFree. La réservation de l’espace entre le dernier objet en vie et la fin du segment est annulée (sauf sur le segment éphémère, où vivent les générations 0 et 1, sur lequel le récupérateur de mémoire maintient la réservation pour que votre application puisse l’utiliser immédiatement). Par ailleurs, les espaces libres restent réservés bien qu’ils soient réinitialisés, ce qui signifie que le système d’exploitation n’a pas besoin d’écrire de données dans ces espaces une fois revenus sur le disque.

Comme que le LOH est collecté uniquement pendant le GC de la génération 2, le segment LOH peut seulement être libéré pendant ce GC. La figure 3 illustre un scénario où le récupérateur de mémoire rend un segment (segment 2) au système d’exploitation et annule la réservation d’espace supplémentaire sur les segments restants. S’il doit utiliser l’espace libéré à la fin du segment pour répondre aux demandes d’allocation de grands objets, il réserve de nouveau la mémoire. (Pour obtenir une explication de la réservation/libération, consultez la documentation de VirtualAlloc.)

Figure 3: LOH after a gen 2 GC
Figure 3 : LOH après un GC de la génération 2

Quand un grand objet est-il collecté ?

En général, un GC se produit dans l’une des trois conditions suivantes :

  • L’allocation dépasse le seuil des grands objets ou de la génération 0.

    Le seuil est une propriété des générations. Le seuil d’une génération est défini quand le récupérateur de mémoire lui alloue des objets. Quand le seuil est dépassé, un GC est déclenché sur cette génération. Quand vous allouez des objets petits ou volumineux, vous consommez les seuils de la génération 0 et du LOH, respectivement. Quand le récupérateur de mémoire alloue des objets dans les générations 1 et 2, il consomme leurs seuils. Ces seuils sont réglés dynamiquement pendant l’exécution du programme.

    C’est le cas par défaut. La plupart des GC se produisent suite à des allocations sur le tas managé.

  • La méthode GC.Collect est appelée.

    Si la méthode GC.Collect() sans paramètre est appelée ou qu’une autre surcharge reçoit GC.MaxGeneration comme argument, le LOH est nettoyé avec le reste du tas managé.

  • Le système est en situation d’insuffisance de mémoire.

    Cela se produit quand le récupérateur de mémoire reçoit une notification de mémoire haute du système d’exploitation. Si le récupérateur de mémoire pense qu’un GC de la génération 2 peut être productif, il le déclenche.

Implications sur les performances du LOH

Les allocations sur le tas de grands objets impacte les performances des façons suivantes.

  • Coût d’allocation.

    Le CLR garantit que la mémoire allouée pour chaque nouvel objet est libérée. Cela signifie que le coût d’allocation d’un objet volumineux est dominé par la libération de la mémoire (sauf s’il déclenche un GC). Si deux cycles sont nécessaires pour libérer un octet, 170 000 cycles sont nécessaires pour libérer le plus petit des objets volumineux. Pour libérer la mémoire d’un objet de 16 Mo sur une machine de 2 GHz, 16 ms sont nécessaires. C’est un coût plutôt élevé.

  • Coût de nettoyage.

    Comme le LOH et la génération 2 sont nettoyés ensemble, si le seuil de l’un des deux est dépassé, un nettoyage de la génération 2 est déclenché. Si le nettoyage de la génération 2 est déclenché à cause du LOH, la génération 2 n’est pas forcément plus petite après le GC. Si la génération 2 n’a pas beaucoup de données, l’impact est minime. En revanche, si la génération 2 est grande, le nettoyage peut entraîner des problèmes de performances s’il faut déclencher plusieurs GC sur la génération 2. Si de nombreux objets volumineux sont alloués de façon temporaire et que vous avez un grand SOH, vous risquez de passer trop de temps sur les GC. Par ailleurs, le coût d’allocation vient s’ajouter si vous continuez d’allouer et de libérer de très grands objets.

  • Éléments de tableau avec des types référence.

    Les très grands objets sur le LOH sont généralement des tableaux (il est très rare d’avoir un objet d’instance très grand). Si les éléments d’un tableau ont beaucoup de références, le coût est plus élevé. Si l’élément n’a aucune référence, le récupérateur de mémoire n’a pas du tout besoin de traiter le tableau. Par exemple, si vous utilisez un tableau pour stocker des nœuds dans une arborescence binaire, vous pouvez l’implémenter en référençant les nœuds droit et gauche d’un nœud comme étant les nœuds eux-mêmes :

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

    Si num_nodes est grand, le récupérateur de mémoire doit traiter au moins deux références par élément. Une autre méthode est de stocker l’index des nœuds droit et gauche :

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

    Au lieu de référencer les données du nœud gauche comme left.d, vous les référencez comme binary_tr[left_index].d. Ainsi, le récupérateur de mémoire n’a pas besoin d’examiner les références des nœuds gauche et droit.

Des trois facteurs, les deux premiers ont généralement plus d’impact que le troisième. Pour cette raison, nous vous recommandons d’allouer un pool de grands objets que vous réutilisez au lieu d’allouer des objets temporaires.

Collecter des données de performances pour le LOH

Avant de collecter des données de performances pour une zone spécifique, vous devez déjà avoir effectué les étapes suivantes :

  1. Rechercher les raisons d’examiner cette zone.
  2. Examiner toutes les autres zones connues sans trouver ce qui pourrait expliquer le problème de performances rencontré.

Pour plus d’informations sur les principes fondamentaux de la mémoire et du processeur, consultez le blog Understand the problem before you try to find a solution (Comprendre le problème avant d’essayer de chercher une solution).

Vous pouvez utiliser les outils suivants pour collecter des données sur les performances du LOH :

Compteurs de performances pour la mémoire CLR .NET

Les compteurs de performances pour la mémoire CLR .NET sont généralement une première étape appropriée pour investiguer des problèmes de performances (même si nous vous recommandons d’utiliser les événements ETW). En général, vous surveillez les compteurs de performances par le biais du moniteur de performances (PerfMon.exe). Sélectionnez Ajouter (Ctrl + A) pour ajouter les compteurs intéressants pour les processus qui vous intéressent. Vous pouvez enregistrer les données des compteurs de performances dans un fichier journal.

Les deux compteurs suivants dans la catégorie Mémoire CLR .NET sont pertinents pour le tas des objets volumineux (LOH, Large Object Heap) :

  • Nombre de collections de génération 2

    Affiche le nombre d’occurrences de GC de la génération 2 depuis le démarrage du processus. Ce compteur est incrémenté à la fin de chaque nettoyage de la génération 2 (aussi appelé nettoyage complet de la mémoire). Ce compteur affiche la dernière valeur observée.

  • Taille du tas d’objets volumineux

    Affiche la taille actuelle du LOH en octets (y compris l’espace libre). Ce compteur est actualisé à la fin de chaque garbage collection, et non à chaque allocation.

Screenshot that shows adding counters in Performance Monitor.

Vous pouvez également interroger les compteurs de performances par programmation en utilisant la classe PerformanceCounter. Pour le LOH, spécifiez « .NET CLR Memory » (Mémoire CLR .NET) pour CategoryName et « Large Object Heap size » (Taille du tas d’objets volumineux) pour CounterName.

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

Console.WriteLine(performanceCounter.NextValue());

Il est courant de collecter des compteurs par programmation dans le cadre d’un processus de test de routine. Quand vous repérez des compteurs dont les valeurs sortent de l’ordinaire, utilisez d’autres moyens pour obtenir des données plus détaillées afin de faciliter l’investigation.

Remarque

Nous vous recommandons d’utiliser les événements ETW au lieu des compteurs de performances, car ETW fournit des informations plus détaillées.

Événements ETW

Le récupérateur de mémoire fournit un riche ensemble d’événements ETW pour vous aider à comprendre ce que fait le tas et pourquoi. Les billets de blog suivants décrivent comment collecter et comprendre les événements GC avec ETW :

Pour identifier le nombre excessif de GC de la génération 2 dus à des allocations de LOH temporaires, observez la colonne Raison du déclencheur pour les GC. Pour un test simple qui alloue uniquement des grands objets temporaires, vous pouvez collecter des informations sur les événements ETW avec la commande PerfView suivante :

perfview /GCCollectOnly /AcceptEULA /nogui collect

Le résultat ressemble à ceci :

Screenshot that shows ETW events in PerfView.

Comme vous pouvez le voir, tous les GC sont effectués sur la génération 2 et ils sont déclenchés par AllocLarge, ce qui signifie que c’est l’allocation d’un grand objet qui a déclenché ce GC. Nous savons que ces allocations sont temporaires parce que la colonne % de taux de survie LOH indique 1 %.

Vous pouvez collecter d’autres événements ETW qui vous indiquent qui a alloué ces grands objets. La ligne de commande suivante :

perfview /GCOnly /AcceptEULA /nogui collect

collecte un événement AllocationTick qui est déclenché toutes les 100 000 allocations environ. En d’autres termes, un événement est déclenché chaque fois qu’un grand objet est alloué. Vous pouvez alors examiner une des vues d’allocation de tas du récupérateur de mémoire qui indique les pile d’appels qui ont alloué des grands objets :

Screenshot that shows a garbage collector heap view.

Comme vous pouvez le voir, il s’agit d’un test très simple qui alloue simplement de grands objets à partir de sa méthode Main.

Un débogueur

Si tout ce que vous avez est un vidage de mémoire et que vous devez examiner les objets qui se trouvent sur le LOH, vous pouvez utiliser l’extension de débogueur SoS fournie par .NET.

Notes

Les commandes de débogage mentionnées dans cette section sont applicables aux débogueurs Windows.

Le code suivant illustre un exemple de sortie de l’analyse du 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

La taille du tas LOH est (16 754 224 + 16 699 288 + 16 284 504) = 49 738 016 octets. Entre les adresses 023e1000 et 033db630, 8 008 736 octets sont occupés par un tableau d’objets System.Object, 6 663 696 octets sont occupés par un tableau d’objets System.Byte et 2 081 792 octets sont occupés par de l’espace libre.

Parfois, le débogueur montre que la taille totale du LOH est inférieure à 85 000 octets. C’est parce que le runtime lui-même utilise le LOH pour allouer des objets dont la taille est inférieure à celle d’un grand objet.

Comme le LOH n’est pas compacté, il est parfois perçu comme la source de la fragmentation. Une fragmentation peut désigner :

  • La fragmentation du tas managé, indiquée par la quantité d’espace libre entre les objets managés. Dans SoS, la commande !dumpheap –type Free affiche la quantité d’espace libre entre les objets managés.

  • La fragmentation de l’espace d’adressage de mémoire virtuelle, qui est la mémoire marquée comme MEM_FREE. Vous pouvez l’obtenir à l’aide de diverses commandes de débogueur dans windbg.

    L’exemple suivant montre une fragmentation dans l’espace de mémoire virtuelle :

    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)
    

Souvent, la fragmentation de mémoire virtuelle est causée par des grands objets temporaires qui obligent le récupérateur de mémoire à fréquemment acquérir de nouveaux segments de tas managé du système d’exploitation et lui en rendre des vides.

Pour vérifier si le LOH provoque une fragmentation de mémoire virtuelle, vous pouvez définir un point d’arrêt sur VirtualAlloc et VirtualFree pour voir qui les a appelés. Par exemple, pour voir qui a essayé d’allouer des blocs de mémoire virtuelle de système d’exploitation supérieurs à 8 Mo, vous pouvez définir un point d’arrêt de la façon suivante :

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

Cette commande arrête le débogueur et affiche la pile des appels uniquement si VirtualAlloc est appelée avec une taille d’allocation supérieure à 8 Mo (0x800000).

CLR 2.0 a ajouté une fonctionnalité appelée VM Hoarding (Réserve de mémoire virtuelle) qui peut être utile dans les scénarios où des segments (y compris ceux des tas de petits et grands objets) sont fréquemment acquis et libérés. Pour utiliser la fonctionnalité VM Hoarding, vous spécifiez un indicateur de démarrage appelé STARTUP_HOARD_GC_VM via l’API d’hébergement. Au lieu de renvoyer des segments vides au système d’exploitation, le CLR annule la réservation de mémoire sur ces segments et les met sur liste d’attente. (Notez que le CLR ne le fait pas pour les segments trop volumineux.) Le CLR utilise ultérieurement ces segments pour satisfaire les nouvelles demandes de segment. La prochaine fois que votre application a besoin d’un nouveau segment, le CLR en utilise un de cette liste d’attente, s’il en trouve un assez grand.

La fonctionnalité VM Hoarding s’avère également utile pour les applications qui veulent garder les segments déjà acquis, comme certaines applications serveur qui sont les applications principales exécutées sur le système, pour éviter les exceptions de mémoire insuffisante.

Nous vous recommandons vivement de tester soigneusement votre application quand vous utilisez cette fonctionnalité, pour vérifier qu’elle utilise la mémoire de façon suffisamment stable.