La historia se remonta a la introducción de java.lang.ref.Cleaner en Java 9 como un reemplazo para el método obsoleto Object.finalize(), que sufría de una ejecución de temporización impredecible, penalizaciones de rendimiento y vulnerabilidades de seguridad, incluidas las ataques de resurrección. El problema central surge porque finalize() permitía que el método anulado almacenara una referencia a this de vuelta en el gráfico de objetos activos, lo que "resucitaba" un objeto que el recolector de basura ya había considerado inalcanzable, violando la invariante de construcción única y destrucción única y potencialmente dejando recursos nativos en un estado inconsistente.
La solución aprovecha la semántica de PhantomReference dentro de la implementación de Cleaner: la acción de limpieza recibe solo el objeto Runnable o de acción de limpieza, no el referente en sí, y se garantiza que el referente esté en el estado de accesibilidad fantasma, lo que significa que ya ha sido finalizado y no puede ser resucitado, asegurando que la lógica de limpieza opere sobre un objeto irrevocablemente inalcanzable.
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); }
Nuestro equipo gestionó un pipeline de procesamiento de imágenes de alto rendimiento donde los objetos ImageProcessor envolvían los búferes nativos de OpenCV asignados a través de JNI. Inicialmente, confiamos en finalize() para invocar cvReleaseImage(), pero ocasionalmente encontramos agotamiento de memoria nativa a pesar de la estabilidad del montón de Java, acompañada de fallos de segmentación intermitentes que sugerían errores de uso después de liberar en la memoria nativa.
El primer enfoque considerado fue mantener finalize() pero agregar una guardia de resurrección donde sincronizáramos en un mapa estático para rastrear objetos "muertos". Esto sufrió de latencias impredecibles: la finalización podría ocurrir minutos después de que comenzara la presión del montón, y la lógica de la guardia en sí misma creó fugas de memoria al mantener referencias fuertes a objetos "muertos" en el mapa de seguimiento, irónicamente impidiendo su recolección por completo mientras aún permitía condiciones de carrera de resurrección.
El segundo enfoque involucró usar java.lang.ref.Cleaner ingenuamente capturando el puntero nativo en una lambda dentro del constructor de la instancia: cleaner.register(this, () -> free(pointer)). Si bien esto evitó retrasos de finalización, corría el riesgo de recolección prematura si this escapaba durante la construcción, y críticamente, si la lambda cerraba accidentalmente sobre la instancia ImageProcessor en lugar de solo el valor pointer, se crearía un ciclo de referencia fuerte que impediría la recolección de basura del referente, aunque aún impidiendo la resurrección en sí.
Elegimos el tercer enfoque: implementar una clase estática anidada CleaningAction que solo contuviera el puntero nativo (como un long) y que implementara Runnable, completamente desacoplada de la instancia externa ImageProcessor. Registramos la acción de limpieza inmediatamente después de la asignación nativa exitosa y invoqué explícitamente Reference.reachabilityFence(this) al final del constructor para asegurar que el objeto permaneciera accesible hasta que se completara el registro. Esto eliminó los riesgos de resurrección y fugas nativas, reduciendo los incidentes de presión de memoria de ocurrencias diarias a cero durante seis meses.
¿Por qué el Cleaner usa PhantomReference en lugar de WeakReference o SoftReference, y cómo evita esto que la acción de limpieza acceda al estado del referente?
PhantomReference se emplea porque permite que el recolector de basura identifique objetos reclamables sin permitir que el código del programa acceda a los campos o métodos del referente después de que entra en el estado de accesibilidad fantasma. A diferencia de WeakReference, que permite recuperar el referente a través de get() hasta que se recolecta, PhantomReference siempre devuelve null de get(), asegurando que la acción de limpieza opere con la suposición de que el objeto está lógicamente destruido y evitando cualquier intento de resurrección o inspección del estado que podría violar invarianzas post-mortem.
¿Qué es el ataque de "resurrección" en el contexto de Object.finalize(), y por qué viola las garantías de seguridad del sistema de tipos?
La resurrección ocurre cuando el método finalize() almacena la referencia this en un campo estático o gráfico de objetos activos, haciendo que el objeto sea accesible nuevamente después de que el recolector de basura lo marcó para reclamación. Esto viola la invariante de que el constructor de un objeto se ejecuta exactamente una vez y su finalizador se ejecuta como máximo una vez, permitiendo que un código malicioso o defectuoso observe un objeto en un estado parcialmente destruido donde los recursos nativos se liberan pero los campos de Java aún son accesibles, lo que lleva a vulnerabilidades de uso después de liberar y comportamiento inconsistente del objeto.
¿Cómo interactúa Reference.reachabilityFence con las optimizaciones de compilación just-in-time de la JVM, y cuándo es estrictamente necesario usarlo con los registros de Cleaner?
Reference.reachabilityFence actúa como una barrera de compilador que impide que el optimizador de la JVM vuelva a ordenar o eliminar referencias de objetos antes de que se complete una sección crítica, previniendo específicamente la "publicación temprana" donde un objeto se vuelve inalcanzable mientras su constructor aún se está ejecutando. Es estrictamente necesario al registrar objetos con Cleaner durante la construcción porque, sin ello, la JVM podría determinar que this ya no es necesario después de la asignación del recurso nativo pero antes de la llamada de registro, permitiendo que el limpiador se ejecute y libere el recurso mientras el constructor continúa inicializando el objeto, lo que lleva a la corrupción de recursos.