Distinguer l'attente du GC d'une fuite mémoire en .NET — Procédure pratique pour observer, comparer et prouver la croissance mémoire

· Mis à jour le: · · .NET, C#, GC, Fuite de mémoire, Diagnostics, dotnet-counters, dotnet-dump, Exploitation, Réutilisation des actifs existants

1. Ce qu’il faut comprendre en premier lieu

Lorsqu’on exploite une application .NET, il arrive que la consommation mémoire augmente progressivement.

Le Gestionnaire des tâches ou top montre que la mémoire du processus augmente. La consommation mémoire du conteneur augmente également. Dans la supervision, le graphique du Working Set ou du RSS monte régulièrement.

Devant ce constat, on est tenté de conclure immédiatement à une « fuite mémoire ». Mais en .NET, une hausse de la mémoire du processus et une fuite mémoire ne sont pas la même chose.

.NET dispose du ramasse-miettes (GC). La mémoire n’est pas rendue à l’OS dès l’instant où un objet devient inutile. Le GC agit en fonction de l’état des allocations, des seuils du tas, de la pression mémoire, des générations et de la charge de travail.

Il en résulte des situations comme celles-ci :

  • Des objets devenus inutiles n’ont pas encore été collectés
  • Le GC a eu lieu, mais le Working Set du processus ne redescend pas immédiatement
  • La mémoire augmente une seule fois lors du premier accès, du JIT, de la mise en cache ou du pool de connexions, puis se stabilise
  • Le tas managé est stable, mais la mémoire native, les threads, les sockets ou une bibliothèque de traitement d’image augmentent de leur côté
  • Des objets réellement devenus inutiles continuent d’être référencés depuis quelque part

Cet article traite de la manière de repérer ce dernier cas — la fuite réelle. Ce qu’il faut observer, ce n’est pas simplement la quantité de mémoire utilisée, mais ces trois points :

  1. La mémoire qui survit au GC augmente-t-elle ?
  2. Quel type augmente ?
  3. Qui référence ces objets ?

Une investigation de fuite mémoire en .NET ne doit pas s’arrêter à « la mémoire augmente » ; il faut aller jusqu’à « les objets de ce type augmentent et restent référencés depuis cette racine ».

Le code présenté dans cet article est publié sur GitHub sous forme d’un ensemble d’exemples compilables et exécutables (une bibliothèque de schémas de fuite typiques, une démo observant la différence entre l’attente du GC et la survie d’objets, et des tests unitaires vérifiant la rétention et la collecte avec WeakReference).

dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)

2. Avant tout, s’accorder sur le sens de « fuite mémoire »

En .NET, une fuite mémoire ne se limite pas à la forme « mémoire allouée puis oubliée » que l’on connaît en C ou en C++.

En code managé, c’est le GC qui collecte les objets. Le fait que le GC puisse ou non collecter un objet dépend d’une seule chose : existe-t-il encore une référence permettant d’atteindre cet objet ?

Autrement dit, la fuite mémoire typique en .NET ressemble à ceci :

Un objet n’est plus nécessaire du point de vue métier, mais il reste référencé — depuis un champ static, un cache, un événement, un Timer, une collection, une durée de vie DI, un contexte asynchrone, etc. — de sorte que, du point de vue du GC, il paraît encore utilisé.

Le GC est intelligent, mais il ne peut pas savoir si quelque chose est encore utile du point de vue métier. S’il est référencé, il le considère vivant.

C’est pourquoi, en .NET, il est plus parlant de parler de « rétention non intentionnelle » plutôt que de « fuite ».

À l’inverse, les états suivants n’indiquent pas immédiatement une fuite mémoire.

État Pourquoi ce n’est pas forcément une fuite
Le Working Set / RSS augmente C’est la mémoire allouée au processus par l’OS ; elle ne correspond pas nécessairement à la quantité d’objets vivants sur le tas managé
Le Total Allocated augmente Il s’agit du volume cumulé alloué depuis le démarrage, qui augmente naturellement tant que l’application fonctionne
Le GC Heap augmente momentanément Il peut simplement s’agir d’objets non encore collectés en attendant le prochain GC
La mémoire augmente juste après le démarrage Cela se produit couramment avec le JIT, le chargement des types, les caches initiaux, les pools de connexions, le déploiement des modèles
Le LOH est volumineux Cela peut refléter la réutilisation de gros tableaux et buffers, la fragmentation, ou une stratégie de pooling
La mémoire ne redescend pas Même après collecte par le GC, le processus ne rend pas forcément la mémoire à l’OS immédiatement

À l’inverse, plus les observations suivantes se cumulent, plus le soupçon de fuite mémoire se renforce.

Observation Signification
Le tas après GC augmente à chaque répétition de la même opération Le nombre d’objets survivants augmente
La taille de Gen 2 ou du LOH continue de croître Des objets à longue durée de vie, ou de grands objets, restent en place
Le Count / la Size d’un même type augmente sur plusieurs dumps successifs On peut identifier le type qui augmente
gcroot montre des références depuis des champs static, des événements, des caches ou des services à longue durée de vie On peut expliquer pourquoi le GC ne peut pas collecter
La mémoire ne redescend pas après l’arrêt de la charge, même après un délai suffisant ou un GC de vérification Il ne s’agit probablement pas d’une simple allocation temporaire

3. Distinguer « de quelle mémoire » on parle

La première source de confusion dans une investigation mémoire est le mélange de différents indicateurs. Ils portent tous le nom de « mémoire », mais n’ont pas le même sens.

