JavaProgrammationDéveloppeur Java Senior

Quel risque de synchronisation se pose lorsque la libération explicite de ressources entre en concurrence avec le nettoyage automatisé dans les classes JDK gérant la mémoire native, illustré par l'implémentation de **Inflater** ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Historique : Avant Java 9, la gestion des ressources natives dans des classes comme Inflater et Deflater reposait sur Object.finalize(). Ce mécanisme a été déprécié en raison de son imprévisibilité, de son overhead de performance sévère et du risque de résurrection d'objet retardant la collecte des ordures. Java 9 a introduit l'API Cleaner comme alternative moderne, utilisant PhantomReference et ReferenceQueue pour découpler la logique de nettoyage du cycle de vie de l'objet tout en garantissant que l'objet reste inaccessible pendant le nettoyage.

Problème : Dans l'implémentation de Inflater, la structure native sous-jacente z_stream doit être explicitement désallouée via la méthode end() pour éviter les fuites de mémoire native. Lorsqu'un thread d'application appelle explicitement end() alors que le thread Cleaner tente simultanément d'exécuter l'action de nettoyage enregistrée, une condition de course émerge. Sans synchronisation appropriée, les deux threads pourraient tenter de libérer le même pointeur natif, entraînant une erreur de double libération, ou un thread pourrait accéder à la ressource après que l'autre l'a libérée (utilisation après libération), entraînant des plantages de la JVM (SIGSEGV) dans la bibliothèque zlib native.

Solution : La solution utilise un indicateur d'état AtomicBoolean pour garantir que le nettoyage natif s'exécute exactement une fois, peu importe quel thread l'initie. La méthode explicite end() et l'action de nettoyage du Cleaner effectuent toutes deux une opération de comparaison-et-ensemble (CAS) sur cet indicateur. Seul le thread qui réussit à faire passer l'indicateur de false à true appelle la routine de désallocation native. Cette approche sans verrou garantit la sécurité des threads tout en maintenant la haute performance requise pour les opérations de compression.

Situation de la vie réelle

Un service de compression de journaux à haut débit traite des millions d'entrées de journaux par jour en utilisant des instances de Deflater mises en pool pour minimiser l'overhead d'allocation. Pour optimiser l'utilisation des ressources, les développeurs ont mis en œuvre un patron de retour dans le pool qui appelle explicitement end() sur les instances de Deflater avant de les libérer dans le pool, tout en s'appuyant également sur la collecte des ordures pour récupérer les instances qui ont fui en raison d'exceptions non gérées dans le pipeline de traitement.

Le système a connu des plantages sporadiques mais critiques de la JVM (SIGSEGV) lors des charges de pointe, avec des dumps de cœur indiquant une corruption de mémoire au sein de la bibliothèque zlib native. L'enquête a révélé que lorsqu'une instance de Deflater était retournée dans le pool, le thread d'application appelait end(), mais si l'instance devenait éligible pour la collecte des ordures simultanément, le thread Cleaner tenterait également de nettoyer le même handle z_stream natif. Cet accès non synchronisé à la ressource native a causé des plantages imprévisibles du processus.

La première solution envisagée a été de synchroniser chaque accès à l'instance de Deflater en utilisant des blocs ou des méthodes synchronized. Cette approche empêchait effectivement la condition de course en garantissant l'exclusion mutuelle. Cependant, elle a introduit un overhead de contention important dans le pipeline de compression à haute fréquence et risquait des interblocages si l'objet était accédé incorrectement par plusieurs threads en même temps, violant le contrat de sécurité des threads de la classe.

La deuxième approche consistait à utiliser un AtomicBoolean pour suivre l'état de nettoyage. La méthode explicite end() et l'action du Cleaner vérifieraient et définiraient cette indication de manière atomique avant de toucher à la ressource native. Cela offrait une sécurité sans verrou avec une pénalité de performance minimale, bien qu'il exigeât une mise en œuvre minutieuse pour s'assurer que le handle natif n'était pas accédé après la vérification atomique mais avant l'appel natif.

