Comprendre le garbage collection dans .NET

170

Considérez le code ci-dessous:

public class Class1
{
    public static int c;
    ~Class1()
    {
        c++;
    }
}

public class Class2
{
    public static void Main()
    {
        {
            var c1=new Class1();
            //c1=null; // If this line is not commented out, at the Console.WriteLine call, it prints 1.
        }
        GC.Collect();
        GC.WaitForPendingFinalizers();
        Console.WriteLine(Class1.c); // prints 0
        Console.Read();
    }
}

Maintenant, même si la variable c1 dans la méthode main est hors de portée et n'est plus référencée par aucun autre objet lorsqu'elle GC.Collect()est appelée, pourquoi n'y est-elle pas finalisée?

Victor Mukherjee
la source
8
Le GC ne libère pas immédiatement les instances lorsqu'elles sont hors de portée. Il le fait quand il le juge nécessaire. Vous pouvez tout lire sur le GC ici: msdn.microsoft.com/en-US/library/vstudio/0xy59wtx.aspx
user1908061
@ user1908061 (Pssst. Votre lien est rompu.)
Dragomok

Réponses:

352

Vous êtes trébuché ici et tirez de très mauvaises conclusions parce que vous utilisez un débogueur. Vous devrez exécuter votre code comme il s'exécute sur la machine de votre utilisateur. Basculez d'abord vers la version Release avec Build + Configuration Manager, changez le combo «Configuration de la solution active» dans le coin supérieur gauche en «Release». Ensuite, allez dans Outils + Options, Débogage, Général et décochez l'option "Supprimer l'optimisation JIT".

Maintenant, exécutez à nouveau votre programme et bricolez le code source. Notez que les accolades supplémentaires n'ont aucun effet. Et notez que la définition de la variable sur null ne fait aucune différence. Il imprimera toujours "1". Il fonctionne maintenant comme vous l'espérez et vous vous attendez à ce qu'il fonctionne.

Ce qui laisse la tâche d'expliquer pourquoi cela fonctionne si différemment lorsque vous exécutez la version Debug. Cela nécessite d'expliquer comment le garbage collector découvre les variables locales et comment cela est affecté par la présence d'un débogueur.

Tout d'abord, le jitter effectue deux tâches importantes lorsqu'il compile l'IL d'une méthode en code machine. Le premier est très visible dans le débogueur, vous pouvez voir le code machine avec la fenêtre Debug + Windows + Disassembly. Le second devoir est cependant totalement invisible. Il génère également un tableau qui décrit comment les variables locales à l'intérieur du corps de la méthode sont utilisées. Cette table a une entrée pour chaque argument de méthode et variable locale avec deux adresses. L'adresse où la variable stockera d'abord une référence d'objet. Et l'adresse de l'instruction de code machine où cette variable n'est plus utilisée. Aussi si cette variable est stockée sur le frame de pile ou un registre CPU.

Cette table est essentielle pour le garbage collector, il a besoin de savoir où chercher les références d'objet lorsqu'il effectue une collecte. Assez facile à faire lorsque la référence fait partie d'un objet sur le tas GC. Certainement pas facile à faire lorsque la référence d'objet est stockée dans un registre CPU. Le tableau indique où chercher.

L'adresse «plus utilisée» dans le tableau est très importante. Cela rend le ramasse-miettes très efficace . Il peut collecter une référence d'objet, même si elle est utilisée dans une méthode et que cette méthode n'a pas encore fini de s'exécuter. Ce qui est très courant, votre méthode Main () par exemple ne cessera de s'exécuter que juste avant la fin de votre programme. De toute évidence, vous ne voudriez pas que les références d'objets utilisées dans cette méthode Main () vivent pendant toute la durée du programme, ce qui équivaudrait à une fuite. La gigue peut utiliser la table pour découvrir qu'une telle variable locale n'est plus utile, en fonction de la progression du programme dans cette méthode Main () avant d'effectuer un appel.

Une méthode presque magique liée à cette table est GC.KeepAlive (). C'est une méthode très spéciale, elle ne génère aucun code. Son seul devoir est de modifier ce tableau. Il s'étendla durée de vie de la variable locale, empêchant la référence qu'elle stocke d'être récupérée. Le seul moment où vous devez l'utiliser est d'empêcher le GC d'être trop impatient de collecter une référence, ce qui peut se produire dans les scénarios d'interopérabilité où une référence est transmise à du code non managé. Le garbage collector ne peut pas voir de telles références utilisées par un tel code car il n'a pas été compilé par la gigue et n'a donc pas le tableau indiquant où chercher la référence. Passer un objet délégué à une fonction non gérée comme EnumWindows () est l'exemple standard du moment où vous devez utiliser GC.KeepAlive ().