Indicateur Ce qu’il mesure Comment le lire
Working Set / RSS Les pages du processus présentes en mémoire physique Point de vue de l’OS ; ce n’est pas le tas GC lui-même
Private Bytes / Commit Mémoire committée détenue en propre par le processus Inclut aussi la mémoire native, les piles, le code JIT, les segments GC
GC Heap Size Quantité d’objets sur le tas managé Le point d’entrée pour observer la mémoire gérée par le GC en .NET
Total Allocated Volume cumulé alloué depuis le démarrage Augmente en permanence ; à ne jamais utiliser seul pour juger une fuite
Gen 0 / Gen 1 / Gen 2 Tas par génération Ce qui reste en Gen 2 a une longue durée de vie
LOH Tas accueillant les grands objets de 85 000 octets ou plus Tend à augmenter avec les grands tableaux, chaînes et buffers
POH Tas destiné aux objets épinglés (pinned) Indice pour les effets de l’interopérabilité native et de l’épinglage
Finalization Queue Objets en attente de finalisation Indice de Dispose manqué ou de finaliseur engorgé

Il n’est pas nécessaire de tout examiner en détail dès le départ. Décomposez d’abord la question ainsi :

La mémoire du processus augmente
  ↓
Le tas managé augmente-t-il aussi ?
  ↓
La quantité qui survit au GC augmente-t-elle ?
  ↓
Quel type augmente ?
  ↓
Qui le référence ?

En respectant cet ordre, on évite plus facilement de confondre « une hausse apparente de la mémoire » et « une véritable fuite ».

4. Le déroulé de la décision

En pratique, il est plus facile d’avancer avec le déroulé suivant :

1. Définir les conditions de reproduction
   - Quelle API, quel écran, quel job, quel batch fait augmenter la mémoire
   - Combien d'exécutions faut-il pour observer la hausse
   - Que se passe-t-il quand on arrête la charge

2. Observer les tendances avec dotnet-counters
   - Working Set
   - GC Heap
   - Gen 2 / LOH
   - Total Allocated
   - Nombre de GC

3. Comparer dans le temps
   - Juste après le démarrage
   - Après le warm-up
   - Sous charge
   - Après l'arrêt de la charge
   - Après N répétitions de la même opération

4. Prendre au moins deux dumps
   - before
   - after
   - si possible, aussi après l'arrêt de la charge

5. Rechercher les types qui augmentent
   - dumpheap -stat
   - gcdump report
   - Visual Studio / PerfView

6. Vérifier les références
   - gcroot
   - gchandles
   - finalizequeue

7. Établir un diagnostic
   - Attente du GC
   - Croissance normale d'un cache
   - Fuite mémoire managée
   - Problème de mémoire native
   - Fragmentation du LOH ou allocations temporaires volumineuses

L’essentiel est de ne pas juger sur une seule valeur. Une fuite mémoire est « une tendance à la croissance continue » : on compare donc dans le temps, dans les mêmes conditions, plutôt que sur un point unique.

5. Les outils utilisés

Cet article utilise principalement les outils suivants :

Outil Quand l’utiliser
dotnet-counters Observer la tendance du GC et du Working Set d’un processus en cours d’exécution
dotnet-gcdump Prendre des statistiques légères des objets managés vivants
dotnet-dump Inspecter le tas en détail et remonter jusqu’aux références avec dumpheap et gcroot
Visual Studio Memory Usage Pour une comparaison graphique sous Windows
PerfView Pour une analyse approfondie du GC / du tas / des traces sous Windows
dotnet-trace Pour suivre les allocations et les événements GC dans le temps

Commencez par installer les outils CLI.

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace

S’ils sont déjà installés, mettez-les à jour.

dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace

Recherchez le processus à étudier.

dotnet-counters ps

Dans les exemples qui suivent, l’ID du processus cible est noté <PID>.

Sous Linux, macOS et dans les environnements en conteneur, les outils de diagnostic doivent s’exécuter sous le même utilisateur que le processus cible. Selon l’environnement, vous pouvez aussi être affecté par TMPDIR, le port de diagnostic et l’espace de noms PID du conteneur.

Lorsque vous intervenez sur un environnement de production, ne prenez pas de dump directement : vérifiez d’abord la charge et son impact dans un environnement de test.

6. Commencer par observer les tendances avec dotnet-counters

Ce qu’il faut regarder en premier, ce n’est pas un dump détaillé, mais la tendance.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime

La sortie varie légèrement selon la version de .NET. À partir de .NET 9, les compteurs peuvent apparaître sous les noms de Meter System.Runtime ; en .NET 8 et versions antérieures, sous les noms traditionnels d’EventCounter.

Les éléments principaux à surveiller :

Élément à surveiller Ce qu’il indique
dotnet.process.memory.working_set La mémoire résidente du processus du point de vue de l’OS
dotnet.gc.last_collection.heap.size La taille du tas par génération après le dernier GC
dotnet.gc.last_collection.memory.committed_size La quantité de mémoire committée par le GC
dotnet.gc.heap.total_allocated Le volume cumulé alloué depuis le démarrage
dotnet.gc.collections Le nombre de GC par génération
dotnet.gc.pause.time Le temps de pause GC cumulé

Vous pouvez aussi restreindre la surveillance à certains compteurs :

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]

Pour pouvoir les consulter plus tard, enregistrez-les en CSV :

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output counters.csv \
  --counters System.Runtime

À ce stade, ce qu’il faut distinguer, ce sont les cas suivants.

6.1 Seul le Total Allocated augmente

dotnet.gc.heap.total_allocated est une valeur cumulative. Dès que l’application traite des requêtes, elle alloue des objets ; même si ces objets deviennent immédiatement inutiles et sont collectés par le GC, le volume cumulé d’allocation continue d’augmenter.

Le simple fait que Total Allocated augmente ne signifie donc pas une fuite mémoire. Ce qu’il faut observer, c’est si les allocations restent en place après coup.

Total Allocated : augmente
GC Heap Size :    fluctue mais reste stable dans l'ensemble
Gen 2 / LOH :     ne continue pas d'augmenter

Dans ce cas, il ne s’agit pas d’une fuite, mais d’une application qui alloue beaucoup.

