IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)
Penser en Java 2nde édition - Sommaire |  Préface |  Avant-propos | Chapitre : 1  2  3  4  5  6  7  8  9  10  11  12  13  14  15 |  Annexe : A B C D  | Tables des matières - Thinking in Java

  Chapitre 4 - Initialisation & nettoyage

pages : 1 2 3 4 5 6 

Le nettoyage est impératif

Pour nettoyer un objet, son utilisateur doit appeler une méthode de nettoyage au moment où celui-ci est nécessaire. Cela semble assez simple, mais se heurte au concept de destructeur de C++. En C++, tous les objets sont, ou plutôt devraient être, détruits. Si l'objet C++ est créé localement (c'est à dire sur la pile, ce qui n'est pas possible en Java), alors la destruction se produit à la fermeture de la portée dans laquelle l'objet a été créé. Si l'objet a été créé par new (comme en Java) le destructeur est appelé quand le programmeur appelle l'opérateur C++ delete (cet opérateur n'existe pas en Java). Si le programmeur C++ oublie d'appeler delete, le destructeur n'est jamais appelé et l'on obtient une fuite mémoire. De plus les membres de l'objet ne sont jamais nettoyé non plus. Ce genre de bogue peut être très difficile à repérer.

Contrairement à C++, Java ne permet pas de créer des objets locaux, new doit toujours être utilisé. Cependant Java n'a pas de «delete» pour libérer l'objet car le ramasse-miettes se charge automatiquement de récupérer la mémoire. Donc d'un point de vue simplistique, on pourrait dire qu'à cause du ramasse-miettes, Java n'a pas de destructeur. Cependant à mesure que la lecture de ce livre progresse, on s'aperçoit que la présence d'un ramasse-miettes ne change ni le besoin ni l'utilité des destructeurs (de plus, finalize( ) ne devrait jamais être appelé directement, ce n'est donc pas une bonne solution pour ce problème). Si l'on a besoin d'effectuer des opérations de nettoyage autre que libérer la mémoire, il est toujours nécessaire d'appeler explicitement la méthode correspondante en Java, ce qui correspondra à un destructeur C++ sans être aussi pratique.

Une des utilisations possibles de finalize( ) est l'observation du ramasse-miettes. L'exemple suivant montre ce qui se passe et résume les descriptions précédentes du ramasse-miettes :

//: c04:Garbage.java
// Démonstration du ramasse-miettes
// et de la finalisation

class Chair {
  static boolean gcrun = false;
  static boolean f = false;
  static int created = 0;
  static int finalized = 0;
  int i;
  Chair() {
    i = ++created;
    if(created == 47)
      System.out.println("Created 47");
  }
  public void finalize() {
    if(!gcrun) {
      // Premier appel de finalize() :
      gcrun = true;
      System.out.println(
        "Beginning to finalize after " +
        created + " Chairs have been created");
    }
    if(i == 47) {
      System.out.println(
        "Finalizing Chair #47, " +
        "Setting flag to stop Chair creation");
      f = true;
    }
    finalized++;
    if(finalized >= created)
      System.out.println(
        "All " + finalized + " finalized");
  }
}

public class Garbage {
  public static void main(String[] args) {
    // Tant que le flag n'a pas été levé,
    // construire des objets Chair et String:
    while(!Chair.f) {
      new Chair();
      new String("To take up space");
    }
    System.out.println(
      "After all Chairs have been created:\n" +
      "total created = " + Chair.created +
      ", total finalized = " + Chair.finalized);
    // Arguments optionnels pour forcer
    // la finalisation et l'exécution du ramasse-miettes :
    if(args.length > 0) {
      if(args[0].equals("gc") ||
         args[0].equals("all")) {
        System.out.println("gc():");
        System.gc();
      }
      if(args[0].equals("finalize") ||
         args[0].equals("all")) {
        System.out.println("runFinalization():");
        System.runFinalization();
      }
    }
    System.out.println("bye!");
  }
} ///:~

Le programme ci-dessus crée un grand nombre d'objets Chair et, à un certain point après que le ramasse-miettes ait commencé à s'exécuter, le programme arrête de créer des Chairs. Comme le ramasse-miettes peut s'exécuter n'importe quand, on ne sait pas exactement à quel moment il se lance, il existe donc un flag appelé gcrun qui indique si le ramasse-miettes a commencé son exécution. Un deuxième flag f est le moyen pour Chair de prévenir la boucle main( ) qu'elle devrait arrêter de fabriquer des objets. On lève ces deux flags dans finalize( ), qui est appelé pendant l'exécution du ramasse-miettes.

Deux autres variables statiques, created and finalized, enregistre le nombre d'objets Chair créés par rapport au nombre réclamé par le ramasse-miettes. Enfin, chaque objet Chair contient sa propre version (non statique) de l'int i pour savoir quel est son numéro. Quand l'objet Chair numéro 47 est réclamé, le flag est mis à true pour arrêter la création des objets Chair.

Tout ceci se passe dans le main( ), dans la boucle

while(!Chair.f) {
      new Chair();
      new String("To take up space");
    }

On peut se demander comment cette boucle va se terminer puisque rien dans la boucle ne change la valeur de Chair.f. Cependant, finalize( ) le fera au moment de la réclamation du numéro 47.

La création d'un objet String à chaque itération représente simplement de l'espace mémoire supplémentaire pour inciter le ramasse-miettes à s'exécuter, ce qu'il fera dès qu'il se sentira inquiet pour le montant de mémoire disponible.

A l'exécution du programme, l'utilisateur fournit une option sur la ligne de commande : «gc,» «finalize,» ou «all». Le paramètre «gc» permet l'appel de la méthode System.gc( ) (pour forcer l'exécution du ramasse-miettes). «finalize» permet d'appeler System.runFinalization( ) ce qui, en théorie, fait que tout objet non finalisé soit finalisé. Enfin, «all» exécute les deux méthodes.

Le comportement de ce programme et celui de la version de la première édition de cet ouvrage montrent que la question du ramasse-miettes et de la finalisation a évolué et qu'une grosse part de cette évolution s'est passée en coulisse. En fait, il est possible que le comportement du programme soit tout à fait différent lorsque vous lirez ces lignes.

Si System.gc( ) est appelé, alors la finalisation concerne tous les objets. Ce n'était pas forcément le cas avec les implémentations précédentes du JDK bien que la documentation dise le contraire. De plus, il semble qu'appeler System.runFinalization( ) n'ait aucun effet.

Cependant, on voit que toutes les méthodes de finalisation sont exécutées seulement dans le cas où System.gc( ) est appelé après que tous les objets aient été créés et mis à l'écart. Si System.gc( ) n'est pas appelé, seulement certains objets seront finalisés. En Java 1.1, la méthode System.runFinalizersOnExit( ) fut introduite pour que les programmes puissent exécuter toutes les méthodes de finalisation lorsqu'ils se terminent, mais la conception était boguée et la méthode a été classée deprecated. C'est un indice supplémentaire qui montre que les concepteurs de Java ont eu de nombreux démêlés avec le problème du ramasse-miettes et de la finalisation. Il est à espérer que ces questions ont été réglées dans Java 2.

Le programme ci-dessus montre que les méthodes de finalisation sont toujours exécutées mais seulement si le programmeur force lui-même l'appel. Si on ne force pas l'appel de System.gc( ), le résultat ressemblera à ceci :

Created 47
Beginning to finalize after 3486 Chairs have been created
Finalizing Chair #47, Setting flag to stop Chair creation
After all Chairs have been created:
total created = 3881, total finalized = 2684
bye!

Toutes les méthodes de finalisation ne sont donc pas appelées à la fin du programme. Ce n'est que quand System.gc( ) est appelé que tous les objets qui ne sont plus utilisés seront finalisés et détruits.

Il est important de se souvenir que ni le ramasse-miettes, ni la finalisation ne sont garantis. Si la machine virtuelle Java (JVM) ne risque pas de manquer de mémoire, elle ne perdra (légitimement) pas de temps à en récupérer grâce au ramasse-miettes.

La «death condition»

En général, on ne peut pas compter sur un appel à finalize( ), et il est nécessaire de créer des fonctions spéciales de nettoyage et de les appeler explicitement. Il semblerait donc que finalize( ) ne soit utile que pour effectuer des tâches de nettoyage mémoire très spécifiques dont la plupart des programmeurs n'aura jamais besoin. Cependant, il existe une très intéressante utilisation de finalize( ) qui ne nécessite pas que son appel soit garanti. Il s'agit de la vérification de la death condition [29] d'un objet (état d'un objet à sa destruction).

