BT

Diffuser les Connaissances et l'Innovation dans le Développement Logiciel d'Entreprise

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Le défaut fatal des Finalizers et Phantom References

Le défaut fatal des Finalizers et Phantom References

Favoris

La méthode finalize() en Java permet à un objet d'effectuer des actions de nettoyage avant qu'il ne soit vraiment détruit par le garbage collector. Il est bien connu que cette fonctionnalité à des défauts, et que son utilisation sûre est limitée à des cas très rares, l'exemple principal étant le pattern du "filet de sécurité", où un finalizer est utilisé au cas où le propriétaire de l'objet oublie d'appeler explicitement la méthode de terminaison. Malheureusement, il est moins connu que de tels cas sont fragiles et sans précautions particulières peuvent aussi échouer. Et contrairement à la croyance populaire, PhantomReference, qui est souvent citée comme une bonne alternative aux finalizers, souffre du même problème fondamental.

Avant d'entrer dans les détails de ce problème, il est utile de revoir les défauts des finalizers.

Défauts des Finalizers

1. L'exécution est retardée et peut ne jamais arriver

Les garbage collectors peuvent décider de retarder le nettoyage des objets jusqu'à ce que la mémoire devienne basse ou que des caractéristiques d'exécution arrivent, comme une charge réduite, augurant d'une bonne période pour collecter. Le défaut de ce comportement est que le finalizer ne sera pas exécuté jusqu'à ce que l'objet soit collecté. De plus, les finalizers sont souvent exécutés par un petit pool de threads, augmentant les délais. Le problème empire quand les finalizers sont mal écrits, ce qui peut introduire des actions bloquantes qui peuvent retarder l'exécution d'autres finalizers puisqu'ils tendent à tous partager le même pool. Si le programme est terminé prématurément ou si le garbage collector a suffisamment de ressources, le finalizer peut ne jamais être exécuté. Une classe doit donc être conçue de telle façon qu'elle ne nécessite pas qu'une action soit effectuée par un finalizer.

2. La Garbage collection d'un objet est significativement plus coûteuse lorsqu'elle contient un finalizer.

Les objets avec finalize() nécessitent plus de travail pour être traités par le garbage collector et l'exécution du finalizer nécessite que toute la mémoire associée soit conservée jusqu'à ce que le finalizer ait été exécuté. Cela signifie typiquement que le garbage collector doit revisiter cet objet, probablement dans une passe différente. Par conséquent, les finalizers sur des objets très nombreux et avec une durée de vie courte peuvent introduire des problèmes de performance majeurs.

3. L'exécution concurrente des finalizers sur des objets appartenant au même graphe peut produire des résultats inattendus.

Cela conduit à des comportements non intuitifs dans des structures de données où des noeuds se référencent les uns les autres. Les finalizers sur ces noeuds peuvent êtres appelés au même instant et dans n'importe quel ordre, ce qui peut corrompre leur état s'ils tentent d'accéder à leurs pairs respectifs. Une grande attention doit être donnée pour assurer un ordre spécifique ou pour gérer les conséquences.

4. Les exceptions non catchées dans les finalizers sont ignorées et jamais reportées.

Les Finalizers nécessitent une gestion correcte des exceptions et un log dans les cas d'échec. Autrement, des informations critiques de debug sont perdues et les objets peuvent rester dans un état indéterminé.

5. Les Finalizers peuvent ressusciter des objets dans un état corrompu.

Si la référence "this" s'échappe d'un finalize(), l'objet peut être encore visible mais dans un état corrompu, à moitié nettoyé, et allant probablement entrainer des bugs dans d'autres parties de l'application.

En résumé, la combinaison d'un ou plusieurs de ces facteurs préviennent la plupart des cas d'utilisation des outils de nettoyage des langages déterministes, tels que les destructeurs en C++, qui sont liés à un scope bien défini ou des opérations de libération explicites. Java est au contraire conçu autour du nettoyage par l'appelant.

Nettoyage correct des ressources