La remédiation ne consiste pas à corriger une fuite, mais à réduire les allocations : réutilisation de buffers, révision d’un usage intensif de LINQ, réduction de la création de chaînes, révision de la sérialisation, etc.

6.2 Le Working Set augmente mais le GC Heap reste stable

Il arrive que le Working Set ou le RSS augmente alors que le GC Heap reste stable. Dans ce cas, il ne s’agit pas nécessairement d’une fuite d’objets managés.

Les facteurs possibles sont, entre autres :

  • Le code JIT-compilé
  • Les assemblies chargées
  • Les piles de threads
  • La mémoire des bibliothèques natives
  • La mémoire non managée, comme celle allouée via Marshal.AllocHGlobal
  • Les buffers côté natif pour l’image, la compression, la cryptographie, les pilotes de base de données
  • Les buffers internes pour les sockets, les descripteurs de fichiers, SSL, HTTP/2, gRPC
  • Le fait que l’OS ne récupère pas encore les pages physiques du processus

Dans cet état, examiner dumpheap en boucle ne permet parfois pas de trouver le coupable.

Le repère à retenir :

Working Set / RSS : augmente
GC Heap Size :      stable
Gen 2 / LOH :       stable

Dans ce cas, suspectez la mémoire native, les handles, le nombre de threads, les sockets et les bibliothèques externes plutôt qu’une fuite du tas managé .NET.

Ne vous limitez pas à dotnet-counters : examinez aussi les outils de l’OS, les métriques du conteneur, le nombre de handles, le nombre de threads, le tas natif et les métriques des bibliothèques externes.

6.3 Le GC Heap augmente sous charge mais redescend après l’arrêt

Il est normal que le GC Heap augmente sous charge.

Beaucoup de requêtes. Beaucoup d’objets temporaires. Traitement de gros JSON. Création temporaire de listes ou de tableaux.

Dans ces cas, le tas augmente jusqu’au prochain GC. Lorsque la charge s’arrête, un GC s’exécute et le tas peut redescendre.

Sous charge :             le GC Heap augmente
Après l'arrêt de charge : le GC Heap redescend, ou revient à une valeur stable
Après répétition :        la baseline ne continue pas d'augmenter

Dans ce cas, on peut conclure que « ce n’est simplement pas encore collecté » ou qu’« il y a beaucoup d’allocations temporaires ».

Cela dit, si les allocations temporaires sous charge sont trop nombreuses, le nombre de GC et le temps de pause augmentent, ce qui devient un problème de performance. Même sans être une fuite, cela reste une cible d’amélioration des performances.

6.4 Gen 2 / LOH continuent de croître après le GC

Voici le schéma auquel il faut prêter attention :

Répéter la même opération
  ↓
Gen 2 augmente
  ↓
Le LOH augmente
  ↓
Ne redescend pas même après l'arrêt de la charge
  ↓
La mesure suivante montre encore une augmentation

Gen 2 est la génération où résident les objets à longue durée de vie. Le LOH est le tas où tendent à se trouver les grands tableaux et chaînes de caractères.

Si cette croissance se poursuit, suspectez une fuite, un cache sans limite, la rétention de gros buffers, un défaut de désabonnement d’événement, une collection static, ou une rétention par un service à longue durée de vie.

À ce stade, passez à l’étape suivante.

7. Comment vérifier s’il s’agit d’une simple « attente du GC »

Pour vérifier si quelque chose « n’a simplement pas encore été collecté », observez l’état après avoir laissé au GC une occasion suffisante de s’exécuter.

Cependant, n’introduisez jamais GC.Collect() à la légère dans du code de production.

GC.Collect() force un GC. Un GC bloquant sur toutes les générations, en particulier, crée un temps de pause pour l’application. En exploitation normale, la règle est de laisser faire le GC.

Malgré tout, pendant une investigation, il peut être utile de vérifier, dans un environnement de vérification maîtrisé, si un objet survit à un GC forcé.

Dans une application console de vérification ou un environnement de reproduction, du code comme celui-ci permet d’examiner l’état après un GC complet :

static void ForceFullGcForDiagnosticsOnly()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}

Le point important est de ne jamais utiliser ceci comme solution. C’est uniquement à des fins d’investigation.

Ce que l’on cherche à vérifier, c’est ce déroulé :

Avant l'opération
  ↓
Répéter l'opération N fois
  ↓
Arrêter la charge
  ↓
Attendre suffisamment, ou provoquer un GC complet dans l'environnement de vérification
  ↓
Le tas après GC revient-il à une valeur proche de celle d'avant l'opération ?

S’il y revient, il s’agit probablement d’une attente du GC ou d’allocations temporaires. S’il n’y revient pas, et que la baseline monte à chaque répétition de la même opération, quelque chose survit. On recherche alors ce « quelque chose » dans un dump.

8. Comparaison légère avec dotnet-gcdump

Pour une première comparaison, dotnet-gcdump est pratique.

dotnet-gcdump permet de capturer un GC dump depuis un processus .NET en cours d’exécution et d’en observer les statistiques par type sur le tas.

dotnet-gcdump collect --process-id <PID> --output before.gcdump

Après avoir appliqué la charge, capturez-le à nouveau.

dotnet-gcdump collect --process-id <PID> --output after.gcdump

Vous pouvez aussi produire un rapport simple depuis la CLI.

dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump  > after-heap.txt

Ce qu’il faut regarder, c’est le Count et la Size de chaque type.

Par exemple, si des types comme les suivants ont fortement augmenté dans after, ils deviennent des cibles d’investigation :

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[]

Ce qui compte, ce n’est pas « le type volumineux » mais « le type qui a augmenté ».

System.String et System.Byte[] figurent parmi les premiers dans la plupart des applications. Être en tête ne fait pas d’eux le coupable pour autant.

L’angle de comparaison :