Au moment où un objet n'est plus intéressant, c'est à dire lorsqu'il est prêt à être réclamé par le ramasse-miettes, cet objet doit être dans un état où sa mémoire peut être libérée sans problème. Par exemple, si l'objet représente un fichier ouvert, celui-ci doit être fermé par le programmeur avant que la mémoire prise par l'objet ne soit réclamée. Si certaines parties de cet objet n'ont pas été nettoyées comme il se doit, il s'agit d'un bogue du programme qui peut être très difficile à localiser. L'intérêt de finalize( ) est qu'il est possible de l'utiliser pour découvrir cet état de l'objet, même si cette méthode n'est pas toujours appelée. Si une des finalisations trouve le bogue, alors le problème est découvert et c'est ce qui compte vraiment après tout.

Voici un petit exemple pour montrer comment on peut l'utiliser :

//: c04:DeathCondition.java
// Comment utiliser finalize() pour détecter les objets qui
// n'ont pas été nettoyés correctement.

class Book {
  boolean checkedOut = false;
  Book(boolean checkOut) {
    checkedOut = checkOut;
  }
  void checkIn() {
    checkedOut = false;
  }
  public void finalize() {
    if(checkedOut)
      System.out.println("Error: checked out");
  }
}

public class DeathCondition {
  public static void main(String[] args) {
    Book novel = new Book(true);
    // Nettoyage correct :
    novel.checkIn();
    // Perd la référence et oublie le nettoyage :
    new Book(true);
    // Force l'exécution du ramasse-miettes et de la finalisation :
    System.gc();
  }
} ///:~

Ici, la «death condition» est le fait que tous les objets de type Book doivent être «rendus» (checked in) avant d'être récupéré par le ramasse-miettes, mais dans la fonction main( ) une erreur de programmation fait qu'un de ces livres n'est pas rendu. Sans finalize( ) pour vérifier la «death condition», cela pourrait s'avérer un bogue difficile à trouver.

Il est important de noter l'utilisation de System.gc( ) pour forcer l'exécution de la finalisation (en fait, il est utile de le faire pendant le développement du programme pour accélérer le débogage). Cependant même si System.gc() n'est pas appelé, il est très probable que le livre (Book) perdu soit découvert par plusieurs exécutions successives du programme (en supposant que suffisamment de mémoire soit alloué pour que le ramasse-miettes se déclenche).

Comment fonctionne un ramasse-miettes ?

Les utilisateurs de langages où l'allocation d'objets sur le tas coûte cher peuvent supposer que la façon qu'a Java de tout allouer sur le tas (à l'exception des types de base) coûte également cher. Cependant, il se trouve que l'utilisation d'un ramasse-miettes peut accélérer de manière importante la création d'objets. Ceci peut sembler un peu bizarre à première vue : la réclamation d'objets aurait un effet sur la création d'objets. Mais c'est comme ça que certaines JVMs fonctionnent et cela veut dire, qu'en Java, l'allocation d'objets sur le tas peut être presque aussi rapide que l'allocation sur la pile dans d'autres langages.

Un exemple serait de considérer le tas en C++ comme une pelouse où chaque objet prend et délimite son morceau de gazon. Cet espace peut être abandonné un peu plus tard et doit être réutilisé. Avec certaines JVMs, le tas de Java est assez différent ; il ressemble plus à une chaîne de montage qui avancerait à chaque fois qu'un objet est alloué. Ce qui fait que l'allocation est remarquablement rapide. Le «pointeur du tas» progresse simplement dans l'espace vide, ce qui correspond donc à l'allocation sur la pile en C++ (il y a bien sûr une petite pénalité supplémentaire pour le fonctionnement interne mais ce n'est pas comparable à la recherche de mémoire libre).

On peut remarquer que le tas n'est en fait pas vraiment une chaîne de montage, et s'il est traité de cette manière, la mémoire finira par avoir un taux de «paging» (utiliser toute la mémoire virtuelle incluant la partie sur disque dur) important (ce qui représente un gros problème de performance) et finira par manquer de mémoire. Le ramasse-miettes apporte la solution en s'interposant et, alors qu'il collecte les miettes (les objets inutilisables), il compacte tous les objets du tas. Ceci représente l'action de déplacer le «pointeur du tas» un peu plus vers le début et donc plus loin du «page fault» (interruption pour demander au système d'exploitation des pages de mémoire supplémentaire situées dans la partie de la mémoire virtuelle qui se trouve sur disque dur). Le ramasse-miettes réarrange tout pour permettre l'utilisation de ce modèle d'allocation très rapide et utilisant une sorte de «tas infini».

Pour comprendre comment tout cela fonctionne, il serait bon de donner maintenant une meilleure description de la façon dont un ramasse-miettes fonctionne. Nous utiliserons l'acronyme GC (en anglais, un ramasse-miette est appelé Garbage Collector) dans les paragraphes suivants. Une technique de GC relativement simple mais lente est le compteur de référence. L'idée est que chaque objet contient un compteur de référence et à chaque fois qu'une nouvelle référence sur un objet est créée le compteur est incrémenté. A chaque fois qu'une référence est hors de portée ou que la valeur null lui est assignée, le compteur de références est décrémenté. Par conséquent, la gestion des compteurs de références représente un coût faible mais constant tout au long du programme. Le ramasse-miettes se déplace à travers toute la liste d'objets et quand il en trouve un avec un compteur à zéro, il libère la mémoire. L'inconvénient principal est que si des objets se référencent de façon circulaire, ils ne peuvent jamais avoir un compteur à zéro tout en étant inaccessible. Pour localiser ces objets qui se référencent mutuellement, le ramasse-miettes doit faire un important travail supplémentaire. Les compteurs de références sont généralement utilisés pour expliquer les ramasses-miettes mais ils ne semblent pas être utilisés dans les implémentations de la JVM.

D'autres techniques, plus performantes, n'utilisent pas de compteur de références. Elles sont plutôt basées sur l'idée que l'on est capable de remonter la chaîne de références de tout objet «non-mort» (i.e encore en utilisation) jusqu'à une référence vivant sur la pile ou dans la zone statique. Cette chaîne peut très bien passer par plusieurs niveaux d'objets. Par conséquent, si l'on part de la pile et de la zone statique et que l'on trace toutes les références, on trouvera tous les objets encore en utilisation. Pour chaque référence que l'on trouve, il faut aller jusqu'à l'objet référencé et ensuite suivre toutes les références contenues dans cet objet, aller jusqu'aux objets référencés, etc. jusqu'à ce que l'on ait visité tous les objets que l'on peut atteindre depuis la référence sur la pile ou dans la zone statique. Chaque objet visité doit être encore vivant. Notez qu'il n'y a aucun problème avec les groupes qui s'auto-référencent : ils ne sont tout simplement pas trouvés et sont donc automatiquement morts.

Avec cette approche, la JVM utilise un ramasse-miettes adaptatif. Le sort des objets vivants trouvés dépend de la variante du ramasse-miettes utilisée à ce moment-là. Une de ces variantes est le stop-and-copy. L'idée est d'arrêter le programme dans un premier temps (ce n'est pas un ramasse-miettes qui s'exécute en arrière-plan). Puis, chaque objet vivant que l'on trouve est copié d'un tas à un autre, délaissant les objets morts. De plus, au moment où les objets sont copiés, ils sont rassemblés les uns à côté des autres, compactant de ce fait le nouveau tas (et permettant d'allouer de la mémoire en la récupérant à l'extrémité du tas comme cela a été expliqué auparavant).

Bien entendu, quand un objet est déplacé d'un endroit à un autre, toutes les références qui pointent (i.e. qui référencent) l'objet doivent être mis à jour. La référence qui part du tas ou de la zone statique vers l'objet peut être modifiée sur le champ, mais il y a d'autres références pointant sur cet objet qui seront trouvées «sur le chemin». Elles seront corrigées dès qu'elles seront trouvées (on peut s'imaginer une table associant les anciennes adresses aux nouvelles).

Il existe deux problèmes qui rendent ces «ramasse-miettes par copie» inefficaces. Le premier est l'utilisation de deux tas et le déplacement des objets d'un tas à l'autre, utilisant ainsi deux fois plus de mémoire que nécessaire. Certaines JVMs s'en sortent en allouant la mémoire par morceau et en copiant simplement les objets d'un morceau à un autre.

Le deuxième problème est la copie. Une fois que le programme atteint un état stable, il se peut qu'il ne génère pratiquement plus de miettes (i.e. d'objets morts). Malgré ça, le ramasse-miettes par copie va quand même copier toute la mémoire d'une zone à une autre, ce qui est du gaspillage put et simple. Pour éviter cela, certaines JVMs détectent que peu d'objets meurent et choisissent alors une autre technique (c'est la partie d'«adaptation»). Cette autre technique est appelée mark and sweep (NDT : litéralement marque et balaye), et c'est ce que les versions précédentes de la JVM de Sun utilisaient en permanence. En général, le «mark and sweep» est assez lent, mais quand on sait que l'on génère peu ou pas de miettes, la technique est rapide.

La technique de «mark and sweep» suit la même logique de partir de la pile et de la zone de mémoire statique et de suivre toutes les références pour trouver les objets encore en utilisation. Cependant, à chaque fois qu'un objet vivant est trouvé, il est marqué avec un flag, mais rien n'est encore collecté. C'est seulement lorsque la phase de «mark» est terminée que le «sweep» commence. Pendant ce balayage, les objets morts sont libérés. Aucune copie n'est effectuée, donc si le ramasse-miettes décide de compacter la mémoire, il le fait en réarrangeant les objets.

Le «stop-and-copy» correspond à l'idée que ce type de ramasse-miettes ne s'exécute pas en tâche de fond, le programme est en fait arrêté pendant l'exécution du ramasse-miettes. La littérature de Sun mentionne assez souvent le ramasse-miettes comme une tâche de fond de basse priorité, mais il se trouve que le ramasse-miettes n'a pas été implémenté de cette manière, tout au moins dans les premières versions de la JVM de Sun. Le ramasse-miettes était plutôt exécuté quand il restait peu de mémoire libre. De plus, le «mark-and-sweep» nécessite l'arrêt du programme.

Comme il a été dit précédemment, la JVM décrite ici alloue la mémoire par blocs. Si un gros objet est alloué, un bloc complet lui est réservé. Le «stop-and-copy» strictement appliqué nécessite la copie de chaque objet vivant du tas d'origine vers un nouveau tas avant de pouvoir libérer le vieux tas, ce qui se traduit par la manipulation de beaucoup de mémoire. Avec des blocs, le ramasse-miettes peut simplement utiliser les blocs vides (et/ou contenant uniquement des objets morts) pour y copier les objets. Chaque bloc possède un compteur de génération pour savoir s'il est « mort » (vide) ou non. Dans le cas normal, seuls les blocs créés depuis le ramasse-miettes sont compactés ; les compteurs de générations de tous les autres blocs sont mis à jour s'ils ont été référencés. Cela prend en compte le cas courant des nombreux objets ayant une durée de vie très courte. Régulièrement, un balayage complet est effectué, les gros objets ne sont toujours pas copiés (leurs compteurs de génération sont simplement mis à jour) et les blocs contenant des petits objets sont copiés et compactés. La JVM évalue constamment l'efficacité du ramasse-miettes et si cette technique devient une pénalité plutôt qu'un avantage, elle la change pour un « mark-and-sweep ». De même, la JVM évalue l'efficacité du mark-and-sweep et si le tas se fragmente, le stop-and-copy est réutilisé. C'est là où l'« adaptation » vient en place et finalement on peut utiliser ce terme anglophone à rallonge : « adaptive generational stop-and-copy mark-and-sweep » qui correspondrait à « adaptatif entre marque-et-balaye et stoppe-et-copie de façon générationnelle ».

Ce livre a été écrit par Bruce Eckel ( télécharger la version anglaise : Thinking in java )
Ce chapitre a été traduit par F. Defaix et Y. Chicha ( groupe de traduction )
télécharger la version francaise (PDF) | Commandez le livre en version anglaise (amazon) | télécharger la version anglaise
pages : 1 2 3 4 5 6 
Penser en Java 2nde édition - Sommaire |  Préface |  Avant-propos | Chapitre : 1  2  3  4  5  6  7  8  9  10  11  12  13  14  15 |  Annexe : A B C D  | Tables des matières - Thinking in Java