I. Introduction▲
Cet article s'inscrit dans la série dédiée à la découverte de la plateforme .NET, et vient compléter le précédent concernant le ramasse-miettes. Initialement, je n'en avais pas prévu un 2e sur le sujet, mais les questions posées à la suite de la publication de cette première partie m'ont incité à aller un peu plus loin.
II. Retour sur les BLOB▲
II-A. Taille d'un objet▲
Dans l'article précédent, j'ai mentionné l'existence des BLOB (Binary Large OBject), ces objets binaires volumineux dont la taille dépasse une limite qui est actuellement de 85 000 octets.
La question était de savoir quels étaient les objets qui rentraient dans cette catégorie, et donc, comment était déterminée la taille d'un objet.
Pour qu'un objet soit considéré comme un BLOB, il faut que ce soit sa taille propre, c'est-à-dire le nombre d'octets contigus réservés en mémoire, qui dépasse le seuil. Ainsi, un objet qui référence 10 tableaux de 20 000 octets chacun ne sera pas considéré comme un BLOB, car l'objet en lui-même n'aura qu'une taille très limitée (10 références, avec 4 à 8 octets par référence en fonction de l'architecture 32/64 bits).
Un tableau, pour dépasser cette limite, doit dépasser la limite de 85 000 octets. Un tableau d'objets n'a que peu de chance de dépasser cette limite, dans la mesure où ce ne sont pas les objets eux-mêmes qui sont stockés au sein du tableau, mais des références vers ces objets.
Par contre, il y a des situations où cela peut très vite se produire, notamment :
- chargement d'un fichier en mémoire ;
- gestion d'un cache ;
- des tableaux de structures.
Hormis ces cas-là, il faut dépasser le seuil théorique de 85 000/(taille référence) d'objets pour qu'un tableau soit considéré comme un BLOB, soit 21 250 pour les architectures 32 bits et 10 625 pour les architectures 64 bits. Ce qui n'arrive qu'assez rarement, il faut l'avouer.
Avec les structures, il est possible de dépasser cette limite rapidement. En effet, dans la mesure où une structure est un type valeur, l'objet est directement stocké au sein du tableau. Si la structure est large, cette limite peut être dépassée. Par exemple, pour une structure qui aurait une taille de 1000 octets, il suffirait d'un tableau de 85 éléments pour qu'il soit considéré comme un BLOB.
II-B. Le tas des BLOB▲
Le tas dédié aux BLOB est géré de la même manière que le tas managé « classique », à deux différences près :
- les objets ne sont pas déplacés, car le déplacement de gros objets est coûteux ;
- tous les objets sont de génération 2, c'est-à-dire qu'ils ne seront collectés que lorsque le ramasse-miettes fera une collecte complète. Une collecte des générations 0 et 1 n'aura aucune incidence sur ce tas.
Il est à noter que depuis le framework 4.5.1, il est possible de préciser que l'on souhaite regrouper les objets de grande taille. Pour cela, il faut initialiser la propriété GCSettings.LargeObjectHeapCompactionMode à CompactOnce.
Ainsi, lors de la prochaine collecte, les objets de grande taille seront aussi regroupés, défragmentant le tas des objets volumineux. Il faut noter qu'après la collecte, cette propriété retrouve sa valeur Default. Il faudra donc réinitialiser explicitement à CompactOnce si on souhaite à nouveau défragmenter le tas des objets de grande taille.
III. Choix de la génération lors d'une collection▲
III-A. Principe▲
Une excellente question qui m'a été posée, et qui n'avait pas été abordée, était de savoir comment était choisi le nombre de générations à collecter.
Chaque génération dispose d'une taille seuil, au-delà de laquelle la génération sera collectée. La procédure est la suivante :
- le ramasse-miettes se déclenche ;
- si la taille de la génération 2 est supérieure à sa taille seuil, alors une collecte de génération 2 est lancée ;
- si la taille de la génération 2 est inférieure à sa taille seuil, alors le ramasse-miettes examine la taille de la génération 1 et la compare à sa taille seuil. Si la taille de la génération est supérieure à sa taille seuil, alors le ramasse-miettes lance une collecte de la génération 1 ;
- si les tailles des générations 1 et 2 sont inférieures à leur taille seuil respective, alors une collection de la génération 0 est lancée.
III-B. Choix des seuils▲
Le choix des seuils est laissé à la libre appréciation de l'implémentation du ramasse-miettes, et nous n'avons pas de possibilité d'agir directement sur ceux-ci. Impossible donc « d'imposer » les seuils.
Il est intéressant de noter que ces seuils ne sont pas figés. Ils évoluent au cours de la vie d'une application.
En effet, une fois la collecte terminée, le ramasse-miettes peut éventuellement ajuster les tailles seuil afin de s'adapter au comportement de l'application.
Cette modification des tailles seuil peut être aussi bien à la hausse qu'à la baisse. Par exemple, si le ramasse-miettes détecte qu'il y a énormément de petits objets à la durée de vie extrêmement courte qui sont créés, il peut décider de réduire le seuil de la génération 0, afin d'effectuer un nettoyage plus souvent, mais qui sera plus rapide (car moins d'objets à examiner à chaque fois).
III-C. Encore plus loin dans l'optimisation▲
Le choix de la génération lors d'une collecte permet d'accélérer le ramasse-miettes puisqu'il n'examine alors qu'une partie des objets au lieu de la totalité.
Néanmoins, même si une collecte de génération 0 ne collecte pas les objets des générations 1 et 2, il est nécessaire de parcourir l'ensemble des objets, quelle que soit leur génération, afin de déterminer si un objet est encore référencé ou non. Cela limite donc grandement l'intérêt de la collecte générationnelle.
Mais c'était sans compter sur une optimisation du ramasse-miettes lui permettant de ne pas parcourir l'intégralité des objets afin de construire le graphe des objets atteignables.
Le principe est que si un objet d'une génération (par exemple, 0) fait référence à un objet d'une génération supérieure (par exemple, 1), il n'est pas utile de parcourir les références internes de cet objet s'il n'a pas été modifié depuis la dernière collecte.
Le ramasse-miettes dispose donc d'un tel mécanisme, permettant de ne parcourir que les objets nécessaires, et non l'intégralité des objets.
IV. Finalizer vs destructeur▲
Dans le précédent article, il y a une section dédiée aux objets avec destructeur, qui nécessite de petites précisions.
IV-A. Finalizer▲
Tout d'abord, une précision terminologique.
Le précédent article fait référence à la notion de destructeur. Il faut savoir que l'environnement .NET en lui-même n'a pas cette notion en tant que telle, mais dispose de la notion de Finalizer (qui est, à quelques choses près, la même chose).
Derrière cette notion se cache une méthode, la méthode Finalize, définie au sein de la classe Object. Autrement dit, tous les objets en .NET disposent de cette méthode. Cette méthode est protégée (donc uniquement appelable depuis la classe elle-même ou ses descendants) et virtuelle (donc peut être réimplémentée par ses descendants).
On retrouve cette notion dans d'autres langages managés comme VB.NET. Dans l'article précédent, je me suis laissé influencé, plus ou moins involontairement, par mon utilisation du C# où cette notion est masquée en tant que tel au développeur pour n'apparaître que sous la forme d'un destructeur.
Mais d'un point de vue fonctionnel, Finalizer et destructeur représentent bel et bien la même chose : nettoyer proprement un objet lorsqu'il n'est plus nécessaire.
IV-B. Différence entre Finalizer et destructeur▲
Même si Finalizer et destructeur sont fonctionnellement similaires, il y a malgré tout de subtiles différences entre les deux.
IV-B-1. Le destructeur▲
En effet, en C#, la notion de Finalizer est totalement masquée : il n'est pas possible de redéfinir la méthode Finalize, ni même de l'appeler. La seule possibilité pour le faire est de définir un destructeur au sein d'une classe, car lorsque le ramasse-miettes appelle le destructeur d'une classe, il appelle en réalité la méthode Finalize.
C'est le compilateur C# qui « traduit » le destructeur d'une classe via une redéfinition de sa méthode Finalize.
La notion de destructeur est une notion que l'on retrouve en programmation orientée objet dans des langages sans ramasse-miettes comme le C++. Le choix a été fait par les concepteurs du langage C# de venir coller au plus près possible de cette notion :
- lorsqu'on instancie une classe, toute la hiérarchie des constructeurs est appelée, de la classe de base (Object) à la classe finale ;
- il en est de même pour le destructeur, mais dans le sens inverse : de la classe finale, à la classe de base.
IV-B-2. Le finalizer▲
En VB.Net, l'approche est différente. Il n'y a pas de notion de destructeur, et la seule manière de procéder est donc de redéfinir cette méthode. L'approche n'est donc pas une approche orientée objet, mais une approche plus polymorphique : on redéfinit une méthode, et charge au développeur d'appeler ou non la méthode Finalize de la classe parente.
IV-B-3. La différence entre les deux approches▲
En règle générale, lorsqu'on redéfinit une méthode, il faut effectivement se poser la question de savoir si on doit appeler la méthode de la classe mère ou non. Il n'y a pas de réponse toute faite dans la mesure où cela dépend de la situation.
Par contre, ici, on ne devrait pas avoir le choix. Une classe redéfinissant la méthode Finalize devrait toujours appeler la méthode de la classe mère. En effet, même avec une connaissance profonde de la manière dont est implémentée une classe, une classe fille ne peut pas accéder à tous les champs de la classe mère : ceux marqués comme privés. Seule la classe mère peut donc correctement libérer ses propres ressources.
Il est donc de la responsabilité d'une classe de libérer correctement toutes les ressources qu'elle possède, c'est-à-dire :
- les champs privés ;
- les champs non privés, mais introduits par la classe. Par exemple, un attribut protégé hérité de la classe mère ne doit pas être géré dans la classe fille. Mais un attribut protégé introduit dans la classe fille doit être géré par cette même classe fille.
Du point de vue strictement personnel, je préfère l'approche du C#, car sur ce point précis, elle ne laisse pas le choix au programmeur pour un détail d'implémentation où il ne devrait de toutes les façons, pas avoir le choix.
IV-C. Toutes les classes sont finalisables▲
La classe Object définit elle-même un finalizer. Dans la mesure où toutes les classes dérivent directement ou indirectement de la classe Object, toutes les classes ont une méthode Finalize, et donc sont finalisables.
C'est vrai. Mais le CLR est au courant de cet état de fait et ignore la méthode Finalize définie par la classe Object.
Ainsi, tant qu'une classe ne redéfinit pas cette méthode, elle sera considérée par le ramasse-miettes comme n'ayant pas de méthode Finalize (ou de destructeur).
IV-D. Références▲
Pour les personnes désirant approfondir le sujet, vous trouverez ci-dessous les deux références principales ayant servi à la rédaction de l'article précédent sur le ramasse-miettes et de ce complément :
- CLR via C#, 4e édition de Jeffrey Richter ;
- la section Garbage Collection de la MSDN.
IV-E. Remerciements▲
Je remercie f-leb pour sa relecture orthographique.