Cómo manejar los errores de Java y la limpieza sin finalizar

El método de finalización de Java quedará obsoleto en Java 18 y se eliminará por completo en una versión futura. Veamos las alternativas.

Después de varios años de rumores, Java se está preparando para desaprobar el 

finalize
método en JDK 18 . Esto está cubierto por JDK Enhancement Proposal 421 , que marcará Finalize como obsoleto y permitirá que se apague para realizar pruebas. Permanecerá habilitado por defecto. Se eliminará por completo en una versión futura. En esta ocasión, echemos un vistazo a lo que significa el fin de finalizar y cómo debemos manejar los errores y la limpieza de recursos ahora.

¿Qué es finalizar?

Antes de que entendamos por qué finalize va a desaparecer y qué usar en su lugar, entendamos qué es o era finalize.

La idea básica es permitirle definir un método en sus objetos que se ejecutará cuando el objeto esté listo para la recolección de elementos no utilizados. Técnicamente, un objeto está listo para la recolección de basura cuando se vuelve accesible fantasma , lo que significa que no quedan referencias fuertes o débiles en la JVM. 

En ese momento, la idea es que la JVM ejecute el 

object.finalize()
método y el código específico de la aplicación limpiará todos los recursos, como los flujos de E/S o los identificadores de los almacenes de datos.https://imasdk.googleapis.com/js/core/bridge3.496.0_en.html#goog_5503966211 second of 27 seconds Volumen 0% 

La clase de objeto raíz en Java tiene un 

finalize()
método, junto con otros métodos como 
equals()
hashCode()
. Esto es para permitir que cada objeto en cada programa Java jamás escrito participe en este sencillo mecanismo para evitar fugas de recursos. 

Tenga en cuenta que esto también aborda los casos en los que se lanza una excepción y se puede perder otro código de limpieza: el objeto aún se marcará para la recolección de basura y eventualmente se llamará a su método de finalización. Problema resuelto, ¿verdad? Simplemente anule el 

finalize()
método en sus objetos que consumen recursos.

Los problemas con finalizar

Esa es la idea, pero la realidad es algo completamente diferente. Hay una serie de deficiencias con finalizar que impiden la realización de la utopía de limpieza. (Esta situación es similar a 

serialize()
, otro método que se veía bien en el papel pero se volvió problemático en la práctica).

Entre los problemas con finalizar:

  • Finalize puede ejecutarse de formas inesperadas . A veces, el GC determinará que su objeto no tiene referencias activas antes de que crea que las tendrá. Eche un vistazo a esta respuesta de desbordamiento de pila bastante aterradora .
  • Es posible que Finalize nunca se ejecute o se ejecute después de un largo retraso . En el otro extremo del espectro, es posible que su método de finalización nunca se ejecute. Como establece JEP 421 RFC, “GC generalmente opera solo cuando es necesario para satisfacer las solicitudes de asignación de memoria”. Entonces estás a merced del capricho de GC.
  • Finalize puede resucitar clases muertas . A veces, un objeto activará una excepción que lo hará elegible para GC. Sin embargo, el 
    finalize()
    método tiene la oportunidad de ejecutarse primero, y ese método podría hacer cualquier cosa , incluso restablecer referencias en vivo al objeto. Esta es una fuente potencial de fugas y un peligro para la seguridad.
  • Finalizar es difícil de implementar correctamente . Simplemente escribir directamente un método de finalización sólido que sea funcional y libre de errores no es tan fácil como parece. En particular, no hay garantías sobre las implicaciones de subprocesamiento de finalizar. Los finalizadores pueden ejecutarse en cualquier hilo, introduciendo condiciones de error que son muy difíciles de depurar. Olvidarse de llamar 
    finalize()
    puede conducir a problemas difíciles de descubrir.
  • rendimiento _ Dada la falta de confiabilidad de finalizar para cumplir con su propósito declarado, no se justifica la sobrecarga de la JVM para admitirlo.
  • Finalize hace que las aplicaciones a gran escala sean más frágiles . La conclusión, tal como se desprende de la investigación, es que es más probable que el software a gran escala que utiliza finalizar sea frágil y encuentre condiciones de error difíciles de reproducir que surgen bajo cargas pesadas.