before → after Lecture
Le Count reste quasiment inchangé Ce type n’est probablement pas le principal coupable
Le Count et la Size augmentent tous deux Un candidat
Les types MyApp.* augmentent Suspecter une rétention dans la logique métier
System.Byte[] augmente Suspecter des buffers, la sérialisation, l’image, la compression, HTTP, la base de données
System.String augmente Suspecter des caches, des logs, du JSON, des clés de dictionnaire, des chaînes dupliquées
Task, Timer, CancellationTokenSource augmentent Suspecter du traitement asynchrone, des Timer, des désabonnements/annulations manquées

dotnet-gcdump est un point d’entrée pratique pour la comparaison, mais sa capture provoque un GC de Gen 2. Dans les environnements à tas volumineux ou aux exigences de latence strictes, faites attention au temps de pause et à la consommation mémoire supplémentaire.

Sous Windows, vous pouvez ouvrir les fichiers .gcdump dans Visual Studio ou PerfView pour les comparer. Sur des environnements non Windows, l’approche pratique consiste à examiner les statistiques par type avec le report en CLI, puis à passer à dotnet-dump pour approfondir les références.

9. Inspecter le tas et les références avec dotnet-dump

Une fois les « types qui augmentent » identifiés, l’étape suivante consiste à comprendre « pourquoi ils ne sont pas collectés ».

Pour cela, capturez un dump avec dotnet-dump et analysez-le avec les commandes SOS.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-1.dmp

Attendez un moment, puis capturez-en un second.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-2.dmp

La capture de dump est une opération lourde. Les dumps Full / Heap en particulier sont volumineux et sollicitent le processus et le conteneur. En production, faites attention au créneau horaire, à l’espace disque, aux limites mémoire du conteneur, ainsi qu’au risque que des informations personnelles ou confidentielles s’y retrouvent.

Analysez le dump obtenu.

dotnet-dump analyze myapp-2.dmp

Commencez par regarder les statistiques globales du tas.

> dumpheap -stat

La sortie donne le nombre d’instances et la taille par type.

MT               Count       TotalSize Class Name
00007f...        120000      3840000   MyApp.Models.Order
00007f...        250000      8000000   System.String
00007f...         10000     40000000   System.Byte[]

Filtrez sur un type précis.

> dumpheap -stat -type MyApp.Models.Order

Ou filtrez sur une MethodTable précise.

> dumpheap -mt <MT>

Une fois l’adresse d’une instance connue, remontez à ses références.

> gcroot <OBJECT_ADDRESS>

C’est le point le plus important. Avec gcroot, vous vérifiez pourquoi cet objet est encore vivant.

Imaginons que le chemin de référence suivant apparaisse :

static MyApp.CustomerCache._items
  -> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
  -> MyApp.Models.Customer
  -> System.String

Dans ce cas, la raison pour laquelle le GC ne collecte pas est claire : Customer est référencé depuis un cache static, donc du point de vue du GC il est encore en cours d’utilisation.

C’est seulement à ce stade que l’on peut se poser les questions suivantes :

  • Ce cache est-il vraiment nécessaire ?
  • A-t-il une limite ?
  • A-t-il une expiration ?
  • Le design fait-il que les clés augmentent indéfiniment ?
  • Est-il indexé sur le tenant, l’utilisateur, la date, l’ID de requête, etc., et croît-il sans limite ?

Dans une investigation de fuite mémoire, l’important est de ne pas s’arrêter à dumpheap -stat. dumpheap -stat indique « ce qu’il y a en grande quantité » ; gcroot indique « pourquoi cela reste en place ». C’est ce dernier qui mène à la correction.

10. Aide-mémoire pour le diagnostic

Récapitulatif des schémas les plus fréquents en pratique :

Observation Possibilité À examiner ensuite
Seul le Total Allocated augmente Allocation normale, ou allocation excessive Allocation Rate, nombre de GC, CPU, dotnet-trace
Le Working Set augmente mais le GC Heap est stable Mémoire native, JIT, piles, rétention côté OS Nombre de threads, nombre de handles, outils natifs, bibliothèques externes
Le GC Heap augmente uniquement sous charge et redescend après Attente du GC, allocations temporaires Gen 2 / LOH après l’arrêt de la charge, nombre de GC
Gen 2 continue de croître après le GC Rétention d’objets à longue durée de vie dumpheap -stat, gcroot
Le LOH continue de croître Grands tableaux, buffers, fragmentation, chaînes gigantesques System.Byte[], System.Char[], LOH, zones Free
System.String est volumineux Cache de chaînes, JSON, logs, clés de dictionnaire Rechercher les types propres qui détiennent les chaînes
System.Byte[] est volumineux Buffers, sérialisation, image, compression, communication Type propriétaire, retours ArrayPool manqués, interopérabilité native
Task augmente Traitement asynchrone qui ne se termine jamais, files d’attente Attentes async, annulation, canaux, files
Timer augmente Timer non libéré Dispose, désenregistrement, service à longue durée de vie
CancellationTokenSource augmente CTS non libéré, trop de tokens liés Dispose, déliaison, endroits créant des timeouts
EventHandler ou delegate restent en place Désabonnement d’événement manqué Écart de durée de vie entre publisher et subscriber
Finalization Queue augmente Dispose manqué, finaliseur engorgé finalizequeue, thread finaliseur
Beaucoup de handles épinglés Buffers épinglés, interopérabilité native gchandles, POH, points d’épinglage

11. Formes courantes de fuite mémoire

11.1 Collections static

La forme la plus facile à comprendre.

public static class CustomerStore
{
    private static readonly List<Customer> Customers = new();

    public static void Add(Customer customer)
    {
        Customers.Add(customer);
    }
}

Dans ce code, tout Customer ajouté à Customers reste en place aussi longtemps que le processus vit. Même si l’intention était un stockage temporaire, tant qu’un objet est référencé depuis un champ static, le GC ne le collecte pas.

