Skip to content

Colecciones inmutables en Java

Hubo un tiempo en que parecía que los objetos inmutables iban a salvar el mundo. En particular cuando los gurús los pusieron de moda introduciendo la programación funcional a las masas. Hoy en día sigue siendo un patrón totalmente válido y que debería utilizarse más a menudo de lo que normalmente se hace.

En algunos lenguajes todo son objetos inmutables. La idea es que una vez que se crea un objeto éste no se puede modificar. De esta manera el objeto se puede pasar de forma segura entre distintos componentes de un sistema. Un objeto que no expone ningún mecanismo para cambiar su estado interno no se puede modificar por ningún componente que consuma dicho objeto. Los componentes que actúan como clientes pueden llamar a componentes que actúan como servidores sin preocuparse de que los objetos pasados se modifiquen.

De forma muy simplista puede visualizarse como una clase POJO (Plain Old Java Object) en la que todos los atributos son final, inicializados en el constructor, y que por tanto carece de mutators (setters). En la práctica se podría incluso prescindir de los accesors (getters) y hacer los atributos public, pero en algunos entornos esto es motivo de excomunión.

Los compiladores y máquinas virtuales pueden aplicar optimizaciones cuando se usan objetos inmutables. Un objeto en memoria no es más que una secuencia de bytes. Si se garantiza que dicha secuencia no puede cambiar, entonces es seguro pasar una referencia (puntero) a dicha secuencia a lo largo y ancho de todo un sistema. No hay que preocuparse de hacer copias o sincronizar el acceso en entornos multhilos. Y es más, si se crean varios objetos iguales entonces es seguro utilizar la misma referencia para todos ellos.

En Java los objetos inmutables han existido desde el principio, por ejemplo las instancias de las clase String son objetos inmutables. Cuando se modifica una instancia lo que se hace en realidad es crear una nueva instancia. La instancia no cambia su estado, sino que se crea una nueva instancia con un nuevo estado. Y de esta forma se eliminan los efectos laterales debidos a los cambios de estado de los objetos.

Un caso de uso habitual en el desarrollo de casi cualquier sistema es procesar una colección de objetos. Se pasa una colección a un método, y se presupone que la colección original pasada no es modificada por el método llamado. Sin embargo esta suposición es difícil de garantizar en la práctica. Se puede establecer en el contrato de la interface, pero debe ser finalmente la implementación la que asegure el cumplimiento de dicho contrato.

En Java las colecciones inmutables se han creado tradicionalmente a través de los métodos estáticos de la clase Collections, como  emptyList, para crear listas vacías, y  unmodifiableList, que admite una lista como parámetro y retorna una copia inmutable de dicha lista:

En el segundo caso, si la lista original pasada como parámetro se modifica, la lista inmutable no se ve afectada. Si se intenta añadir, modificar o borrar sobre la lista inmutable se eleva una excepción en tiempo de ejecución. Si la lista es de objetos inmutables entonces toda la lista es inmutable. Si se modifica un objeto de la lista original  parecerá que la lista inmutable se modifica también, pero en realidad lo que se modifica es el objeto. La lista inmutable sólo almacena referencias (punteros) a los objetos que contenía la lista original en el momento de su creación.

La clase Collections contiene métodos adicionales para la creación de otros tipos de colecciones inmutables, como Map y Set. Pero Java en si mismo carece de una sintaxis que permita crear colecciones inline de manera sencilla.

Crear arrays es sencillo:

Pero crear listas es sencillo sólo si se conoce el API, ¡el método está en la clase Arrays!:

Java 9 añadió un nuevo método estático of sobrecargado dentro de las interfaces List, Set y Map para facilitar la creación de colecciones inmutables:

Teniendo en cuenta que estas factorías no admiten nulos, ni duplicados en el caso concreto de Set y Map, ni claves nulas en el Map, y que dos llamadas distintas pueden retornar la misma referencia si las colecciones creadas son iguales.

Por su parte Java 10 añadió un nuevo método estático copyOf dentro de las interfaces List, Set y Map para facilitar la creación de colecciones inmutables a partir de una colección dada:

Y métodos en Collectors para facilitar la creación de colecciones inmutables a partir de un stream:

Be inmutable, my friend.

Multi-Release JAR Files

Si se está desarrollando una librería en la versión 8 de Java, no se puede empezar a utilizar las novedades introducidas en las versiones 10 de la noche a la mañana, ya que ello obligaría a los usuarios de la librería a migrar también a las nuevas versiones de Java si quieren seguir utilizando la librería.

Para intentar mitigar en parte este problema, Java permite compilar el código de forma que funcione en una versión inferior. Por ejemplo, con Java 10 se puede generar código para Java 8, pero evidentemente no se puede utilizar ninguna característica específica de Java 10.

A partir de Java 9 se pueden utilizar ficheros Multi-Release JAR (MRJAR). Un nuevo formato de fichero JAR que permite empaquetar distintas versiones de una misma clase compiladas para ser ejecutadas en distintas versiones de Java.

Supongamos que partimos de una sencilla aplicación escrita originalmente en Java 8, que se compone de las dos clases listadas a continuación, y que se limita a obtener la versión de la máquina virtual de Java que se está ejecutando leyéndola de una variable del sistema.

  • src/Main.java:

  • src/Util.java:

Aplicación que puede compilarse, empaquetarse y ejecutarse de la forma habitual con los siguientes comandos:

El resultado de la aplicación dependerá de la versión de Java utilizada para ejecutar el programa. Si se ejecuta con la máquina virtual de Java 8 (161) se obtendrá 1.8.0_161. Pero si se ejecuta con la máquina virtual de Java 10 se obtendrá 10.

Si la aplicación se compilase con Java 10 utilizando los mismos argumentos de compilación por defecto entonces no podría ejecutarse con Java 8, daría un error en tiempo de ejecución al arrancar la aplicación.

Para generar código que pueda ejecutarse en una versión anterior de Java se debe utilizar el parámetro --release seguido del número de la versión de Java para la que se quiere compilar. Por ejemplo, para compilar y empaquetar con Java 10 la aplicación indicando que se genere código compatible con la versión 8 se debe usar el siguiente comando:

El código generado de esta manera se ejecutará correctamente con la versión 8 y superiores de Java.

El parámetro --release se introdujo nuevo con la versión 9 de Java, es similar a los parámetros -source y -target ya existentes, pero además detecta si el código utiliza alguna API pública conocida de Java que no se encuentre en la versión de Java destino de la compilación.

Veamos ahora como mejorar la aplicación de ejemplo anterior, utilizando el método estático java.lang.Runtime.version, que permite a las aplicaciones obtener la versión de Java que se está ejecutando, en vez de leerla de una variable del sistema. Es un método que sólo está disponible a partir de Java 9, por lo que no se puede simplemente reemplazar el código de la clase Util original para que lo utilice si se quiere que la aplicación siga siendo compatible con Java 8.

La solución es crear una nueva clase como la listada a continuación, y empaquetarla junto con las dos anteriores en un MRJAR.

  • src-9/Util.java:

Clase que puede compilarse con el siguiente comando:

Y que puede empaquetarse junto con el resto de clases con el siguiente comando:

El parámetro --release indica la versión de Java que se debe utilizar para una lista de clases dada, hace que se añada la entrada Multi-Release: true al fichero MANIFEST.MF, y fuerza a que se cree un MRJAR con la siguiente estructura:

Cuando se ejecute la aplicación con Java 8 se ejecutará la clase Util de la raíz, y cuando se ejecute con Java 9 o superior se ejecutará la clase Util de META-INF/versions/9.

Inferencia de tipos en variables locales en Java

Una de las novedades más relevantes de la versión 10 de Java, desde el punto de vista del desarrollador, es la inferencia de tipos de las variables locales. Lo que quiere decir que ya no es necesario indicar el tipo de las variables al declararlas. El compilador calculará (inferirá) los tipos en función de cómo se inicializan las variables.

En la práctica quiere decir que el siguiente código que declara un par de variables:

ahora puede escribirse sin indicar los tipos de manera explícita con el nuevo nombre de tipo reservado  var:

var puede utilizarse sobre variables inicializadas con literales de tipo String, char, long, float, double y boolean:

var interpreta todos los valores numéricos enteros sin sufijo como de tipo int por defecto, por lo que las variables de tipo byte y short deben seguir siendo declaradas con su tipo de manera explícita o mediante un cast:

var interpreta los valores numéricos con decimales sin sufijo como double por defecto:

La inicialización de variables con tipos numéricos nulables como Integer o Double puede realizarse con el método valueOf:

El uso de var simplifica la declaración de variables en los bucles de tipo for, evitando tener que repetir el tipo de la colección recorrida, y minimizando el impacto de posibles refactorizaciones:

El uso de var evita el clásico patrón de Java de tener que importar interfaces en una clase con objeto de declarar variables inicializadas con una implementación de dicha interface:

var interpreta como Object el tipo de las clases genéricas cuando se utiliza el operador diamante en la inicialización, por lo que el tipo debe indicarse de forma explícita:

En todo caso, var no se puede utilizar sobre variables que no se inicialicen en el momento de declararse:

Teniendo en cuenta que Java es un lenguaje fuertemente tipado, al que siempre se ha acusado de ser extremadamente repetitivo, es un cambio importante y que continúa el esfuerzo realizado en la versión 8 con la introducción del operador diamante o las funciones lambda.

No se trata tanto de escribir menos código, el ahorro de caracteres netos tecleados, sino de hacerlo más conciso, legible, a la par que favorecer buenas prácticas, como la elección de nombres coherentes, o la minimización de la localidad de las variables, y simplificar algunos patrones de Java, como la declaración de variables en bucles, o la declaración de variables con una interface y su inicialización con una clase que implementa dicha interface.

Y aunque parezca algo novedoso, hay que tener en cuenta que es una característica que se encuentra presente en muchos lenguajes veteranos como C++ o recientes como Go, por citar algunos. Pero que supone un paso en la dirección correcta en todo caso.

Máquinas virtuales de Java personalizadas con jlink

Tener un runtime o máquina virtual de tamaño reducido ha sido tradicionalmente una característica bastante apreciada, ya que normalmente se traduce en un menor almacenamiento, memoria y tiempo de carga. Las tendencias tecnológicas actuales de microservicios, contenedores e Internet de las Cosas (IOT) no han variado ni un ápice esta apreciación.

Java se ha caracterizado siempre por tener una máquina virtual de un tamaño considerable y pocas opciones de personalización. Afortunadamente, con la versión 9 de Java se cambió a un diseño más modular y se publicó la herramienta jlink. El diseño modular introdujo el concepto de módulo, tanto para el código propio de la máquina virtual como para el de las aplicaciones, y la herramienta jlink dotó a los desarrolladores de una forma de crear distribuciones de Java personalizadas, en las que incluyesen tan solo los módulos que fueran necesarios para ejecutar sus aplicaciones.

Dentro del subdirectorio jmods donde se encuentre instalado Java están los distintos módulos en los que se estructura la máquina virtual. Siendo el módulo java.base el que contiene las clases básicas fundamentales necesarias para ejecutar la máquina virtual, y del que todos los demás módulos dependen, ya sea de forma directa o indirecta.

Para crear una imagen con una distribución personalizada de Java que contenga tan sólo el módulo base se debe ejecutar el siguiente comando:

Con el parámetro --module-path se indica donde se encuentran los módulos, con --add-modules la lista de módulos que se quieren incluir en la imagen, y con --output el nombre del directorio donde crear la imagen personalizada.

Como resultado de la ejecución del comando anterior se creará un directorio de nombre imagen con la misma estructura de subdirectorios que en la distribución original de Java, pero que tan sólo contendrá los archivos del módulo java.base, lo que se traduce en una ocupación de unos 36,5 MB frente a los 474 MB del JDK original.

Es posible reducir el tamaño de la imagen resultante a unos 20 MB con diversos parámetros como --compress, --no-man-pages--no-header-files y otros. Las opciones disponibles se muestran en la ayuda que se lista con el clásico parámetro --help.

La máquina virtual personalizada se ejecuta de igual forma que la original:

En todos los aspectos es una máquina virtual de Java, y se puede aplicar sobre ella por ejemplo la técnica de Class Data Sharing para generar un fichero de clases precompiladas con .\imagen\bin\java --Xshare:dump, aunque a costa de doblar el tamaño de la distribución.

De forma general, para comprobar qué módulos se encuentran en una distribución de Java se tiene que ejecutar el siguiente comando:

Ejecutado sobre la distribución original de Java lista todos los módulos, ejecutado sobre nuestra distribución personalizada lista sólo el módulo básico incluido en ella:

    java.base@10

Una distribución personalizada de Java cobra mayor sentido si se crea para ejecutar una aplicación concreta, de forma que la imagen contenga sólo los módulos que utiliza dicha aplicación. Algo sencillo de ejemplificar con una aplicación muy simple que dependa de un módulo cualquiera, como la compuesta por los siguientes dos ficheros:

  • modulo/paquete/Clase.java:

  • modulo/module-info.java:

La clase paquete.Clase simplemente invoca un método estático cualquiera de una clase de un paquete del módulo java.prefs. El fichero module-info define el módulo modulo, indica la dependencia explícita con el módulo java.prefs, y exporta el paquete paquete que contiene la clase principal de la aplicación.

La aplicación se puede compilar, empaquetar en un jar, y probar de la forma acostumbrada con los siguientes comandos:

La imagen a partir del módulo de dicha aplicación se puede crear con el siguiente comando:

Y se puede comprobar qué módulos se han incluido dentro la imagen con el siguiente comando:

El resultado mostrará cuatro módulos:

    java.prefs@10
    java.xml@10
    java.base@10
    modulo

El primer módulo es java.pref, requerido por la aplicación de forma explícita. Los dos siguientes son dependencias directas y transitivas, ya que java.prefs depende de java.xml, y java.xml depende a su vez de java.base. El último módulo es el propio de la aplicación.

El último paso es verificar que la aplicación se ejecuta correctamente con la imagen:

Como se observa en el último comando, para ejecutar la aplicación no es necesario pasar como parámetro ni el módulo ni el jar, la clase paquete.Clase es parte de la imagen.

Class Data Sharing en Java

A fin de optimizar el tiempo de arranque de la máquina virtual de Java y reducir el uso de memoria se puede crear un fichero que contenga un conjunto de clases precompiladas. Este fichero almacena las clases en un formato de representación interna que puede cargarse directamente en memoria, y que además, por ser de sólo lectura y mapeado en memoria, es automáticamente compartido por las distintas máquinas virtuales que se encuentren levantadas a un mismo tiempo. Esta opción se llama Class Data Sharing (CDS).

Un fichero de este tipo se crea automáticamente cuando se instala Java desde el programa de instalación en Windows con las clases básicas de Java. Pero si la instalación se realiza descomprimiendo el programa de instalación manualmente, o copiándola de un directorio ya existente, entonces el fichero hay que crearlo con el siguiente comando:

Como resultado de la ejecución del comando se muestra un log muy detallado que en la mayoría de los casos se puede ignorar, aunque hay algún dato informativo útil, como por ejemplo el número de clases añadidas al fichero:
    Number of classes 1298
El fichero generado se llama classes.jsa  y se crea en el subdirectorio /bin/server. Si ya existe se sobreescribe.

La máquina virtual de Java tiene un comportamiento definido por defecto con respecto al uso del fichero. No obstante, existe un parámetro que puede utilizarse para deshabilitar o forzar su uso. -Xshare:off fuerza que no se utilice el fichero, -Xshare:on fuerza que se utilice, y -Xshare:auto aplica el comportamiento por defecto.

Debido al que el formato del fichero depende de la arquitectura de cada máquina en concreto, se puede utilizar -Xshare:on para comprobar que el fichero es válido, y no se ha copiado por ejemplo desde una máquina con Windows a otra con Linux. Con -Xshare:on Java no arrancará si el fichero no es válido. Con el comportamiento por defecto Java ignorará el fichero si no es válido.

Además de las clases básicas de Java añadidas al fichero, Java 10 permite crear ficheros personalizados con las clases precompiladas que se quieran. Opción a la que Oracle ha llamado Application Class-Data Sharing (ApsCDS), y que se implementa utilizando un fichero que contenga un listado con el nombre de las clases que se quieran precompilar.

No obstante, antes de crearlo es interesante entender como probar y verificar el origen de cada clase en Java. Para ello se puede utilizar el parámetro que activa el log en la máquina virtual de Java. Por ejemplo, para ver desde donde se cargan las clases si no se utiliza CDS se puede ejecutar el siguiente comando:

En la salida se mostrará cada clase cargada y su origen. Y puede verse que la clase java.lang.Object, y todas las demás, se cargan desde el jar del runtime:

    java.lang.Object source: jrt:/java.base

En cambio, si se fuerza el uso de CDS:

en la salida puede comprobarse que  ahora java.lang.Object, y todas las demás, se cargan desde el fichero compartido:
    java.lang.Object source: shared objects file
Una vez realizada esta comprobación, ya se puede crear una aplicación de prueba con objetivo de añadir sus clases a un fichero compartido que la máquina virtual pueda precargar:

Aplicación que se puede compilar, empaquetar en un jar, y probar de la forma acostumbrada con los siguientes comandos:

De forma que en la salida puede verse que la clase test.Test se carga desde Test.jar:

    test.Test source: file:<path>/test/Test.jar

Para obtener un fichero con las clases cargadas por la aplicación de prueba se debe ejecutar el siguiente comando:

El parámetro -XX:+UnlockCommercialFeatures es necesario sólo si se está utilizando la máquina virtual de Oracle, y no requiere ningún tipo de licencia en contra de lo que el nombre parece sugerir. -XX:+UseAppCDS activa la opción de AppCDS. Y -XX:DumpLoadedClassList especifica el fichero donde se deben de volcar los nombres de las clases cargadas por la aplicación. El fichero generado es un fichero de texto plano con una línea por cada clase.

El siguiente paso es crear un fichero con las clases precompiladas a partir del fichero de clases generado en el paso anterior:

El parámetro -XX:SharedClassListFile indica el fichero con el listado de clases a utilizar, y -XX:SharedArchiveFile especifica el fichero con las clases precompiladas a crear.

El último paso es volver a lanzar la aplicación de prueba forzando que se utilice el fichero con las clases compartidas creado en el paso anterior:

En la salida puede verse que ahora la clase de la aplicación de pruebas se carga desde el fichero compartido:

    test.Test source: shared objects file

Este procedimiento debería realizarse, y automatizarse, para las aplicaciones y servidores de aplicaciones, con el objetivo de disminuir el tiempo de arranque y la memoria utilizada cuando se encuentren en ejecución varias máquinas virtuales al mismo tiempo.