Partager via


Tas d'objets de grande taille sur les systèmes Windows

Le ramasse-miettes (GC) de .NET divise les objets en petits objets et objets volumineux. Lorsqu’un objet est volumineux, certains de ses attributs deviennent plus significatifs que si l’objet 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 grands objets sur le tas de grands objets (LOH). Cet article décrit ce qui qualifie un objet en tant qu’objet volumineux, comment les objets volumineux sont collectés et quel type d’implications en termes de performances les grands objets imposent.

Importante

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

Comment un objet se retrouve sur le LOH

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

Pour comprendre ce que cela signifie, il est utile d’examiner certaines notions de base sur le ramasse-miettes.

Le récupérateur de mémoire est un nettoyeur générationnel. Il a trois générations : génération 0, génération 1 et génération 2. La raison d’avoir trois générations est que, dans une application bien paramétrée, la plupart des objets meurent en gen0. Par exemple, dans une application serveur, les allocations associées à chaque requête doivent mourir une fois la demande terminée. Les demandes d’allocation en cours passent en génération 1 et y meurent. Essentiellement, gen1 agit comme un tampon entre les objets jeunes et les objets de longue durée.

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

Les objets volumineux appartiennent à la génération 2, car ils sont collectés uniquement pendant une collection de génération 2. Lorsqu’une génération est collectée, toutes ses jeunes générations sont également collectées. Par exemple, lorsqu’un GC de génération 1 se produit, les générations 1 et 0 sont collecté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 génération 2 est également appelé gc complet. Cet article fait référence au GC de génération 2 au lieu du GC entièrement, 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é. Lorsque le CLR est chargé, le GC alloue deux segments de tas initiaux : l'un pour les petits objets (le tas des petits objets, ou SOH) et l'autre pour les objets volumineux (le tas des objets volumineux).

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 le segment pour le SOH ; sinon, il est mis 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 vers la prochaine génération. Les objets qui survivent à une collection de génération 0 sont désormais considérés comme des objets de génération 1, et ainsi de suite. Toutefois, les objets qui survivent à la génération la plus ancienne sont toujours considérés comme étant dans la génération la plus ancienne. En d’autres termes, les survivants de la génération 2 sont des objets de génération 2 ; et les survivants du LOH sont des objets LOH (qui sont collectés avec gen2).

Le code d’utilisateur peut seulement allouer dans la génération 0 (petits objets) ou le LOH (grands objets). Seul le GC peut « allouer » des objets dans la génération 1 (en favorisant les survivants de la génération 0) et la génération 2 (en favorisant 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 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. Et à l’avenir, .NET peut décider de compacter automatiquement le LOH. Cela signifie que, si vous allouez des objets volumineux et que vous souhaitez vous assurer qu’ils ne se déplacent pas, vous devez toujours les épingler.

La figure 1 illustre un scénario dans lequel le GC forme la génération 1 après le premier GC de génération 0 où Obj1 et Obj3 sont morts, et il forme la génération 2 après le premier GC de génération 1 où Obj2 et Obj5 sont morts. Notez que cet exemple et les illustrations suivantes sont uniquement destinés à l'illustration ; elles contiennent très peu d’objets pour mieux montrer ce qui se passe dans la pile. En réalité, de nombreux objets supplémentaires sont généralement impliqués dans un GC.

Figure 1 : Un GC de génération 0 et un GC de génération 1
Figure 1 : Une génération 0 et un GC de génération 1.

La figure 2 montre qu’après un GC de génération 2 ayant constaté que Obj1 et Obj2 sont morts, le GC crée un espace libre contigu à partir de la mémoire précédemment occupée par Obj1 et Obj2, qui a ensuite été utilisé pour satisfaire une demande d’allocation de Obj4. L’espace après le dernier objet, Obj3à la fin du segment peut également être utilisé pour répondre aux demandes d’allocation.

Figure 2 : Après un GC de génération 2
Figure 2 : Après un GC de génération 2

S’il n’y a pas suffisamment d’espace libre pour prendre en charge les demandes d’allocation d’objets volumineux, le GC tente d’abord d’acquérir davantage de segments du système d’exploitation. Si cela échoue, il déclenche un GC de génération 2 dans l’espoir de libérer de 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). Et les espaces libres restent validés bien qu’ils soient réinitialisés, ce qui signifie que le système d’exploitation n’a pas besoin d’écrire des données dans ces espaces sur le disque.

Étant donné que le LOH n’est collecté que pendant les GCS de génération 2, le segment LOH ne peut être libéré que pendant un tel 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 décompressé à la fin du segment pour répondre aux demandes d’allocation d’objets volumineux, il valide à nouveau la mémoire. (Pour obtenir une explication de commit/decommit, consultez la documentation de VirtualAlloc.)

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