La gestion correcte des ressources en Java devrait être effectuée par l'appelant. Cette approche requiert qu'une ressource fournisse une méthode "close", et que les appelants utilisent le try-with-ressource de Java 7 (ou try/finally). Le nettoyage est donc déterministe et immédiat. Tout code qui utilise une ressource (quelque chose avec un close() ou une autre méthode de fermeture) devrait être développé en utilisant ce mécanisme, même si la ressource fournit sa propre méthode finalize(). Faire ainsi réduit grandement le potentiel de bugs et améliore les performances.

Exemple 1 : Un try-with-resources correct

try (FileInputStream file = new FileInputStream(“file")) {
   // Try with resources appelera file.close en sortant de ce bloc
}

Néanmoins, les concepteurs d'API veulent souvent ou sont contraints par le contrat d'ajouter des garde-fous supplémentaires aux ressources lourdes, au cas où l'appelant oublierait d'utiliser correctement le mécanisme de try-with-resource (ou try/finally). Comme illustration de ce dernier cas, un Context JNDI est souvent associé avec des ressources comme une connexion réseau, mais sa Javadoc indique explicitement qu'appeler close() est optionel, et que le nettoyage sera quand même effectué.

Le problème

Pour protéger contre de tels oublis, la seule option disponible est d'utiliser un finalizer filet de protection ou d'utiliser une PhantomReference. Malheureusement, ces options peuvent échouer si des précautions ne sont pas prises.

Exemple 2 : Un mauvais finalizer filet de protection

public void work() throws Exception {
  FileOutputStream stream = this.stream;
  stream.write(PART1);
  stream.write(PART2);
} 

protected void finalize() {
  try {
    stream.close();
  } catch (Throwable t) {
  }
} 

public static void main(String[] args) throws Exception {
  Example example = new Example(); 
  example.work();
}

Au premier regard, il semble n'y avoir rien d'incorrect avec cet exemple de mauvais filet de protection, et dans de nombreux cas, il s'exécutera correctement. Néanmoins, dans les bonnes conditions, il échouera avec une exception inattendue. (Le lecteur attentif notera que FileOutputStream possède déjà un finalizer, et que cet exemple est donc redondant ; cependant toutes les ressources n'en ont pas, et cet exemple est fait pour être une illustration concise).

Exception 1 : Exception démontrant la finalization prématurée

Exception in thread "main" java.io.IOException: Stream Closed

at java.io.FileOutputStream.writeBytes(Native Method)
at java.io.FileOutputStream.write(FileOutputStream.java:325)
at Example.work(Example.java:36)
at Example.main(Example.java:47)

Cet échec montre clairement que le finalizer a été exécuté durant la méthode work, la question qu'on peut se poser est "quand et pourquoi cela arrive ?".

Une recherche dans les mécanismes de la JVM Hotspot d'OpenJDK va fournir des réponses.

Comment cela se produit - Plongée dans les rouages de la JVM

En observant le comportement d'HotSpot, il est utile de comprendre quelques concepts clés. Avec HotSpot, les objets sont considérés vivants s'ils sont atteignables à partir d'un autre objet de la heap, un handle JNI ou par une référence locale d'une méthode sur la stack d'un thread.

Déterminer si une référence locale est utilisée est complexe pour HotSpot en raison de son compilateur just-in-time. Ce compilateur transforme des bytecode Java en instructions natives optimisées selon l'architecture CPU cible et des informations disponibles à l'exécution. Le code produit peut varier grandement, un mécanisme de coordination avec le garbage collector est requis.

Avec HotSpot, ce mécanisme est connu sous le nom de safe-point. Quand un thread atteint un safe-point, le garbage collector peut manipuler l'état du thread de façon sûre et peut donc déterminer quels sont les objets vivants en suspendant brièvement l'exécution du code applicatif. Seules certaines parties du code sont susceptibles de devenir des safe-points, la plus notable étant l'appel de méthode.

Durant la génération du code natif, le compilateur JIT stocke une GC map à chaque safe-point potentiel. Une GC map contient la liste des références considérées comme vivantes à ce moment de l'exécution. Le garbage collector peut alors utiliser ces maps et déterminer précisément quels objets sont atteignables localement sans avoir besoin de comprendre le code natif.