La vida después de finalizar

¿Cuáles son las formas adecuadas de manejar los errores y la limpieza ahora? Veremos tres alternativas aquí: bloques try-catch-finally, sentencias try-with-resource y limpiadores. Cada uno tiene sus pros y sus contras.

Bloques Try-catch-finally

La forma antigua de manejar la liberación de recursos es a través de bloques try-catch. Esto es factible en muchas situaciones, pero adolece de ser propenso a errores y detallado. Por ejemplo, para capturar completamente las condiciones de error anidadas (es decir, cuando se cierra el recurso también se genera una excepción), necesita algo como el Listado 1. 

Puede parecer una exageración, pero en un sistema de larga duración y muy utilizado, este tipo de condiciones pueden generar fugas de recursos que matarán una aplicación. Por lo tanto, lamentablemente, la verbosidad debe repetirse en todo el código base. Estas cosas son notorias por romper el flujo de código.

Listado 1. Manejo del cierre de recursos con try-catch-finally

FileOutputStream outStream = nulo ; intente {   outStream = new FileOutputStream ( "salida.archivo" ); flujo de ObjectOutputStream = nuevo ObjectOutputStream ( outStream );   corriente _ escribir //…   corriente . cerrar (); } catch ( FileNotFoundException ffe ) { throw new RuntimeException ( "No se pudo abrir el archivo para escribir" , ffee 


 



  );
} catch ( IOException ioe ) { System . error _ println ( "Error al escribir en el archivo" ); } finalmente { if ( outStream != null ) { try {       outStream . cerrar (); } catch ( Excepción e ) { System . error _ println (" Error al cerrar el flujo ", e ); }
 

 
   

   
     
   
  }
}

Todo lo que quiere hacer en el Listado 1 es abrir una secuencia, escribirle algunos bytes y asegurarse de que se cierre, independientemente de las excepciones que se generen. Para hacer esto, debe envolver las llamadas en un bloque de prueba y, si se generan excepciones verificadas, tratarlas (ya sea generando una excepción en tiempo de ejecución envuelta o imprimiendo la excepción en el registro). 

Luego, debe agregar un bloque finalmente que verifique dos veces la transmisión. Esto es para garantizar que una excepción no impidió el cierre. Pero no puedes simplemente cerrar la transmisión; tiene que envolverlo en otro bloque de prueba para asegurarse de que el cierre no se equivoque.

Eso es mucho trabajo e interrupción para una necesidad simple y común.

Declaraciones de prueba con recursos

Introducida en Java 7, una declaración try-with-resource le permite especificar uno o más objetos de recursos como parte de la declaración try. Se garantiza que estos recursos se cerrarán cuando se complete el bloque de prueba.

Específicamente, cualquier clase que implemente java.lang.AutoCloseable se puede proporcionar a try-with-resource. Eso cubre casi todos los recursos de uso común que encontrará en el ecosistema de Java.

Reescribamos el Listado 1 para hacer uso de una declaración de prueba con recursos, como se ve en el Listado 2.

Listado 2. Manejo del cierre de recursos con try-with-resource

pruebe ( FileOutputStream outStream = new ObjectOutputStream ( outStream )) { ObjectOutputStream stream = new ObjectOutputStream ( outStream );   corriente _ escribir //…   corriente . cerrar (); } catch ( FileNotFoundException ffee ) { throw new RuntimeException ( "No se pudo abrir el archivo para escribir" , ffee ); } atrapar (    
 



 
IOException ioe ) { Sistema . error _ println ( "Error al escribir en el archivo" ); }
 

Puede ver que hay una serie de beneficios aquí que dan como resultado una huella de código más pequeña, pero la mayor sutileza es que una vez que entrega la transmisión (o lo que sea que esté usando) a la máquina virtual al declararla dentro del paréntesis del bloque de prueba, usted no tienes que preocuparte más por eso. Estará cerrado para ti. Sin fugas de recursos.