Quand un objet volumineux 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. Un seuil pour une génération est défini lorsque le ramasse-miettes alloue des objets dans celle-ci. Lorsque le seuil est dépassé, un GC est déclenché sur cette génération. Quand vous allouez des petits ou des grands objets, 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 paramétrés dynamiquement à mesure que le programme s’exécute.

    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 si une autre surcharge est utilisée en tant qu'argument GC.MaxGeneration, le LOH est collecté avec le reste du tas géré.

  • Le système est en situation de mémoire limitée.

    Cela se produit lorsque le collecteur de déchets reçoit une notification indiquant une utilisation élevée de la mémoire 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 en matière de 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 grand objet est dominé par la libération de la mémoire (sauf s’il déclenche un GC). S’il faut deux cycles pour effacer un octet, il faut 170 000 cycles pour effacer le plus petit objet grand. L’effacement de la mémoire d’un objet de 16 Mo sur une machine de 2 GHz prend environ 16 ms. C’est un coût plutôt important.

  • Coût du regroupement.

    Étant donné que le LOH et la génération 2 sont collectés ensemble, si l’un des seuils est dépassé, une collection de génération 2 est déclenchée. 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. S’il n’y a pas beaucoup de données sur la génération 2, cela a un impact minimal. Mais si la génération 2 est volumineuse, cela peut entraîner des problèmes de performances si de nombreux GCS de génération 2 sont déclenchés. Si de nombreux grands objets 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 objets très volumineux sur le LOH sont généralement des tableaux (il est très rare d’avoir un objet d’instance vraiment volumineux). Si les éléments d’un tableau sont riches en références, cela entraîne un coût qui n’est pas présent si les éléments ne sont pas riches en références. Si l’élément n’a aucune référence, le récupérateur de mémoire n’a pas besoin de traiter le tableau. Par exemple, si vous utilisez un tableau pour stocker des nœuds dans une arborescence binaire, une façon de l’implémenter consiste à faire référence au nœud droit et à gauche d’un nœud par les nœuds réels :

    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 approche consiste à stocker l’index des nœuds de droite et de gauche :

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

    Au lieu de faire référence aux données du nœud gauche en tant que left.d, vous l’appelez en tant que 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.

Sur les trois facteurs, les deux premiers sont généralement plus significatifs que le troisième. Pour cette raison, nous vous recommandons d’allouer un pool d’objets volumineux 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à effectuer les opérations suivantes :

  1. Rechercher les raisons d’examiner cette zone.
  2. Exploré tous les domaines que vous connaissez sans trouver quoi que ce soit qui pourrait expliquer le problème de performance que vous aviez constaté.

Pour plus d’informations sur les principes fondamentaux de la mémoire et de l’UC, consultez le blog Comprendre le problème avant d’essayer de trouver une solution.

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

Compteurs de performance de la mémoire .NET CLR

Les compteurs de performances de la mémoire CLR .NET constituent généralement une bonne première étape dans l’examen des problèmes de performances (bien que nous vous recommandons d’utiliser des événements ETW). Un moyen courant d’examiner les compteurs de performances est d’utiliser l’Analyseur 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 du compteur de performances dans un fichier journal.

Les deux compteurs suivants dans la catégorie .NET CLR Memory sont pertinents pour le LOH :

  • Collections Génération 2

    Affiche le nombre de fois où les GCS de génération 2 se sont produites 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 des objets volumineux

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

Capture d’écran montrant l’ajout de compteurs dans l’Analyseur de performances.

Vous pouvez également interroger des compteurs de performances par programmation à l’aide de la PerformanceCounter classe. Pour le LOH, spécifiez « .NET CLR Memory » comme CategoryName et « Taille du tas d’objets volumineux » comme 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 de manière programmatique dans le cadre d’un processus de test de routine. Lorsque vous placez des compteurs avec des valeurs hors de l’ordinaire, utilisez d’autres moyens d’obtenir des données plus détaillées pour faciliter l’investigation.

Remarque

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

É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 montrent 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 objets volumineux temporaires, vous pouvez collecter des informations sur les événements ETW avec la commande PerfView suivante :

perfview /GCCollectOnly /AcceptEULA /nogui collect

Le résultat est semblable à ceci :

Capture d’écran montrant les événements ETW dans 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 le taux de survie du LOH % colonne indique 1%.

Vous pouvez collecter des événements ETW supplémentaires qui vous indiquent qui a alloué ces objets volumineux. 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 :

Capture d’écran montrant une vue du tas garbage collector.

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

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 réellement sur le LOH, vous pouvez utiliser l’extension du débogueur SoS fournie par .NET.

Remarque

Les commandes de débogage mentionnées dans cette section s’appliquent 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 l’espace libre.

Parfois, le débogueur indique que la taille totale du LOH est inférieure à 85 000 octets. Cela se produit parce que le runtime lui-même utilise le LOH pour allouer certains objets qui sont plus petits qu’un objet volumineux.

Étant donné que le LOH n’est pas compacté, le LOH est parfois considéré comme la source de fragmentation. La fragmentation signifie :

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

  • Fragmentation de l’espace d’adressage de la mémoire virtuelle (VM), 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 la fragmentation dans l’espace de machine 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 la fragmentation des machines virtuelles, vous pouvez définir un point d’arrêt sur VirtualAlloc et VirtualFree pour voir qui les a appelées. Par exemple, pour voir qui a essayé d’allouer des blocs de mémoire virtuelle de plus de 8 Mo à partir du système d’exploitation, vous pouvez définir un point d’arrêt comme suit :

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

Cette commande entre dans le débogueur et affiche la pile des appels uniquement si VirtualAlloc est appelé 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 spécifier le hoarding de machine virtuelle, 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 fait pas cela pour les segments trop volumineux.) Le CLR utilise ultérieurement ces segments pour répondre aux nouvelles demandes de segment. La prochaine fois que votre application a besoin d’un nouveau segment, le CLR utilise celui de cette liste de secours s’il peut en trouver un suffisamment grand.

La fonctionnalité VM Hoarding est é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 lorsque vous utilisez cette fonctionnalité pour vous assurer que votre application dispose d’une utilisation assez stable de la mémoire.