En insérant un appel de méthode arbitraire comme yield() au début de la méthode work() de l'exemple, la GC map à cet endroit peut être comparé avec la map d'autres appels de méthodes pour déterminer exactement à quel moment HotSpot décide que l'objet référencé devienne éligible pour la collecte. Faisons un peu plus d'analyse pour comprendre ce qui a causé l'exception.

Exemple 3 : Un mauvais finalizer filet de protection avec une méhode supplémentaire pour comparer les GC maps

public void work() throws Exception {
  Thread.yield(); // appel de méthode, safe-point potentiel
  FileOutputStream stream = this.stream;
  stream.write(PART1); // L'appel existant est déjà un safe-point potentiel
  stream.write(PART2);
}

Les GC maps peuvent être inspectées dans l'assembleur affiché en sortie par OpenJDK, en installant un plugin désassembleur puis en activant les options JVM adéquates. La sortie ne se fera que quand la méthode est compilée, d'autres paramètres sont donc requis pour forcer une compilation. Les optimisations les plus agressives sont effectuées par le compilteur serveur (C2), qui le rend idéal pour cette analyse. Notez que ce mode requiert habituellement 10 000 appels de la méthode avant qu'elle ne soit compilée. Abaisser le seuil du compilateur à 1 permet de forcer cette compilation immédiatement.

Exemple 4 : Arguments JVM pour désassembler la méthode work()

java —server XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Example.work -XX:CompileThreshold=1 -XX:-TieredCompilation Example

Diagramme 1 : HotSpot x86 désassemblage, réduit pour la brièveté, et annoté avec le code Java correspondant à chaque bloc d'instructions

HotSpot suit les conventions d'appels standards x86-64. Quand la méthode work est appelée, une copie de la référence "this" est placée dans le registre "rsi" avant que le code de cette méthode ne soit exécuté. La première instruction copie simplement la référence "this" qui était placée dans "rsi" vers le registre de travail "rbp".

mov rbp,rsi

A la seconde instruction "call", la méthode Thread.yield est appelée, qui comme mentionné avant, est un safe-point potentiel, et C2 a donc inclus une GC map (avec le label OopMap dans la sortie). A ce moment, le contenu de "rbp", qui est la référence "this", est marqué comme vivant, l'objet ne peut donc pas être collecté, et n'est donc pas finalisable.

call   0x00007f0375037f60  ; OopMap{rbp=Oop off=36}
                           ;*invokestatic yield 

-> Thread.yield(); // Nouveau safe-point potentiel

La troisième instruction, "mov", copie le contenu du champ "stream" dans "rbp", écrasant la référence à "this" qui y était stockée précédemment. Dans HotSpot, les objets Java sont stockés dans une zone de mémoire contiguë, un champ lu est simplement un décalage ajouté à l'adresse de l'objet qui contient le champ. Dans ce cas, "stream" est situé 16 bytes après le début de "this".

mov rbp,QWORD PTR [rbp+0x10] ;*getfield stream

-> FileOutputStream stream = this.stream;

Les instructions suivantes préparent et exécutent l'appel de write() sur le contenu du champ "stream" qui est maintenant stocké dans le registre "rbp". Les instructions "test" et "je" effectuent une comparaison du pointeur avec null, lançant une NullPointerException si nécessaire. La première instruction "mov" copie le contenu de la constante "PART1" (une référence à un tableau de bytes) dans "rdx", préparant l'argument à l'appel de méthode. Le registre "rbp" qui contient le champ "stream" est copié dans "rsi", qui suit les conventions d'appels pour l'appel suivant à write().