L’orientation de la correction dépend de l’usage :

  • Imposer une limite
  • Ajouter une expiration
  • Utiliser un mécanisme de cache tel que MemoryCache
  • Supprimer explicitement les éléments
  • Abandonner le static et migrer vers un service avec une durée de vie appropriée
  • Si l’objectif est la persistance, migrer vers une base de données ou un stockage externe

L’important n’est pas de dire que « static, c’est mal », mais de comprendre et de respecter le fait que tout ce que l’on place dans un static devient à longue durée de vie.

11.2 Cache sans limite

Un cache utilise intentionnellement de la mémoire ; une croissance conforme à la conception n’est donc pas une fuite. Mais un cache sans limite ni expiration devient de facto une fuite mémoire.

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;
    }
}

Dans cet exemple, si le nombre de combinaisons userId / date continue de croître, le cache continue de croître aussi.

Les cas particulièrement dangereux sont ceux où la clé inclut des valeurs comme :

  • Un ID de requête
  • L’heure actuelle
  • Un GUID
  • Un ID de session
  • Une chaîne construite à partir d’une saisie utilisateur non normalisée
  • Une condition SQL ou de recherche transformée telle quelle en chaîne

Pour tout cache, définissez à l’avance les conditions suivantes :

Condition Exemple
Nombre maximal d’entrées Jusqu’à 10 000 entrées
Taille maximale Jusqu’à 256 Mo
Expiration glissante 30 minutes après le dernier accès
Expiration absolue 6 heures après la création
Conditions de purge Suppression du tenant, suppression de l’utilisateur, changement de configuration
Éléments à surveiller Nombre d’entrées, taille estimée, taux de succès, nombre d’évictions

Ce n’est pas « c’est un cache, donc il peut grossir », mais « jusqu’où peut-il grossir » qu’il faut décider.

11.3 Désabonnement d’événement manqué

Les événements deviennent une fuite lorsqu’un publisher à longue durée de vie continue de référencer un subscriber à courte durée de vie.

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
    }
}

Si OrderService est un singleton et qu’un OrderViewModel est créé pour chaque écran, l’événement d’OrderService continue de référencer l’OrderViewModel. Fermer l’écran ne libère pas le ViewModel tant qu’on ne s’est pas désabonné.

Exemple de correction :

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
    }
}

Dans gcroot, cela peut apparaître comme une référence via un delegate ou un event handler.

Ce schéma revient souvent avec WPF, WinForms, les services à longue durée de vie, les message brokers et les event aggregators.

11.4 Timer non libéré

System.Threading.Timer, PeriodicTimer, ou encore les subscriptions Reactive Extensions restent aussi en place s’ils ne sont pas libérés.

public sealed class PollingWorker
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    private void Poll()
    {
        // polling
    }
}

Si ce PollingWorker est censé être un objet temporaire, il faut que le design libère le 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
    }
}

Un Timer détient le delegate de son callback, d’où une chaîne de références peut mener jusqu’à l’objet cible.

11.5 IDisposable non libéré

Un IDisposable non libéré ne se manifeste pas forcément comme une fuite sur le tas managé.

Cela peut apparaître comme un problème de ressources : fichiers, sockets, connexions à la base de données, handles natifs, buffers.

public async Task<string> ReadAsync(string path)
{
    var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

Dans cet exemple, StreamReader ferme stream, donc ce n’est souvent pas un gros problème — mais dans du code où la propriété est ambiguë, des fuites se produisent.

La règle de base consiste à clarifier la propriété avec 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();
}

Un Dispose manqué se manifeste par des symptômes comme :

  • Le nombre de handles augmente
  • Les sockets s’accumulent
  • Les fichiers ne se ferment jamais
  • La mémoire native augmente
  • La Finalization Queue augmente
  • La mémoire du processus augmente alors que le GC Heap est stable

Dans ce cas, dumpheap seul ne suffit pas. Examinez aussi les handles et sockets de l’OS, ainsi que l’état des bibliothèques externes.

11.6 AsyncLocal et rétention de contexte

AsyncLocal<T> est pratique, mais si ce que l’on y stocke est volumineux, cela peut rester en place longtemps.

Une petite valeur, comme un ID de corrélation de log, pose rarement problème. Mais y placer des informations utilisateur, un corps de requête, un gros DTO ou un contexte de base de données conduit à une rétention non intentionnelle.

public static class RequestContext
{
    public static readonly AsyncLocal<RequestInfo?> Current = new();
}

Comme AsyncLocal suit le flux asynchrone, il peut être plus difficile à repérer qu’un simple champ static.

Envisagez de garder ce que vous y stockez petit et bien défini, et de le remettre à null lorsqu’il n’est plus nécessaire.

11.7 Confusion de durée de vie DI

Dans les conteneurs DI comme celui d’ASP.NET Core, singleton, scoped et transient ont des durées de vie différentes.

Si un singleton à longue durée de vie conserve des données propres à chaque requête, des objets peuvent rester en place après la fin de la requête.

public sealed class AuditBuffer
{
    private readonly List<RequestAudit> _items = new();

    public void Add(RequestAudit item)
    {
        _items.Add(item);
    }
}

Si cette classe est un singleton, _items vit aussi longtemps que l’application.

Si la mise en tampon est intentionnelle, la conception a besoin d’une limite, d’un envoi, d’une suppression et d’une contre-pression (backpressure). S’il s’agit simplement de « on regardera peut-être plus tard », il vaut mieux écrire ces données dans les logs ou un stockage externe.

12. Le LOH est particulièrement facile à mal interpréter

LOH signifie Large Object Heap. En .NET, les grands objets sont placés sur un tas distinct des petits objets ordinaires. L’exemple typique est un grand tableau.

var buffer = new byte[1024 * 1024 * 10]; // 10MB

Les trois problèmes courants du LOH :

  1. Créer fréquemment de grands objets
  2. Conserver longtemps de grands objets
  3. La fragmentation due à la création et à la destruction de grands objets

