Finalisation et nettoyage d'objets

Il y a trois mois, j'ai commencé une mini-série d'articles sur la conception d'objets avec une discussion sur les principes de conception qui se concentrait sur une bonne initialisation au début de la vie d'un objet. Dans cet article sur les techniques de conception , je me concentrerai sur les principes de conception qui vous aident à assurer un nettoyage approprié à la fin de la vie d'un objet.

Pourquoi nettoyer?

Chaque objet d'un programme Java utilise des ressources de calcul finies. De toute évidence, tous les objets utilisent de la mémoire pour stocker leurs images sur le tas. (Ceci est vrai même pour les objets qui ne déclarent aucune variable d'instance. Chaque image d'objet doit inclure une sorte de pointeur vers les données de classe et peut également inclure d'autres informations dépendant de l'implémentation.) Mais les objets peuvent également utiliser d'autres ressources finies en plus de la mémoire. Par exemple, certains objets peuvent utiliser des ressources telles que des descripteurs de fichiers, des contextes graphiques, des sockets, etc. Lorsque vous concevez un objet, vous devez vous assurer qu'il libère finalement toutes les ressources finies qu'il utilise afin que le système ne soit pas à court de ces ressources.

Étant donné que Java est un langage garbage collection, il est facile de libérer la mémoire associée à un objet. Tout ce que vous avez à faire est de supprimer toutes les références à l'objet. Comme vous n'avez pas à vous soucier de libérer explicitement un objet, comme vous le devez dans des langages tels que C ou C ++, vous n'avez pas à vous soucier de la corruption de la mémoire en libérant accidentellement le même objet deux fois. Cependant, vous devez vous assurer que vous libérez réellement toutes les références à l'objet. Si ce n'est pas le cas, vous pouvez vous retrouver avec une fuite de mémoire, tout comme les fuites de mémoire que vous obtenez dans un programme C ++ lorsque vous oubliez de libérer explicitement des objets. Néanmoins, tant que vous libérez toutes les références à un objet, vous n'avez pas à vous soucier de «libérer» explicitement cette mémoire.

De même, vous n'avez pas à vous soucier de libérer explicitement les objets constituants référencés par les variables d'instance d'un objet dont vous n'avez plus besoin. La libération de toutes les références à l'objet inutile invalidera en fait toutes les références d'objet constitutives contenues dans les variables d'instance de cet objet. Si les références maintenant invalidées étaient les seules références restantes à ces objets constituants, les objets constituants seront également disponibles pour le garbage collection. Du gâteau, non?

Les règles du garbage collection

Bien que le garbage collection rende effectivement la gestion de la mémoire en Java beaucoup plus facile qu'en C ou C ++, vous ne pouvez pas oublier complètement la mémoire lorsque vous programmez en Java. Pour savoir quand vous devrez peut-être penser à la gestion de la mémoire en Java, vous devez en savoir un peu plus sur la façon dont le garbage collection est traité dans les spécifications Java.

Le ramassage des ordures n'est pas obligatoire

La première chose à savoir est que, quelle que soit la diligence avec laquelle vous recherchez dans la spécification de la machine virtuelle Java (spécification JVM), vous ne pourrez trouver aucune phrase qui commande, chaque JVM doit avoir un ramasse-miettes. La spécification de machine virtuelle Java donne aux concepteurs de VM une grande marge de manœuvre pour décider de la manière dont leurs implémentations géreront la mémoire, y compris pour décider d'utiliser ou non le ramasse-miettes. Ainsi, il est possible que certaines JVM (comme une JVM simple pour carte à puce) exigent que les programmes exécutés à chaque session «tiennent» dans la mémoire disponible.

Bien sûr, vous pouvez toujours manquer de mémoire, même sur un système de mémoire virtuelle. La spécification JVM n'indique pas la quantité de mémoire disponible pour une JVM. Il indique simplement que chaque fois qu'une machine virtuelle Java ne manque de mémoire, il faut jeter un OutOfMemoryError.

Néanmoins, pour donner aux applications Java les meilleures chances de s'exécuter sans manquer de mémoire, la plupart des JVM utilisent un garbage collector. Le garbage collector récupère la mémoire occupée par les objets non référencés sur le tas, de sorte que la mémoire puisse être réutilisée par de nouveaux objets, et dégrafe généralement le tas pendant l'exécution du programme.

L'algorithme de récupération de place n'est pas défini

Une autre commande que vous ne trouverez pas dans la spécification JVM est que toutes les JVM qui utilisent le garbage collection doivent utiliser l'algorithme XXX. Les concepteurs de chaque JVM décident du fonctionnement du garbage collection dans leurs implémentations. L'algorithme de récupération de place est un domaine dans lequel les fournisseurs de JVM peuvent s'efforcer d'améliorer leur implémentation par rapport à celle de la concurrence. Ceci est important pour vous en tant que programmeur Java pour la raison suivante:

Comme vous ne savez généralement pas comment le garbage collection sera effectué dans une JVM, vous ne savez pas quand un objet particulier sera garbage collection.

Et alors? vous pourriez demander. La raison pour laquelle vous pouvez vous soucier lorsqu'un objet est garbage collection a à voir avec les finaliseurs. (Un finaliseur est défini comme une méthode d'instance Java standard nommée finalize()qui renvoie void et ne prend aucun argument.) Les spécifications Java font la promesse suivante concernant les finaliseurs:

Avant de récupérer la mémoire occupée par un objet qui a un finaliseur, le garbage collector appellera le finaliseur de cet objet.

Étant donné que vous ne savez pas quand les objets seront récupérés, mais que vous savez que les objets finalisables seront finalisés car ils sont récupérés, vous pouvez effectuer la grande déduction suivante:

Vous ne savez pas quand les objets seront finalisés.

Vous devez imprimer ce fait important dans votre cerveau et lui permettre à jamais d'informer vos conceptions d'objets Java.

Finaliseurs à éviter

La règle d'or centrale concernant les finaliseurs est la suivante:

Ne concevez pas vos programmes Java de telle sorte que l'exactitude dépende d'une finalisation «en temps opportun».

En d'autres termes, n'écrivez pas de programmes qui se casseront si certains objets ne sont pas finalisés à certains moments de la vie de l'exécution du programme. Si vous écrivez un tel programme, il peut fonctionner sur certaines implémentations de la JVM mais échouer sur d'autres.

Ne comptez pas sur les finaliseurs pour libérer des ressources autres que la mémoire

Un exemple d'objet qui enfreint cette règle est celui qui ouvre un fichier dans son constructeur et ferme le fichier dans sa finalize()méthode. Bien que cette conception semble soignée, ordonnée et symétrique, elle crée potentiellement un bug insidieux. Un programme Java n'aura généralement qu'un nombre fini de descripteurs de fichiers à sa disposition. Lorsque toutes ces poignées sont utilisées, le programme ne pourra plus ouvrir de fichiers.

Un programme Java qui utilise un tel objet (celui qui ouvre un fichier dans son constructeur et le ferme dans son finaliseur) peut fonctionner correctement sur certaines implémentations JVM. Sur de telles implémentations, la finalisation se produirait assez souvent pour garder un nombre suffisant de descripteurs de fichiers disponibles à tout moment. Mais le même programme peut échouer sur une autre machine virtuelle Java dont le garbage collector ne se termine pas assez souvent pour empêcher le programme de manquer de descripteurs de fichiers. Ou, ce qui est encore plus insidieux, le programme peut fonctionner sur toutes les implémentations JVM maintenant, mais échouer dans une situation critique dans quelques années (et cycles de publication) plus tard.

Autres règles empiriques du finaliseur

Deux autres décisions laissées aux concepteurs de JVM sont la sélection du thread (ou des threads) qui exécutera les finaliseurs et l'ordre dans lequel les finaliseurs seront exécutés. Les finaliseurs peuvent être exécutés dans n'importe quel ordre - séquentiellement par un seul thread ou simultanément par plusieurs threads. Si votre programme dépend d'une manière ou d'une autre de l'exactitude des finaliseurs exécutés dans un ordre particulier, ou par un thread particulier, il peut fonctionner sur certaines implémentations JVM mais échouer sur d'autres.

Vous devez également garder à l'esprit que Java considère qu'un objet est finalisé, que la finalize()méthode retourne normalement ou se termine brusquement en lançant une exception. Les garbage collector ignorent toutes les exceptions levées par les finaliseurs et n'informent en aucun cas le reste de l'application qu'une exception a été levée. Si vous devez vous assurer qu'un finaliseur particulier accomplit pleinement une certaine mission, vous devez écrire ce finaliseur afin qu'il gère toutes les exceptions pouvant survenir avant que le finaliseur n'ait terminé sa mission.

Une autre règle de base concernant les finaliseurs concerne les objets laissés sur le tas à la fin de la durée de vie de l'application. Par défaut, le garbage collector n'exécutera pas les finaliseurs des objets laissés sur le tas lorsque l'application se ferme. Pour modifier cette valeur par défaut, vous devez appeler la runFinalizersOnExit()méthode de la classe Runtimeou System, en la passant truecomme paramètre unique. Si votre programme contient des objets dont les finaliseurs doivent absolument être appelés avant la fermeture du programme, assurez-vous de les appeler runFinalizersOnExit()quelque part dans votre programme.

Alors, à quoi servent les finaliseurs?

À présent, vous avez peut-être l'impression que vous n'avez pas beaucoup d'utilité pour les finaliseurs. Bien qu'il soit probable que la plupart des classes que vous concevez n'incluent pas de finaliseur, il existe certaines raisons d'utiliser des finaliseurs.

Une application raisonnable, bien que rare, pour un finaliseur est de libérer de la mémoire allouée par des méthodes natives. Si un objet invoque une méthode native qui alloue de la mémoire (peut-être une fonction C qui appelle malloc()), le finaliseur de cet objet pourrait invoquer une méthode native qui libère cette mémoire (appels free()). Dans cette situation, vous utiliseriez le finaliseur pour libérer de la mémoire allouée au nom d'un objet - mémoire qui ne sera pas automatiquement récupérée par le garbage collector.

Une autre utilisation, plus courante, des finaliseurs consiste à fournir un mécanisme de secours pour libérer des ressources finies non mémoire telles que des descripteurs de fichiers ou des sockets. Comme mentionné précédemment, vous ne devez pas vous fier aux finaliseurs pour libérer des ressources non mémoire finies. Au lieu de cela, vous devez fournir une méthode qui libérera la ressource. Mais vous pouvez également inclure un finaliseur qui vérifie que la ressource a déjà été libérée, et si ce n'est pas le cas, cela continue et la libère. Un tel finaliseur protège contre (et espérons-le n'encouragera pas) une utilisation bâclée de votre classe. Si un programmeur client oublie d'appeler la méthode que vous avez fournie pour libérer la ressource, le finaliseur libérera la ressource si l'objet est un jour garbage collection. La finalize()méthode duLogFileManager class, présentée plus loin dans cet article, est un exemple de ce type de finaliseur.

Évitez les abus de finaliseur

L'existence de la finalisation produit des complications intéressantes pour les JVM et des possibilités intéressantes pour les programmeurs Java. Ce que la finalisation accorde aux programmeurs, c'est le pouvoir sur la vie et la mort des objets. En bref, il est possible et tout à fait légal en Java de ressusciter des objets dans les finaliseurs - de les ramener à la vie en les rendant à nouveau référencés. (Un finaliseur pourrait y parvenir en ajoutant une référence à l’objet en cours de finalisation à une liste chaînée statique qui est toujours «active».) Bien qu’un tel pouvoir puisse être tentant d’exercer parce qu’il vous fait vous sentir important, la règle de base c'est résister à la tentation d'utiliser ce pouvoir. En général, ressusciter des objets dans les finaliseurs constitue un abus de finaliseur.

La principale justification de cette règle est que tout programme qui utilise la résurrection peut être repensé en un programme plus facile à comprendre qui n'utilise pas la résurrection. Une preuve formelle de ce théorème est laissée comme exercice au lecteur (j'ai toujours voulu le dire), mais dans un esprit informel, considérez que la résurrection d'objet sera aussi aléatoire et imprévisible que la finalisation d'objet. En tant que tel, une conception qui utilise la résurrection sera difficile à comprendre par le prochain programmeur de maintenance qui se produira - qui peut ne pas comprendre pleinement les particularités de la collecte des ordures en Java.

Si vous pensez que vous devez simplement redonner vie à un objet, envisagez de cloner une nouvelle copie de l'objet au lieu de ressusciter le même ancien objet. Le raisonnement derrière ce conseil est que les garbage collector de la JVM n'appellent la finalize()méthode d'un objet qu'une seule fois. Si cet objet est ressuscité et devient disponible pour le garbage collection une deuxième fois, la finalize()méthode de l'objet ne sera pas appelée à nouveau.