Ainsi, comme vous pouvez le voir d'après votre exemple d'extrait de code après l'avoir exécuté dans la version Release, les variables locales peuvent être collectées tôt, avant que la méthode ne termine son exécution. Encore plus puissant, un objet peut se collecté pendant que l' une de ses méthodes exécute si cette méthode ne fait plus référence à ce . Il y a un problème avec cela, il est très difficile de déboguer une telle méthode. Puisque vous pouvez bien mettre la variable dans la fenêtre de surveillance ou l'inspecter. Et il disparaîtrait pendant le débogage si un GC se produit. Ce serait très désagréable, donc la gigue est consciente qu'il y a un débogueur attaché. Il modifie ensuitele tableau et modifie la "dernière adresse utilisée". Et le change de sa valeur normale à l'adresse de la dernière instruction de la méthode. Ce qui maintient la variable en vie tant que la méthode n'est pas retournée. Ce qui vous permet de continuer à le regarder jusqu'au retour de la méthode.

Cela explique maintenant également ce que vous avez vu plus tôt et pourquoi vous avez posé la question. Il imprime "0" car l'appel GC.Collect ne peut pas collecter la référence. Le tableau indique que la variable est en cours d' utilisation après l'appel GC.Collect (), jusqu'à la fin de la méthode. Obligé de le dire en attachant le débogueur et en exécutant la version Debug.

La définition de la variable sur null a un effet maintenant car le GC inspectera la variable et ne verra plus de référence. Mais assurez-vous de ne pas tomber dans le piège dans lequel de nombreux programmeurs C # sont tombés, écrire ce code était inutile. Cela ne fait aucune différence que cette instruction soit présente ou non lorsque vous exécutez le code dans la version Release. En fait, l'optimiseur de gigue supprimera cette instruction car elle n'a aucun effet. Assurez-vous donc de ne pas écrire de code comme ça, même si cela semble avoir un effet.


Une dernière remarque sur ce sujet, c'est ce qui pose des problèmes aux programmeurs qui écrivent de petits programmes pour faire quelque chose avec une application Office. Le débogueur les obtient généralement sur le mauvais chemin, ils veulent que le programme Office se ferme à la demande. La manière appropriée de le faire est d'appeler GC.Collect (). Mais ils découvriront que cela ne fonctionnera pas lorsqu'ils débogueront leur application, ce qui les conduira dans un pays jamais-jamais en appelant Marshal.ReleaseComObject (). Gestion manuelle de la mémoire, cela fonctionne rarement correctement car ils oublieront facilement une référence d'interface invisible. GC.Collect () fonctionne réellement, mais pas lorsque vous déboguez l'application.

Hans Passant
la source
1
Voir aussi ma question à laquelle Hans a bien répondu pour moi. stackoverflow.com/questions/15561025/…
Dave Nay
1
@HansPassant Je viens de trouver cette explication géniale, qui répond également à une partie de ma question ici: stackoverflow.com/questions/30529379/… sur GC et la synchronisation des threads. Une question que j'ai toujours: je me demande si le GC compacte et met à jour réellement les adresses qui sont utilisées dans un registre (stockées en mémoire pendant qu'elles sont suspendues), ou les saute simplement? Un processus qui met à jour les registres après avoir suspendu le thread (avant la reprise) me semble être un thread de sécurité sérieux bloqué par le système d'exploitation.
atlaste
Indirectement, oui. Le thread est suspendu, le GC met à jour le magasin de sauvegarde pour les registres CPU. Une fois que le thread reprend son exécution, il utilise désormais les valeurs de registre mises à jour.
Hans Passant
1
@HansPassant, j'apprécierais que vous ajoutiez des références pour certains des détails non évidents du garbage collector CLR que vous avez décrits ici?
denfromufa
Il semble que sur le plan de la configuration, un point important est que "Optimiser le code" ( <Optimize>true</Optimize>in .csproj) est activé. Il s'agit de la valeur par défaut dans la configuration "Release". Mais dans le cas où l'on utilise des configurations personnalisées, il est pertinent de savoir que ce paramètre est important.
Zero3 du
34

[Je voulais juste ajouter davantage sur le processus de finalisation interne]

Ainsi, vous créez un objet et lorsque l'objet est collecté, la Finalizeméthode de l'objet doit être appelée. Mais il y a plus à finaliser que cette hypothèse très simple.