test   rbp,rbp
je     0x7f0375113ce0
mov    rdx,0x7f02cdaea718  ;   {oop([B)}
mov    rsi,rbp
call   0x00007f0375037b60  ; OopMap{rbp=Oop off=64}
                           ;*invokevirtual write 

-> stream.write(PART1);

Finalement l'appel à write() est fait, et puisque c'est un safe-point potentiel, une autre GC map est ajoutée. Cette map indique que seul "rbp" est atteignable. Puisque "rbp" a été écrasé par "stream", il ne contient plus "this", et "this" n'est donc pas considéré comme atteignable à ce point de l'exécution. Le diagramme précédent décrit l'état de "rbp" durant l'exécution du code de la méthode work().

Puisque la référence "this" de la méthode work() était la seule référence restante à l'objet, le finalizer peut être exécuté de façon concurrente avec l'appel de write(), amenant la stack trace mentionnée plus tôt. De la même façon, une PhantomReference associée avec cet objet pourrait être transmise au thread de nettoyage associé, amenant à la même fermeture prématurée du flux.

En résumé, cette analyse nous montre qu'un objet peut être collecté avant que les appels de méthode sur cet objet ne soient terminés.

Pourquoi cela arrive

Malheuresuement, ce comportement est explicitement autorisé par la Java Language Specification (12.6.1) :

“Les transformations optimisantes d'un programme peuvent être conçues de telle façon qu'elles réduisent le nombre d'objets atteignables par rapport à ce qui pourrait être naïvement attendu. Par exemple, un compilateur Java ou un générateur de code peut choisir de mettre une variable ou un paramètre qui ne sera plus utilisé à null pour permettre de rendre la mémoire allouée à cet objet récupérable plus tôt”.

Et de façon plus menaçante :

“Les transformations de cette sorte peuvent aboutir à des appels de la méthode finalize avant ce qui pourrait être attendu”.

Bien que non intuitive, la notion générale de nettoyage précoce est bénéfique pour la performance. Par exemple, c'est un gaspillage de retenir un objet qui ne sera plus longtemps utilisé par une méthode qui se lancerait dans une activité longue. Par contre, l’interaction de ce comportement avec les finalizers et PhantomReference est contre productive et susceptible de causer des erreurs.

Stratégies de mitigation

Il existe plusieurs techniques qui peuvent être utilisées pour prévenir ces comportements. Néanmoins, gardez en tête que ces techniques sont délicates, utilisez les avec précaution.

La stratégie "tout synchroniser"

Cette stratégie est basée sur une règle spéciale de la JLS (12.6.1) :

“Si le finalizer d'un objet peut causer une synchronisation sur cet objet, alors cet objet doit être vivant et considéré atteignable quand un lock sur celui-ci est tenu”.

En d'autres mots, si le finalizer est synchronisé, il est garanti qu'il ne sera pas appelé tant que d'autres appels de méthode synchronisés sont en cours. C'est l'approche la plus simple, puisque tout ce qu'elle requiert est d'ajouter le mot clé synchronized au finalizer et toutes les méthodes qui peuvent entrer en conflit avec (typiquement, toutes les méthodes).

Exemple 5 : La stratégie tout synchroniser

public synchronized void work() throws Exception {
  FileOutputStream stream = this.stream;
  stream.write(PART1);
  stream.write(PART2);
} 

protected synchronized void finalize() {
  try {
    stream.close();
  } catch (Throwable t) {
  }
}
public static void main(String[] args) throws Exception {
  Example example = new Example(); 
  example.work();
}

Le défaut le plus évident à cette approche est qu'elle sérialise tout les accès à l'objet, ce qui exclut toute classe qui doit supporter des accès concurrents. Un autre défaut est que le coût en performance de la synchronisation va causer un impact sur les performances. Dans les scénarios dans lesquels l'instance est fixée à un seul thread pour une durée prolongée, la JVM peut bloquer le lock par un processus appelé biaised locking, qui élimine la plupart des coûts. Néanmoins, même quand cette optimisation entre en jeu, en raison des règles du Java Memory Model, une barrière mémoire sera probablement émise pour synchroniser l'état entre tous les coeurs CPU, ce qui ajoute typiquement une latence inutile.

La stratégie synchronize avec un RWLock

Pour les objets qui nécessitent des accès concurrents, la stratégie "tout synchroniser" peut être modifiée pour supporter des exécutions parallèles de la méthode work(). Ceci peut être accompli en utilisant un ReadWriteLock et un thread de nettoyage séparé. La méthode work() acquiert un read lock dans un court bloc synchronized pour s'assurer que le finalizer ne soit pas appelé, s'assurant en retour que le read lock est toujours acquis avant le write lock. Le thread de cleanup séparé est nécessaire puisque les tâches de nettoyage, une fois créées, bloquent sur le write lock ; et le blocage du (des) thread(s) de finalization des JVMs devrait être évité pour les raisons listées précédemment.

Exemple 6 : La stratégie synchronize avec un RWLock

private void work()  {
  ReentrantReadWriteLock lock = this.lock;
  try {
    synchronized (this) { // Le monitor évite de bloquer le finalizer
      lock.readLock().lock();
    }
    stream.write(PART1);
    stream.write(PART2);
  } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
  } finally {
    lock.readLock().unlock();
  }
}
protected synchronized void finalize() {
  // Délègue à un autre thread our éviter de bloquer le(s) thread(s) finalizer de la JVM
  REAPER.execute(new CleanupTask(lock));
} 

