L'histoire remonte à l'introduction de java.lang.ref.Cleaner dans Java 9 comme remplacement de la méthode dépréciée Object.finalize(), qui souffrait de délais d'exécution imprévisibles, de pénalités de performance et de vulnérabilités de sécurité, y compris des attaques de résurrection. Le problème central réside dans le fait que finalize() permettait à la méthode redéfinie de stocker une référence à this dans le graphe d'objets en vie, ressuscitant ainsi un objet que le ramasse-miettes avait déjà jugé inaccessibile, violant l'invariant de construction unique et de destruction unique et laissant potentiellement des ressources natives dans un état incohérent.
La solution tire parti de la sémantique de PhantomReference au sein de l'implémentation de Cleaner : l'action de nettoyage reçoit uniquement le Runnable ou l'objet d'action de nettoyage, et le référent est garanti d'être dans l'état de reachabilité fantôme, ce qui signifie qu'il a déjà été finalisé et ne peut pas être ressuscité, assurant ainsi que la logique de nettoyage opère sur un objet irrécupérablement inaccessibile.
public class NativeResource { private static final Cleaner cleaner = Cleaner.create(); private final long nativeHandle; public NativeResource() { nativeHandle = allocateNative(); cleaner.register(this, new CleanupAction(nativeHandle)); Reference.reachabilityFence(this); } private static class CleanupAction implements Runnable { private final long handle; CleanupAction(long handle) { this.handle = handle; } @Override public void run() { releaseNative(handle); } } private native long allocateNative(); private static native void releaseNative(long handle); }
Notre équipe gérait un pipeline de traitement d'image à haut débit où des objets ImageProcessor enveloppaient des tampons OpenCV natifs alloués via JNI. Au départ, nous nous appuyions sur finalize() pour invoquer cvReleaseImage(), mais nous rencontrions sporadiquement une exhaustion de mémoire native malgré la stabilité du tas Java, accompagnée d'erreurs de segmentation intermittentes suggérant des erreurs d'utilisation après libération dans la mémoire native.
La première approche envisagée était de conserver finalize() mais d'ajouter une protection contre la résurrection où nous synchronisions sur une carte statique pour suivre les objets "morts". Cela souffrait d'une latence imprévisible : la finalisation pouvait se produire des minutes après le début de la pression sur le tas, et la logique de protection elle-même créait des fuites de mémoire en maintenant des références fortes aux objets "morts" dans la carte de suivi, empêchant ironiquement leur collecte tout en permettant encore des conditions de course pour la résurrection.
La deuxième approche consistait à utiliser java.lang.ref.Cleaner naïvement en capturant le pointeur natif dans une lambda au sein du constructeur d'instance : cleaner.register(this, () -> free(pointer)). Bien que cela ait évité des délais de finalisation, cela risquait une collecte prématurée si this échappait pendant la construction, et, de manière critique, si la lambda empruntait accidentellement l'instance ImageProcessor plutôt que juste la valeur du pointer, cela créerait un cycle de référence forte empêchant la collecte du ramasse-miettes du référent, bien qu'empêchant toujours la résurrection en soi.
Nous avons choisi la troisième approche : mettre en œuvre une classe statique imbriquée CleaningAction qui ne contenait que le pointeur natif (en tant que long) et implémentait Runnable, complètement découplée de l'instance externe ImageProcessor. Nous avons enregistré l'action de nettoyage immédiatement après une allocation native réussie et invoqué explicitement Reference.reachabilityFence(this) à la fin du constructeur pour garantir que l'objet restait accessible jusqu'à la fin de l'enregistrement. Cela a éliminé les risques de résurrection et les fuites natives, réduisant les incidents de tension mémoire d'occurrences quotidiennes à zéro pendant six mois.
Pourquoi le Cleaner utilise-t-il PhantomReference plutôt que WeakReference ou SoftReference, et comment cela empêche-t-il l'action de nettoyage d'accéder à l'état du référent ?
PhantomReference est utilisé car il permet au ramasse-miettes d'identifier les objets récupérables sans permettre au code du programme d'accéder aux champs ou méthodes du référent après qu'il soit entré dans l'état de reachabilité fantôme. Contrairement à WeakReference, qui permet de récupérer le référent via get() jusqu'à la collecte, PhantomReference retourne toujours null de get(), assurant que l'action de nettoyage opère sur l'hypothèse que l'objet est logiquement détruit et empêchant toute tentative de résurrection ou d'inspection d'état qui pourrait violer les invariants post-mortem.
Quelle est l'attaque de "résurrection" dans le contexte de Object.finalize(), et pourquoi cela viole-t-il les garanties de sécurité du système de types ?
La résurrection se produit lorsque la méthode finalize() stocke la référence this dans un champ statique ou un graphe d'objets en vie, rendant l'objet à nouveau accessible après que le ramasse-miettes l'ait marqué pour la récupération. Cela viole l'invariant selon lequel le constructeur d'un objet s'exécute exactement une fois et son finaliseur s'exécute au plus une fois, permettant à un code malveillant ou bogué d'observer un objet dans un état partiellement détruit où des ressources natives sont libérées mais les champs Java sont encore accessibles, entraînant des vulnérabilités d'utilisation après libération et un comportement incohérent des objets.
Comment Reference.reachabilityFence interagit-elle avec les optimisations de compilation juste-à-temps du JVM, et quand est-il strictement nécessaire de l'utiliser avec les enregistrements de Cleaner ?
Reference.reachabilityFence agit comme une barrière de compilation qui empêche l'optimiseur de JVM de réorganiser ou d'éliminer les références à des objets avant qu'une section critique ne soit terminée, empêchant spécifiquement la "publication anticipée" où un objet devient inaccessible pendant que son constructeur s'exécute encore. Il est strictement nécessaire lors de l'enregistrement d'objets avec Cleaner pendant la construction car sans cela, le JVM pourrait déterminer que this n'est plus nécessaire après l'allocation de la ressource native mais avant l'appel d'enregistrement, permettant au cleaner de s'exécuter et de libérer la ressource pendant que le constructeur continue d'initialiser l'objet, entraînant une corruption des ressources.