La troisième option était de supprimer complètement les appels explicites à end() et de se fier uniquement au Cleaner pour la gestion des ressources. Cela éliminait complètement la condition de course mais introduisait de l'imprévisibilité dans le timing de la libération de mémoire native, ce qui pourrait entraîner une pression mémoire sévère pendant les pauses de collecte des ordures si les cycles de GC prenaient du retard par rapport au taux d'allocation des structures natives.

L'équipe a choisi l'approche AtomicBoolean (Solution 2) parce qu'elle offrait un nettoyage immédiat déterministe lorsque cela était possible (appel explicite) tout en garantissant la sécurité si le cleaner s'exécutait plus tard. Ils ont modifié la classe de wrapper pour implémenter AutoCloseable, en s'assurant que la vérification de l'état atomique protégeait la désallocation native. Cela a complètement résolu les plantages tout en maintenant le débit requis, éliminant les plantages liés à la mémoire native en production.

Ce que les candidats oublient souvent

Comment l'API Cleaner empêche-t-elle le problème de résurrection d'objet inhérent à Object.finalize() ?

Dans Object.finalize(), l'objet est toujours accessible lorsque la méthode finalize() s'exécute car la référence this reste valide, permettant à l'objet de se ressusciter en stockant une référence à lui-même dans un champ statique. Cette résurrection retarde indéfiniment la collecte des ordures si l'objet se ressuscite de manière répétée. L'API Cleaner empêche cela en utilisant PhantomReference. Lorsque l'action de nettoyage du Cleaner s'exécute, le référent (l'objet à nettoyer) est déjà dans l'état de portée fantôme, ce qui signifie qu'il ne peut pas être ressuscité car il n'existe aucune référence forte, douce ou faible. L'action de nettoyage est un Runnable distinct, pas une méthode sur l'objet lui-même, garantissant que l'objet reste inaccessible pendant tout le processus de nettoyage.

Pourquoi Thread.interrupt() est-il inefficace pour arrêter un thread Cleaner lors de l'arrêt de la JVM, et quelles sont les implications ?

Le thread Cleaner est un thread démon qui bloque continuellement sur ReferenceQueue.remove(), attendant que des références fantômes deviennent disponibles. Bien que ReferenceQueue.remove() réponde aux interruptions en lançant InterruptedException, l'implémentation de Cleaner intercepte cette exception et continue sa boucle infinie, ignorant effectivement les interruptions. Ce design garantit que le nettoyage critique des ressources se termine même pendant les séquences d'arrêt. Cependant, si une action de nettoyage enregistrée se bloque indéfiniment (par exemple, en attendant un délai d'attente réseau ou bloquée dans une boucle infinie), le thread Cleaner ne terminera jamais. Cela peut empêcher la JVM de s'arrêter correctement si d'autres threads non-démon attendent des ressources que le cleaner est censé libérer.

Quelle fuite de mémoire catastrophique se produit si l'action de nettoyage d'un Cleaner capture une référence forte à l'objet étant nettoyé ?

Si le Runnable passé à Cleaner.register() capture une référence forte à l'objet (par exemple, via this::cleanupMethod ou une lambda faisant référence à this), il crée un cycle de référence fatal. Le Cleaner maintient un ensemble interne d'objets Cleanable, chacun tenant une référence au Runnable de nettoyage. Si ce Runnable fait référence à l'objet original, l'objet reste fortement accessible depuis le thread Cleaner lui-même. Par conséquent, l'objet ne devient jamais accessible par les fantômes, le PhantomReference n'entre jamais dans la file d'attente, et l'action de nettoyage ne s'exécute jamais. Pendant ce temps, l'objet ne peut pas être collecté par les ordures, entraînant une fuite de mémoire sévère qui croît de manière illimitée avec chaque objet enregistré auprès du Cleaner, provoquant finalement un OutOfMemoryError.