CONCEPTS COURTS ::

  1. Les objets n'implémentant PAS de Finalizeméthodes, la mémoire est récupérée immédiatement, sauf si bien sûr, ils ne sont plus accessibles par le
    code de l'application

  2. Les objets implémentant FinalizeMéthode, le concept / Mise en œuvre Application Roots, Finalization Queue, Freacheable Queuevient avant de pouvoir être remis en état.

  3. Tout objet est considéré comme une poubelle s'il n'est PAS accessible par le code d'application

Supposons que: les classes / objets A, B, D, G, H n'implémentent PAS la Finalizeméthode et C, E, F, I, J implémentent la Finalizeméthode.

Lorsqu'une application crée un nouvel objet, l'opérateur new alloue la mémoire à partir du tas. Si le type de l'objet contient une Finalizeméthode, un pointeur vers l'objet est placé dans la file d'attente de finalisation .

par conséquent, des pointeurs vers les objets C, E, F, I, J sont ajoutés à la file d'attente de finalisation.

La file d'attente de finalisation est une structure de données interne contrôlée par le garbage collector. Chaque entrée de la file d'attente pointe vers un objet dont la Finalizeméthode doit être appelée avant que la mémoire de l'objet puisse être récupérée. La figure ci-dessous montre un tas contenant plusieurs objets. Certains de ces objets sont accessibles depuis les racines de l' application, et certains ne le sont pas. Lorsque les objets C, E, F, I et J ont été créés, le framework .Net détecte que ces objets ont des Finalizeméthodes et des pointeurs vers ces objets sont ajoutés à la file d'attente de finalisation .

entrez la description de l'image ici

Lorsqu'un GC se produit (1ère collecte), les objets B, E, G, H, I et J sont considérés comme des déchets. Parce que A, C, D, F sont toujours accessibles par le code d'application représenté par les flèches de la boîte jaune ci-dessus.

Le garbage collector analyse la file d'attente de finalisation à la recherche de pointeurs vers ces objets. Lorsqu'un pointeur est trouvé, le pointeur est retiré de la file d'attente de finalisation et ajouté à la file d'attente accessible ("F-accessible").

La file d'attente accessible est une autre structure de données interne contrôlée par le garbage collector. Chaque pointeur de la file d'attente accessible identifie un objet prêt à recevoir l' Finalizeappel de sa méthode.

Après la collection (1ère collection), le tas géré ressemble à la figure ci-dessous. Explication donnée ci-dessous:
1.) La mémoire occupée par les objets B, G et H a été récupérée immédiatement parce que ces objets n'avaient pas de méthode de finalisation à appeler .

2.) Cependant, la mémoire occupée par les objets E, I et J n'a pas pu être récupérée car leur Finalizeméthode n'a pas encore été appelée. L'appel de la méthode Finalize se fait par une file d'attente accessible.

3.) A, C, D, F sont toujours accessibles par le code d'application représenté par les flèches de la boîte jaune ci-dessus, donc ils ne seront PAS collectés dans tous les cas

entrez la description de l'image ici

Il existe un thread d'exécution spécial dédié à l'appel des méthodes Finalize. Lorsque la file d'attente accessible est vide (ce qui est généralement le cas), ce thread se met en veille. Mais lorsque des entrées apparaissent, ce thread se réveille, supprime chaque entrée de la file d'attente et appelle la méthode Finalize de chaque objet. Le garbage collector compacte la mémoire récupérable et le thread d'exécution spécial vide la file d'attente accessible en exécutant la Finalizeméthode de chaque objet . Voici donc enfin quand votre méthode Finalize est exécutée

La prochaine fois que le garbage collector est appelé (2nd Collection), il voit que les objets finalisés sont vraiment des déchets, car les racines de l'application ne pointent pas vers elle et la file d'attente accessible ne pointe plus vers elle (c'est VIDE aussi), donc le la mémoire pour les objets (E, I, J) est simplement récupérée à partir de Heap.Voir la figure ci-dessous et la comparer avec la figure juste au-dessus

entrez la description de l'image ici

La chose importante à comprendre ici est que deux GC sont nécessaires pour récupérer la mémoire utilisée par les objets qui nécessitent une finalisation . En réalité, plus de deux collections peuvent même être nécessaires car ces objets peuvent être promus à une génération plus ancienne

REMARQUE: La file d'attente accessible est considérée comme une racine, tout comme les variables globales et statiques sont des racines. Par conséquent, si un objet se trouve dans la file d'attente accessible, l'objet est accessible et n'est pas une poubelle.

Pour terminer, rappelez-vous que le débogage de l'application est une chose, le garbage collection en est une autre et fonctionne différemment. Jusqu'à présent, vous ne pouvez pas ressentir le ramassage des ordures simplement en déboguant les applications, plus si vous souhaitez étudier la mémoire, commencez ici.

RC
la source