private static class CleanupTask implements Runnable {
  // Constructeur et champs omis
  public void run() {
    try {
      lock.writeLock().lock();
      safeClose(stream);
    } finally {
      lock.writeLock().unlock();
    }
  } 
}

Le désavantage de cette approche est qu'elle est complexe et acquiert un lock dans un lock. Puisque son but est de supporter des exécutions concurrentes de la méthode work, le biaised locking ne va pas aider.

La stratégie volatile

Une approche améliorée serait de prendre des actions qui gardent l'objet vivant pendant la durée de la méthode work. Une approche naïve serait de lire un champ à la fin de la méthode work.

Exemple 7 : Essai naïf, fonctionnement non garanti

private int dummy; 

public void work() throws Exception {
  FileOutputStream stream = this.stream;
  stream.write(PART1);
  stream.write(PART2);
  // fonctionnement non garanti
  int dummy = this.dummy;
}

Ce code peut ne pas fonctionner car l'optimiseur peut supprimer une lecture inutile. Un essai "rusé" pour le corriger serait d'utiliser une autre règle de la JLS qui indique que toutes les écritures de champ doivent être visibles au finalizer.

Exemple 8 : Essai rusé, fonctionnement toujours non garanti

private int counter;
public void work() throws Exception {
  FileOutputStream stream = this.stream;
  stream.write(PART1);
  stream.write(PART2);
  // fonctionnement non garanti
  this.counter++;  
}

Cet essai peut aussi échouer car l'optimiseur peut réordonner les instructions voire même supprimer l'écriture puisqu'elle n'est jamais utilisée. Le code de l'exemple 9 est équivalent puisque le résultat de la méthode est le même.

Exemple 9 : L'optimiseur déplaçant une instruction

public void work() throws Exception {
  FileOutputStream stream = this.stream;
  // Un optimiseur peut déplacer cette instruction, déclenchant les problèmes
  this.counter++; 
  stream.write(PART1);
  stream.write(PART2);
}

Heureusement, il est possible d’empêcher l'optimiseur de réordonner les instructions en utilisant une règle du Java Memory Model. Le JMM requiert que toutes les écritures de champ volatiles soient visibles par les autres threads quand le champ volatile est lu, ou quand un autre événement qui établit une relation "happens-before" se produit.

En marquant le compteur comme volatile, HotSpot ne peut pas réordonner les instructions avant l'écriture. Il existe toujours la possibilité théorique que de futurs optimiseurs puissent déterminer que les effets mémoire d'une écriture volatile ne soient pas nécessaires, et puisque le champ n'est jamais utilisé, il peut toujours être éliminé. On peut se prémunir de cette possibilité en publiant la valeur dans un champ public statique. Le contenu d'un champ public statique doit être visible au code non encore chargé. Combiner ces approches mène à une stratégie volatile fonctionnelle qui supporte les accès concurrents sans aucun type de locks.

Exemple 10 : La stratégie volatile

public static int STATIC_COUNTER = 0; 
private volatile int counter = 0; 

public void work() throws Exception {
  FileOutputStream stream = this.stream;
  stream.write(PART1)
  stream.write(PART2);
  this.counter++; // L'écriture volatile empêche le réordonnancent 
} 

protected void finalize() {
  if (safeClose(stream)) {
    STATIC_COUNTER = counter; // L'écriture public static empêche la possibilité d’élimination
  } 
}

L'écriture est inévitable et il y a toujours une barrière mémoire qui doit être émise à chaque écriture, idéalement on souhaite l'éviter.

La stratégie volatile + écriture lazy

Une petite modification à la stratégie volatile peut réduire le coût des écritures tout en assurant que les effets d'ordonnancement désirés soient en place. Utiliser un AtomicIntegerFieldUpdater autorise une classe à effectuer des écritures lazy. Une écriture lazy utilise un type de barrière moins coûteux appelée store-store, qui garantit uniquement l'ordre. x86 et SPARC sont naturellement ordonnés, une écriture lazy est donc gratuite sur ces plateformes. Sur d'autres plateformes, comme ARM, il y a un léger surcoût, mais très inférieur à une écriture volatile classique.

Exemple 11 : La stratégie volatile + écriture lazy

public static int STATIC_COUNTER = 0;
private volatile int counter = 0;
private static AtomicIntegerFieldUpdater UPDATER = …
public void work() throws Exception {
  FileOutputStream stream = this.stream;
  stream.write(PART1);
  stream.write(PART2);
  UPDATER.lazySet(this, counter + 1); // L'écriture volatile empêche le réordonnancent
} 

protected void finalize() {
  if (safeClose(stream)) {
    STATIC_COUNTER = counter; // L'écriture public static empêche la possibilité d’élimination
  } 
}

Protection contre les méthodes natives

Les appels JNI gardent une référence à l'objet hôte, aucune stratégie spéciale n'est nécessaire. Néanmoins, il est courant pour les méthodes natives d'être mélangées aux méthodes Java, une classe utilisant du code natif doit prendre toutes les précautions appropriées pour les méthodes Java.

Le besoin d'améliorations

Bien qu'effectives, ces stratégies sont encombrantes, fragiles et beaucoup plus coûteuses qu'une fonctionnalité du langage. Une telle existe déjà pour la plateforme .NET. Les applications C# peuvent utiliser GC.KeepAlive() qui indique au compilateur JIT de garder vivant l'objet passé en paramètre.

Si le JDK implémentait une fonctionnalité similaire, la méthode work() ressemblerait à :

Exemple 12 : En utilisant une fonctionnalité potentielle du JDK

public void work() throws Exception {
  FileOutputStream stream = this.stream;
  stream.write(PART1);
  stream.write(PART2);
  System.keepAlive(this);
}

Il n'y a pas de surcoût non nécessaire à cette approche. Le code est propre et son objectif est clair pour les futurs mainteneurs. Au moindre doute, il suffit de lire la javadoc de la méthode keepAlive().

Conclusion

Le finalizer et les PhantomReference de Java sont propices aux erreurs et devraient en général être évités. Il existe tout de même des cas légitimes d’utilisation de ces fonctionnalités, et si nécessaire, une des stratégies de cet article devrait être utilisé pour éviter les problèmes de collecte prématurée.

Toutes les utilisations de ressources, finalisables ou non, devraient utiliser le try-with-resources ou le try/finally. Même les objets avec des finalizers incorrects fonctionneront correctement si l'appelant effectue cette action.

Peut être qu'une future version des JVM implémentera une fonctionnalité de keepAlive(), ce qui simplifierait grandement le travail des développeurs et réduirait le nombre de bugs potentiels quand un finalizer est nécessaire.

Code des exemples

Le code pour chacune de ces stratégies, ainsi que pour l'exemple non fonctionnel, est disponible sur GitHub.

A propos de l'Auteur

Jason Greene est un Platform Architect pour Red Hat, et est lead sur le projet open source de serveur d'application Wildfly. Il est aussi membre du JCP et représente Red Hat sur les spécifications Java EE. Au cours de ses fonctions à Red Hat, il a travaillé sur de nombreux sujets, y compris le serveur d'application, le clustering, les web services, l'AOP et la sécurité. Ses centres d’intérêts incluent la concurrence, les systèmes distribués, les protocoles réseau et la conception de langages de programmation. Vous pouvez suivre Jason sur Twitter.

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT