Tutorial avanzado de clases de Java: una guía para recargar clases

Publicado: 2022-03-11

En los proyectos de desarrollo de Java, un flujo de trabajo típico implica reiniciar el servidor con cada cambio de clase y nadie se queja al respecto. Eso es un hecho sobre el desarrollo de Java. Hemos trabajado así desde nuestro primer día con Java. Pero, ¿la recarga de la clase Java es tan difícil de lograr? ¿Y podría ser ese problema desafiante y emocionante de resolver para los desarrolladores de Java expertos? En este tutorial de clases de Java, intentaré abordar el problema, ayudarlo a obtener todos los beneficios de la recarga de clases sobre la marcha y aumentar enormemente su productividad.

La recarga de clases de Java no se discute a menudo, y hay muy poca documentación que explore este proceso. Estoy aquí para cambiar eso. Este tutorial de clases de Java proporcionará una explicación paso a paso de este proceso y lo ayudará a dominar esta increíble técnica. Tenga en cuenta que implementar la recarga de clases de Java requiere mucho cuidado, pero aprender a hacerlo lo colocará en las grandes ligas, tanto como desarrollador de Java como arquitecto de software. Tampoco estará de más entender cómo evitar los 10 errores de Java más comunes.

Configuración del espacio de trabajo

Todo el código fuente de este tutorial está cargado en GitHub aquí.

Para ejecutar el código mientras sigue este tutorial, necesitará Maven, Git y Eclipse o IntelliJ IDEA.

Si está utilizando Eclipse:

  • Ejecute el comando mvn eclipse:eclipse para generar los archivos de proyecto de Eclipse.
  • Cargue el proyecto generado.
  • Establezca la ruta de salida en target/classes .

Si está utilizando IntelliJ:

  • Importe el archivo pom del proyecto.
  • IntelliJ no se compilará automáticamente cuando esté ejecutando cualquier ejemplo, por lo que debe:
  • Ejecute los ejemplos dentro de IntelliJ, luego cada vez que quiera compilar, tendrá que presionar Alt+BE
  • Ejecute los ejemplos fuera de IntelliJ con run_example*.bat . Establezca la compilación automática del compilador de IntelliJ en verdadero. Luego, cada vez que cambie cualquier archivo java, IntelliJ lo compilará automáticamente.

Ejemplo 1: recargar una clase con Java Class Loader

El primer ejemplo le dará una comprensión general del cargador de clases de Java. Aquí está el código fuente.

Dada la siguiente definición de clase User :

 public static class User { public static int age = 10; }

Podemos hacer lo siguiente:

 public static void main(String[] args) { Class<?> userClass1 = User.class; Class<?> userClass2 = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example1.StaticInt$User"); ...

En este ejemplo de tutorial, habrá dos clases User cargadas en la memoria. userClass1 será cargado por el cargador de clases predeterminado de JVM y userClass2 usando DynamicClassLoader , un cargador de clases personalizado cuyo código fuente también se proporciona en el proyecto GitHub, y que describiré en detalle a continuación.

Aquí está el resto del método main :

 out.println("Seems to be the same class:"); out.println(userClass1.getName()); out.println(userClass2.getName()); out.println(); out.println("But why there are 2 different class loaders:"); out.println(userClass1.getClassLoader()); out.println(userClass2.getClassLoader()); out.println(); User.age = 11; out.println("And different age values:"); out.println((int) ReflectUtil.getStaticFieldValue("age", userClass1)); out.println((int) ReflectUtil.getStaticFieldValue("age", userClass2)); }

Y la salida:

 Seems to be the same class: qj.blog.classreloading.example1.StaticInt$User qj.blog.classreloading.example1.StaticInt$User But why there are 2 different class loaders: qj.util.lang.DynamicClassLoader@3941a79c sun.misc.Launcher$AppClassLoader@1f32e575 And different age values: 11 10

Como puede ver aquí, aunque las clases User tienen el mismo nombre, en realidad son dos clases diferentes y se pueden administrar y manipular de forma independiente. El valor de edad, aunque declarado como estático, existe en dos versiones, adjuntando por separado a cada clase, y también se puede cambiar de forma independiente.

En un programa Java normal, ClassLoader es el portal que trae clases a la JVM. Cuando una clase requiere que se cargue otra clase, la tarea de ClassLoader es realizar la carga.

Sin embargo, en este ejemplo de clase Java, el ClassLoader personalizado llamado DynamicClassLoader se usa para cargar la segunda versión de la clase User . Si en lugar de DynamicClassLoader , tuviéramos que usar el cargador de clases predeterminado nuevamente (con el comando StaticInt.class.getClassLoader() ), entonces se usará la misma clase User , ya que todas las clases cargadas se almacenan en caché.

Examinar la forma en que funciona el Java ClassLoader predeterminado en comparación con DynamicClassLoader es clave para beneficiarse de este tutorial de clases de Java.

El DynamicClassLoader

Puede haber varios cargadores de clases en un programa Java normal. El que carga su clase principal, ClassLoader , es el predeterminado y, desde su código, puede crear y usar tantos cargadores de clases como desee. Esta, entonces, es la clave para la recarga de clases en Java. El DynamicClassLoader es posiblemente la parte más importante de todo este tutorial, por lo que debemos entender cómo funciona la carga dinámica de clases antes de que podamos lograr nuestro objetivo.

A diferencia del comportamiento predeterminado de ClassLoader , nuestro DynamicClassLoader hereda una estrategia más agresiva. Un cargador de clases normal le daría a su ClassLoader principal la prioridad y solo cargaría las clases que su padre no puede cargar. Eso es adecuado para circunstancias normales, pero no en nuestro caso. En su lugar, DynamicClassLoader intentará buscar en todas sus rutas de clase y resolver la clase de destino antes de ceder el derecho a su padre.

En nuestro ejemplo anterior, DynamicClassLoader se crea con solo una ruta de clase: "target/classes" (en nuestro directorio actual), por lo que es capaz de cargar todas las clases que residen en esa ubicación. Para todas las clases que no están allí, tendrá que hacer referencia al cargador de clases principal. Por ejemplo, necesitamos cargar la clase String en nuestra clase StaticInt , y nuestro cargador de clases no tiene acceso a rt.jar en nuestra carpeta JRE, por lo que se usará la clase String del cargador de clases principal.

El siguiente código es de AggressiveClassLoader , la clase principal de DynamicClassLoader , y muestra dónde se define este comportamiento.

 byte[] newClassData = loadNewClass(name); if (newClassData != null) { loadedClasses.add(name); return loadClass(newClassData, name); } else { unavaiClasses.add(name); return parent.loadClass(name); }

Tome nota de las siguientes propiedades de DynamicClassLoader :

  • Las clases cargadas tienen el mismo rendimiento y otros atributos que otras clases cargadas por el cargador de clases predeterminado.
  • DynamicClassLoader se puede recolectar basura junto con todas sus clases y objetos cargados.

Con la capacidad de cargar y usar dos versiones de la misma clase, ahora estamos pensando en deshacernos de la versión anterior y cargar la nueva para reemplazarla. En el siguiente ejemplo, haremos precisamente eso... continuamente.

Ejemplo 2: recargar una clase continuamente

El siguiente ejemplo de Java le mostrará que JRE puede cargar y recargar clases para siempre, con clases antiguas volcadas y basura recolectada, y clases nuevas cargadas desde el disco duro y puestas en uso. Aquí está el código fuente.

Aquí está el bucle principal:

 public static void main(String[] args) { for (;;) { Class<?> userClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example2.ReloadingContinuously$User"); ReflectUtil.invokeStatic("hobby", userClass); ThreadUtil.sleep(2000); } }

Cada dos segundos, la clase User anterior se volcará, se cargará una nueva y se invocará su hobby de método.

Aquí está la definición de la clase User :

 @SuppressWarnings("UnusedDeclaration") public static class User { public static void hobby() { playFootball(); // will comment during runtime // playBasketball(); // will uncomment during runtime } // will comment during runtime public static void playFootball() { System.out.println("Play Football"); } // will uncomment during runtime // public static void playBasketball() { // System.out.println("Play Basketball"); // } }

Al ejecutar esta aplicación, debe intentar comentar y descomentar el código indicado en la clase User . Verá que siempre se utilizará la definición más reciente.

Aquí hay una salida de ejemplo:

 ... Play Football Play Football Play Football Play Basketball Play Basketball Play Basketball

Cada vez que se crea una nueva instancia de DynamicClassLoader , cargará la clase de User desde la carpeta target/classes , donde hemos configurado Eclipse o IntelliJ para generar el último archivo de clase. Todas las clases antiguas de DynamicClassLoader y User se desvincularán y se someterán al recolector de elementos no utilizados.

Es fundamental que los desarrolladores avanzados de Java comprendan la recarga dinámica de clases, ya sea activa o no vinculada.

Si está familiarizado con JVM HotSpot, cabe destacar aquí que la estructura de clases también se puede cambiar y volver a cargar: el método playFootball se eliminará y se agregará el método playBasketball . Esto es diferente a HotSpot, que solo permite cambiar el contenido del método, o la clase no se puede volver a cargar.

Ahora que somos capaces de recargar una clase, es hora de intentar recargar muchas clases a la vez. Probémoslo en el siguiente ejemplo.

Ejemplo 3: recargar varias clases

El resultado de este ejemplo será el mismo que el del Ejemplo 2, pero mostrará cómo implementar este comportamiento en una estructura más parecida a una aplicación con objetos de contexto, servicio y modelo. El código fuente de este ejemplo es bastante grande, por lo que solo he mostrado partes de él aquí. El código fuente completo está aquí.

Aquí está el método main :

 public static void main(String[] args) { for (;;) { Object context = createContext(); invokeHobbyService(context); ThreadUtil.sleep(2000); } }

Y el método createContext :

 private static Object createContext() { Class<?> contextClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example3.ContextReloading$Context"); Object context = newInstance(contextClass); invoke("init", context); return context; }

El método invokeHobbyService :

 private static void invokeHobbyService(Object context) { Object hobbyService = getFieldValue("hobbyService", context); invoke("hobby", hobbyService); }

Y aquí está la clase Context :

 public static class Context { public HobbyService hobbyService = new HobbyService(); public void init() { // Init your services here hobbyService.user = new User(); } }

Y la clase HobbyService :

 public static class HobbyService { public User user; public void hobby() { user.hobby(); } }

La clase Context en este ejemplo es mucho más complicada que la clase User en los ejemplos anteriores: tiene enlaces a otras clases y tiene el método init para ser llamado cada vez que se instancia. Básicamente, es muy similar a las clases de contexto de la aplicación del mundo real (que realiza un seguimiento de los módulos de la aplicación y realiza la inyección de dependencia). Por lo tanto, poder volver a cargar esta clase Context junto con todas sus clases vinculadas es un gran paso para aplicar esta técnica a la vida real.

La recarga de clases de Java es difícil incluso para los ingenieros avanzados de Java.

A medida que crece la cantidad de clases y objetos, nuestro paso de "eliminar versiones antiguas" también se volverá más complicado. Esta es también la principal razón por la que la recarga de clase es tan difícil. Para descartar posiblemente versiones antiguas, tendremos que asegurarnos de que, una vez que se crea el nuevo contexto, se eliminen todas las referencias a las clases y objetos antiguos. ¿Cómo lidiamos con esto con elegancia?

El método main aquí controlará el objeto de contexto, y ese es el único enlace a todas las cosas que deben eliminarse. Si rompemos ese enlace, el objeto de contexto y la clase de contexto, y el objeto de servicio... estarán todos sujetos al recolector de basura.

Una pequeña explicación sobre por qué normalmente las clases son tan persistentes y no se recolecta basura:

  • Normalmente, cargamos todas nuestras clases en el cargador de clases Java predeterminado.
  • La relación clase-cargador de clases es una relación bidireccional, con el cargador de clases también almacenando en caché todas las clases que ha cargado.
  • Entonces, mientras el cargador de clases aún esté conectado a cualquier subproceso en vivo, todo (todas las clases cargadas) será inmune al recolector de basura.
  • Dicho esto, a menos que podamos separar el código que queremos recargar del código ya cargado por el cargador de clases predeterminado, nuestros nuevos cambios de código nunca se aplicarán durante el tiempo de ejecución.

Con este ejemplo, vemos que recargar todas las clases de la aplicación es bastante fácil. El objetivo es simplemente mantener una conexión delgada y desplegable desde el subproceso en vivo hasta el cargador de clases dinámicas en uso. Pero, ¿y si queremos que algunos objetos (y sus clases) no se recarguen y se reutilicen entre ciclos de recarga? Veamos el siguiente ejemplo.

Ejemplo 4: separación de espacios de clase persistentes y recargados

Aquí está el código fuente..

El método main :

 public static void main(String[] args) { ConnectionPool pool = new ConnectionPool(); for (;;) { Object context = createContext(pool); invokeService(context); ThreadUtil.sleep(2000); } }

Entonces puede ver que el truco aquí es cargar la clase ConnectionPool e instanciarla fuera del ciclo de recarga, mantenerla en el espacio persistente y pasar la referencia a los objetos de Context .

El método createContext también es un poco diferente:

 private static Object createContext(ConnectionPool pool) { ExceptingClassLoader classLoader = new ExceptingClassLoader( (className) -> className.contains(".crossing."), "target/classes"); Class<?> contextClass = classLoader.load("qj.blog.classreloading.example4.reloadable.Context"); Object context = newInstance(contextClass); setFieldValue(pool, "pool", context); invoke("init", context); return context; }

De ahora en adelante, llamaremos a los objetos y clases que se recargan con cada ciclo el “espacio recargable” ya otros - los objetos y clases no reciclados y no renovados durante los ciclos de recarga - el “espacio persistente”. Tendremos que tener muy claro qué objetos o clases quedan en qué espacio, trazando así una línea de separación entre estos dos espacios.

A menos que se maneje correctamente, esta separación de la carga de clases de Java puede provocar errores.

Como se ve en la imagen, no solo el objeto Context y el objeto UserService se refieren al objeto ConnectionPool , sino que las clases Context y UserService también se refieren a la clase ConnectionPool . Esta es una situación muy peligrosa que a menudo conduce a la confusión y al fracaso. La clase ConnectionPool no debe ser cargada por nuestro DynamicClassLoader , solo debe haber una clase ConnectionPool en la memoria, que es la cargada por el ClassLoader predeterminado. Este es un ejemplo de por qué es tan importante tener cuidado al diseñar una arquitectura de recarga de clases en Java.

¿Qué pasa si nuestro DynamicClassLoader carga accidentalmente la clase ConnectionPool ? Entonces, el objeto ConnectionPool del espacio persistente no se puede pasar al objeto Context , porque el objeto Context está esperando un objeto de una clase diferente, que también se llama ConnectionPool , ¡pero en realidad es una clase diferente!

Entonces, ¿cómo evitamos que nuestro DynamicClassLoader cargue la clase ConnectionPool ? En lugar de usar DynamicClassLoader , este ejemplo usa una subclase llamada: ExceptingClassLoader , que pasará la carga a superclassloader en función de una función de condición:

 (className) -> className.contains("$Connection")

Si no usamos ExceptingClassLoader aquí, DynamicClassLoader cargaría la clase ConnectionPool porque esa clase reside en la carpeta " target/classes ". Otra forma de evitar que DynamicClassLoader seleccione la clase ConnectionPool es compilar la clase ConnectionPool en una carpeta diferente, tal vez en un módulo diferente, y se compilará por separado.

Reglas para elegir el espacio

Ahora, el trabajo de carga de clases de Java se vuelve realmente confuso. ¿Cómo determinamos qué clases deben estar en el espacio persistente y qué clases en el espacio recargable? Estas son las reglas:

  1. Una clase en el espacio recargable puede hacer referencia a una clase en el espacio persistente, pero una clase en el espacio persistente nunca puede hacer referencia a una clase en el espacio recargable. En el ejemplo anterior, la clase Context recargable hace referencia a la clase ConnectionPool persistente, pero ConnectionPool no hace referencia a Context
  2. Una clase puede existir en cualquier espacio si no hace referencia a ninguna clase en el otro espacio. Por ejemplo, una clase de utilidad con todos los métodos estáticos como StringUtils se puede cargar una vez en el espacio persistente y cargar por separado en el espacio recargable.

Entonces puedes ver que las reglas no son muy restrictivas. A excepción de las clases de cruce que tienen objetos a los que se hace referencia en los dos espacios, todas las demás clases se pueden usar libremente en el espacio persistente o en el espacio recargable o en ambos. Por supuesto, solo las clases en el espacio recargable disfrutarán de ser recargadas con ciclos de recarga.

Así que se soluciona el problema más desafiante con la recarga de clase. En el siguiente ejemplo, intentaremos aplicar esta técnica a una aplicación web simple y disfrutar recargando clases de Java como cualquier otro lenguaje de programación.

Ejemplo 5: Pequeña guía telefónica

Aquí está el código fuente..

Este ejemplo será muy similar al aspecto que debería tener una aplicación web normal. Es una aplicación de una sola página con AngularJS, SQLite, Maven y Jetty Embedded Web Server.

Aquí está el espacio recargable en la estructura del servidor web:

Una comprensión profunda del espacio recargable en la estructura del servidor web lo ayudará a dominar la carga de clases de Java.

El servidor web no tendrá referencias a los servlets reales, que deben permanecer en el espacio recargable para poder recargarse. Lo que contiene son servlets auxiliares que, con cada llamada a su método de servicio, resolverán el servlet real en el contexto real para ejecutarse.

Este ejemplo también presenta un nuevo objeto ReloadingWebContext , que proporciona al servidor web todos los valores como un contexto normal, pero internamente contiene referencias a un objeto de contexto real que DynamicClassLoader puede recargar. Es este ReloadingWebContext el que proporciona servlets auxiliares al servidor web.

ReloadingWebContext maneja los servlets auxiliares para el servidor web en el proceso de recarga de la clase Java.

El ReloadingWebContext será el envoltorio del contexto real y:

  • Volverá a cargar el contexto real cuando se llame a HTTP GET a "/".
  • Proporcionará servlets de código auxiliar al servidor web.
  • Establecerá valores e invocará métodos cada vez que se inicialice o destruya el contexto real.
  • Se puede configurar para recargar el contexto o no, y qué cargador de clases se usa para recargar. Esto ayudará al ejecutar la aplicación en producción.

Debido a que es muy importante comprender cómo aislamos el espacio persistente y el espacio recargable, estas son las dos clases que se cruzan entre los dos espacios:

Clase qj.util.funct.F0 para objeto public F0<Connection> connF en Context

  • Objeto de función, devolverá una conexión cada vez que se invoque la función. Esta clase reside en el paquete qj.util, que está excluido de DynamicClassLoader .

Clase java.sql.Connection para objeto public F0<Connection> connF en Context

  • Objeto de conexión SQL normal. Esta clase no reside en la ruta de clase de nuestro DynamicClassLoader , por lo que no se recogerá.

Resumen

En este tutorial de clases de Java, hemos visto cómo recargar una sola clase, recargar una sola clase continuamente, recargar un espacio completo de múltiples clases y recargar múltiples clases por separado de las clases que deben persistir. Con estas herramientas, el factor clave para lograr una recarga de clase confiable es tener un diseño súper limpio. Luego puede manipular libremente sus clases y toda la JVM.

Implementar la recarga de clases Java no es la cosa más fácil del mundo. Pero si le das una oportunidad y en algún momento encuentras que tus clases se cargan sobre la marcha, entonces ya casi estás allí. Quedará muy poco por hacer antes de que pueda lograr un diseño limpio totalmente excelente para su sistema.

¡Buena suerte mis amigos y disfruten de su nuevo superpoder!