Eliminamos la necesidad de un bloque finalmente o cualquier llamada para finalizar. Eso soluciona el problema principal para la mayoría de los casos de uso (aunque se mantiene la verbosidad del manejo de errores verificados). 

Hay algunas situaciones en las que se requiere una solución más elaborada y robusta, cuando las cosas son demasiado complejas para manejarlas en un solo bloque como este. Para esas situaciones, el desarrollador de Java necesita algo más potente. Para esas situaciones, necesitas un limpiador.

Limpiadores

La clase Cleaner se introdujo en Java 9. Los limpiadores le permiten definir acciones de limpieza para grupos de referencias. Los limpiadores producen una implementación Cleanable, cuya interfaz desciende de Runnable. Cada Cleanable se ejecuta en un subproceso dedicado que ignora las excepciones. 

La idea aquí es desacoplar la rutina de limpieza del código que usa los objetos que requieren limpieza. Hagamos esto más concreto con el ejemplo que Oracle proporciona en la documentación, que se muestra en el Listado 3.ANUNCIO PUBLICITARIO

 

Listado 3. Ejemplo de limpiador simple

public class EjemploDeLimpieza implementa AutoCloseable { // Un limpiador, preferiblemente uno compartido dentro de una biblioteca private static final Cleaner cleaner = <cleaner> ; static class State implements Runnable { State (...) { // inicializa el estado necesario para la acción de limpieza } public void run () { // acción de limpieza accediendo al estado, ejecutada como máximo una vez } } private final State ; privado     
 
 
 
   
     
   
   
      
   
 
 
  Limpiador final . Cleanable cleanable public Ejemplo de limpieza () { this . estado = nuevo Estado (...); esto _ limpiable = limpiador . registrarse ( este , estado ); } public void close () {     limpiable . limpio (); } }
 
   
   
 
 

 

Para empezar, y quizás lo más importante, puede invocar explícitamente el 

close()
método para limpiar sus referencias. Esto es distinto de 
finalize()
, que depende totalmente de la llamada (indeterminada) del recolector de elementos no utilizados.

Si la 

close()
llamada no se hace explícitamente, el sistema la ejecutará cuando el objeto pasado como el primer argumento de 
cleaner.register()
se vuelva accesible fantasma. Sin embargo, el sistema no llamará 
close()
si usted, el desarrollador, ya lo ha ejecutado explícitamente.

(Observe que el ejemplo de código en el Listado 3 produce un objeto AutoCloseable. Eso significa que se puede pasar al argumento de una declaración de prueba con recursos).

Ahora una advertencia: no cree referencias a los objetos limpiados en el método de ejecución de su limpiador, porque esto creará potencialmente un objeto zombi (es decir, restablecerá el objeto como vivo). Es poco probable que esto suceda en el formato de ejemplo proporcionado, pero es más probable si lo implementa como una lambda (que tiene acceso a su alcance adjunto).ANUNCIO PUBLICITARIO

 

A continuación, considere el comentario: “Un limpiador, preferiblemente uno compartido dentro de una biblioteca”. ¿Porqué es eso? Es porque cada limpiador generará un hilo, por lo que compartir limpiadores resultará en una menor sobrecarga para el programa en ejecución.

También en InfoWorld: JDK 18: novedades de Java 18 ]

Finalmente (juego de palabras intencionado), observe que el objeto que se supervisa está desacoplado del código (en el ejemplo, el Estado) que realiza el trabajo de limpieza.ANUNCIO PUBLICITARIO

 

Para una inmersión más profunda en los limpiadores, consulte este artículo . Proporciona una buena perspectiva, en particular con respecto a qué casos de uso merecen su uso (en este caso, para deshacerse de recursos nativos costosos).

Adiós, finaliza

Java sigue evolucionando. Esa es una buena noticia para aquellos de nosotros que lo amamos y lo usamos. La desaprobación 

finalize()
y la adición de nuevos enfoques son buenas señales de este compromiso con el futuro.