Un LOH qui augmente ne signifie pas immédiatement une fuite. Avec une conception qui réutilise de grands buffers, il peut croître jusqu’à une certaine taille puis se stabiliser ; et même quand le GC collecte, le Working Set ne redescend pas forcément tout de suite.

Cependant, les états suivants doivent éveiller les soupçons :

  • System.Byte[] augmente à chaque opération
  • System.Char[] ou des String gigantesques augmentent
  • La mémoire ne redescend pas après un traitement d’image, de PDF, d’Excel, de ZIP, de cryptographie ou de compression
  • Des tableaux obtenus via ArrayPool<T>.Rent ne sont pas rendus
  • De grandes réponses sont chargées intégralement en mémoire
  • MemoryStream.ToArray() est utilisé abondamment

Si vous utilisez ArrayPool<T>, rendez systématiquement ce que vous avez emprunté.

var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);

try
{
    // use buffer
}
finally
{
    pool.Return(buffer);
}

Cela dit, le fait de rendre au pool ne fait pas forcément redescendre immédiatement la mémoire du processus. Les pools peuvent conserver de la mémoire en vue de sa réutilisation.

Là encore, ce qu’il faut observer, c’est si cela continue de croître, s’il existe une limite, et si la mémoire est effectivement réutilisée.

13. Comment lire gcroot

gcroot affiche depuis où un objet donné est référencé.

Voici un tableau des racines typiques :

Racine Signification
static field Référencé depuis un champ static d’un type
local variable / stack Référencé depuis la pile d’un thread en cours d’exécution
GC handle Référencé via un GCHandle, un épinglage (pin), un delegate, de l’interop
finalization queue Retenu en attente de finalisation
thread / async state machine Retenu par un traitement asynchrone en cours d’exécution ou d’attente

Le point à surveiller dans l’investigation est l’écart de durée de vie :

Objet à longue durée de vie
  -> Objet censé être à courte durée de vie

Quand cette forme apparaît, c’est un candidat à la fuite.

Par exemple, ceci est suspect :

SingletonService
  -> List<RequestContext>
  -> RequestContext
  -> LargeDto

SingletonService vit aussi longtemps que l’application entière. Si des RequestContext propres à chaque requête s’y accumulent, la conception doit être revue.

À l’inverse, une racine comme celle-ci peut être parfaitement normale selon le moment :

Thread stack
  -> Controller action local variable
  -> RequestDto

Pendant le traitement d’une requête, il est normal que des variables locales soient encore présentes.

C’est pourquoi le moment de la capture du dump est important.

Prendre des dumps non seulement sous charge, mais aussi après l’arrêt de la charge, une fois les files vidées, et après une période d’inactivité, facilite grandement le diagnostic.

14. « La mémoire a baissé après un GC forcé » ne veut pas dire « c’est réglé »

Pendant l’investigation, vous appelez GC.Collect() et la mémoire baisse. Il est dangereux d’en conclure « alors il suffit d’appeler GC.Collect() périodiquement ».

Un GC forcé ne supprime pas la cause racine. Il se contente de collecter sur place des objets qui n’avaient simplement pas encore été ramassés.

Si un taux d’allocation élevé est le problème, un GC forcé augmente le temps de pause et dégrade les performances. S’il s’agit d’une véritable fuite, les objets encore référencés ne sont pas non plus collectés par un GC forcé.

Ce qu’il faut observer dans l’investigation, c’est la distinction suivante :

Après un GC forcé Diagnostic
Baisse fortement, puis la baseline reste stable Attente du GC ou allocations temporaires comme cause principale
Baisse un peu, mais le plancher remonte à chaque répétition Une partie survit. Candidat à la fuite
Baisse à peine Encore référencé, ou la cause principale se situe hors du tas GC
Le GC Heap baisse mais pas le Working Set Rétention possible côté OS / segments GC / natif

Avant d’exécuter GC.Collect() périodiquement en production, identifiez systématiquement ce qui augmente réellement.

15. Procédure d’investigation utilisée en pratique

À partir d’ici, formalisons la procédure suivie lors d’une investigation réelle.

15.1 Fixer le scénario de reproduction

Commencez par fixer les conditions de l’investigation.

Cible :             /api/report/export
Opération :         100 exécutions dans les mêmes conditions
Intervalle mesure : 5 secondes
Fenêtre :           5 min de warm-up + 10 min de charge + 5 min d'inactivité
Environnement :     staging / build Release / configuration équivalente à la production

Dans une investigation mémoire, on ne peut pas conclure si l’on fait une opération différente à chaque fois. Fixez « ce qui a été fait quand la mémoire a augmenté ».

15.2 Prendre une baseline

Utilisez l’état après le warm-up comme baseline, pas juste après le démarrage.

La raison est qu’il existe, juste après le démarrage, des hausses ponctuelles comme celles-ci :

  • Le JIT
  • La construction du conteneur DI
  • Le chargement de la configuration
  • La première connexion à la base de données
  • La première connexion TLS / HTTP
  • La génération des métadonnées du sérialiseur JSON
  • L’initialisation de Razor / des templates
  • L’initialisation du logger et des métriques

L’ordre à suivre :

1. Démarrer l'application
2. Appeler quelques fois les health checks ou une API représentative
3. Attendre environ 1 à 5 minutes
4. Capturer counters et un dump comme baseline

15.3 Collecter les counters sous charge

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output report-export-counters.csv \
  --counters System.Runtime

Effectuez en parallèle l’opération de reproduction. Ce qui vous intéresse, c’est la forme du graphique.

Forme proche de la normale :
  augmente sous charge
  oscille avec les GC
  redescend après l'arrêt de la charge
  la baseline ne continue pas d'augmenter

Forme suspecte :
  augmente proportionnellement au nombre d'opérations
  le plancher de Gen 2 / LOH remonte
  ne redescend pas même après l'arrêt de la charge
  le plancher remonte encore avec la charge suivante

15.4 Prendre deux dumps

Capturez-les avant et après la charge.

dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# appliquer la charge
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp

Si possible, capturez-en un aussi après l’arrêt de la charge.

# après l'arrêt de la charge, une fois les files vides, et après une attente suffisante
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp

Pour la comparaison, idle-after compte autant que before et after.

Même si la mémoire a augmenté sous charge, un retour à la normale après l’inactivité peut indiquer qu’il ne s’agit pas d’une fuite.

15.5 Examiner les types qui ont augmenté

dotnet-dump analyze after.dmp
> dumpheap -stat

Examinez le côté before de la même façon. Le faire manuellement est acceptable ; commencez par comparer les types en tête de liste.

Les points à examiner :

  • Les types de vos propres namespaces augmentent-ils ?
  • Y a-t-il un type propre caché derrière System.String ?
  • Qui détient les System.Byte[] ?
  • List<T> ou Dictionary<TKey,TValue> augmentent-ils ?
  • Des Task ou des state machines async augmentent-ils ?
  • Des Timer ou CancellationTokenSource augmentent-ils ?

15.6 Examiner les références

Repérez des adresses d’objets candidats et lancez gcroot.

> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>

À partir du résultat de gcroot, recherchez le parent qui retient l’objet.

MyApp.Services.ReportCache
  -> Dictionary<string, ReportResult>
  -> ReportResult

À ce stade, les cibles de la revue de code deviennent visibles.

  • ReportCache est-il un singleton ?
  • A-t-il une limite ?
  • Les entrées sont-elles supprimées ?
  • Les clés continuent-elles d’augmenter ?
  • ReportResult est-il trop volumineux ?
  • Faudrait-il déporter cela vers une base de données ou des fichiers plutôt qu’un cache ?

16. Quand utiliser dotnet-trace

dotnet-dump est adapté à l’observation d’un instantané, pour voir « ce qui reste en résultat ». En revanche, pour voir « quand et où se produisent les allocations massives », utilisez dotnet-trace.

Par exemple, une trace incluant les événements liés au GC :

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:01:00 \
  --clrevents gc+gchandle \
  --clreventlevel informational \
  --output gc-trace.nettrace

Si vous voulez aller jusqu’à l’échantillonnage des allocations, le volume d’événements augmente ; commencez donc par de courtes durées dans un environnement de vérification.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:00:30 \
  --clrevents gc+gcsampledobjectallocationhigh \
  --clreventlevel informational \
  --output allocation-trace.nettrace

Les traces sont utiles sous un angle différent des dumps.

Ce que vous voulez voir L’outil adapté
Ce qui reste dump / gcdump
Qui le référence dump + gcroot
Quand ont eu lieu les allocations massives trace
Quand les GC se sont produits counters / trace
Si le temps de pause est le problème counters / trace

Dans une investigation de fuite, il est efficace de commencer par le dump pour voir « ce qui reste », puis, si nécessaire, la trace pour voir « où cela se crée ».

17. Exposer des métriques de vérification dans le code

Le diagnostic sérieux doit être mené avec des outils externes, mais disposer d’une journalisation diagnostique simple côté application est utile.

Par exemple, exposer des informations GC via un endpoint d’administration ou un log périodique :

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)
        };
    }
}

Cette seule information ne permet pas de déterminer une fuite. Mais en cas d’incident, elle facilite les diagnostics suivants :

  • Gen 2 augmente-t-il brutalement ?
  • HeapSize augmente-t-il ?
  • FragmentedBytes augmente-t-il ?
  • L’écart entre TotalMemory et la mémoire du processus est-il important ?
  • La tendance a-t-elle changé après un déploiement ?

Si vous l’intégrez aux logs applicatifs, attention à ne pas en abuser. Un diagnostic lourd effectué à haute fréquence devient lui-même une charge.

18. Le critère permettant de conclure à une « fuite mémoire »

À la fin de l’investigation, vous devriez pouvoir l’expliquer sous cette forme :

Symptôme :
  Après 100 exécutions de /api/report/export, le GC Heap reste 300 Mo plus élevé même après l'arrêt de la charge.

Observation :
  Avec dotnet-counters, la taille du tas de Gen 2 a augmenté proportionnellement au nombre d'opérations.
  Ce n'est pas seulement le Working Set, mais aussi le GC Heap qui augmentait.

Comparaison :
  En comparant before.dmp et after.dmp, MyApp.Models.ReportResult avait augmenté de 12 000 instances.

Référence :
  gcroot a montré une référence depuis MyApp.Services.ReportCache._items.

Cause :
  ReportCache était un singleton indexé sur l'ID utilisateur + l'heure actuelle, sans suppression, expiration ni limite.

Correction :
  Remplacé par MemoryCache, avec une limite de taille et une expiration configurées.
  Le nombre d'entrées du cache a été transformé en métrique.

Si vous pouvez expliquer les choses jusque-là, le rapport ne se limite plus à « la mémoire augmente » : il relie les conditions de reproduction, les observations, le type qui augmente, les références, la cause et la correction.

19. Points de vigilance pendant l’investigation

19.1 Observer avec un build Release

Un build Debug peut donner une apparence différente de l’exploitation réelle, en raison des optimisations, de la durée de vie des variables locales et des informations de débogage.

Pour une investigation représentative de la production, vérifiez avec un build Release, une configuration proche de l’exploitation et un volume de données proche.

19.2 Ne pas juger uniquement juste après le démarrage

Juste après le démarrage, la mémoire augmente en raison de nombreuses initialisations.

Prenez une baseline après le warm-up et observez la croissance à partir de là.

19.3 Ne pas incriminer un type sur la base d’un seul dump

Le type en tête du tas n’est pas nécessairement le coupable.

System.String et System.Byte[] apparaissent volumineux dans la plupart des applications.

Ce qui compte, c’est de savoir s’ils ont augmenté dans le temps, et qui les détient.

19.4 Les dumps contiennent des informations sensibles

Un dump mémoire peut contenir des requêtes, des identifiants d’authentification, des chaînes de connexion, des données personnelles et des données métier.

Définissez des règles pour le lieu de stockage, la sortie du site, le partage et la suppression.

19.5 En conteneur, la capture de dump est elle-même un risque

Si les limites mémoire du conteneur sont strictes, la mémoire supplémentaire et les page-ins provoqués par la capture d’un dump peuvent entraîner un OOM Kill.

Avant de capturer un dump dans un conteneur de production, testez d’abord en staging et vérifiez les limites, l’espace disque, les permissions et l’espace de noms PID.

19.6 Il existe aussi des fuites en dehors du GC Heap

Ce n’est pas parce qu’il s’agit d’une investigation .NET que tout se manifeste sur le tas GC.

Dans des problèmes comme les suivants, la mémoire du processus peut augmenter alors que le GC Heap reste stable :

  • Bibliothèques natives
  • P/Invoke
  • COM
  • Traitement d’image
  • Bibliothèques de compression
  • Traitement cryptographique
  • Pilotes de base de données
  • Sockets
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • Trop de threads

Dans ce cas, dumpheap de dotnet-dump seul ne suffit pas. Il faut examiner les diagnostics côté OS, les métriques des bibliothèques externes, les handles, les threads et la mémoire native.

20. Vérification après la correction

Une fois l’emplacement suspecté de fuite corrigé, refaites la mesure avec la même procédure.

Avant la correction :
  Après 100 exécutions, Gen 2 : +300 Mo
  ReportResult : +12 000 instances

Après la correction :
  Après 100 exécutions, Gen 2 reste stable en dessous de +20 Mo
  ReportResult revient à la baseline après l'arrêt de la charge
  Le nombre d'entrées du cache est stable à la limite de 1 000 entrées

Lors de la vérification d’une correction, comparez toujours dans les mêmes conditions :

  • Même volume de données
  • Même nombre d’exécutions
  • Même durée de charge
  • Même warm-up
  • Même intervalle d’observation
  • Mêmes outils

Une investigation mémoire n’est convaincante que si la comparaison avant/après correction est solide.

21. Conclusion

Quand la mémoire augmente en .NET, ne concluez pas immédiatement à une fuite : procédez au tri dans cet ordre.

  1. Ne pas juger sur le seul Working Set / RSS
  2. Observer le GC Heap, Gen 2, le LOH et le nombre de GC avec dotnet-counters
  3. Comparer sous charge, après l’arrêt de charge, et dans le temps
  4. Examiner les types qui ont augmenté avec dotnet-gcdump ou dotnet-dump
  5. Examiner les références avec gcroot
  6. Vérifier les static, les caches, les événements, les Timer, les durées de vie DI, les contextes asynchrones
  7. Si le GC Heap est stable, suspecter aussi la mémoire native et les problèmes côté OS

La différence entre « simplement pas encore collecté par le GC » et « fuite mémoire » se joue finalement sur les références.

Si un objet devenu inutile n’a plus de référence, il est collecté au moment du GC. S’il devrait être inutile mais reste référencé, le GC ne peut pas le collecter.

Autrement dit, l’objectif de l’investigation est le suivant :

Qu'est-ce qui augmente ?
Qu'est-ce qui survit à chaque GC ?
Qui le référence ?
Cette référence est-elle nécessaire du point de vue de la conception ?

Une fois cela établi, vous n’êtes plus ballotté par les graphiques de mémoire et pouvez traduire le problème en corrections concrètes dans le code.

Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Une hausse de la mémoire utilisée par une application .NET signifie-t-elle une fuite mémoire ?
Une hausse de la mémoire du processus et une fuite mémoire ne sont pas la même chose. En .NET, le GC agit en fonction de l'état des allocations et des seuils du tas ; il se peut simplement que des objets devenus inutiles n'aient pas encore été collectés, ou que l'OS ne récupère pas immédiatement la mémoire après un GC. Les trois points à observer sont : la mémoire qui survit au GC augmente-t-elle, quel type augmente, et qui référence cet objet.
Quels outils utiliser pour étudier une fuite mémoire en .NET ?
Commencez par dotnet-counters pour observer la tendance du Working Set, du GC Heap, de Gen 2/LOH et du nombre de GC. Prenez ensuite des GC dump avant et après charge avec dotnet-gcdump, et comparez le Count et la Size par type pour identifier les types qui ont augmenté. Enfin, prenez un dump du tas avec dotnet-dump et remontez jusqu'à la référence responsable avec dumpheap -stat et gcroot, pour comprendre pourquoi l'objet n'est pas collecté. L'important n'est pas une valeur ponctuelle, mais une comparaison dans le temps dans les mêmes conditions.
Quels sont les schémas de fuite mémoire les plus courants en .NET ?
Les cas typiques sont : des ajouts continus à une collection static, un cache sans limite ni expiration, un défaut de désabonnement d'événement vis-à-vis d'un publisher à longue durée de vie, un Timer non libéré, un IDisposable non libéré, ou une confusion de durée de vie DI où un singleton conserve des données propres à une requête. En .NET, il est plus juste de parler de « rétention non intentionnelle » — un objet devenu inutile mais encore référencé — plutôt que d'un simple oubli de libération.
Exécuter GC.Collect() périodiquement résout-il les problèmes de mémoire ?
Non. Un GC forcé ne fait que collecter sur place les objets qui n'avaient pas encore été ramassés ; il ne supprime pas la cause racine. Si le taux d'allocation élevé est le problème, un GC forcé allonge les temps de pause et dégrade les performances ; s'il s'agit d'une vraie fuite, les objets encore référencés ne seront pas collectés même par un GC forcé. Il peut être utile, dans un environnement de vérification maîtrisé, de constater si un objet survit à un GC forcé à des fins d'investigation, mais avant d'introduire cela en production comme solution, il faut impérativement identifier ce qui augmente réellement.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog