Las colecciones de Java son un recurso fundamental a la hora de almacenar información por parte de las aplicaciones. Los datos alojados en una colección son accesibles a través de distintos mecanismos, dependiendo de que la estructura de datos subyacente sea una lista, árbol, tabla hash, etc., y pueden ser modificados, agregándose, eliminándose y cambiando su contenido.
Que una colección sea Thread-Safe significa que contempla adecuadamente la posibilidad del acceso concurrente a sus datos. En caso de que una colección no sea Thread-Safe, si se diera este acceso concurrente, el resultado sería imprevisible.
Imagina que un hilo está enumerando el contenido de una colección y, mientras tanto, otro hilo ha agregado o eliminado elementos de la misma. Lo habitual es que el primero genere una excepción, salvo que hayamos sincronizado de manera adecuada las operaciones mediante alguno de los procedimientos que proporciona la plataforma Java para ello.
Las versiones más recientes de la plataforma Java cuentan con varios tipos de colecciones, definidas en java.util.concurrent
en lugar del paquete java.util
más habitual, que están específicamente preparadas para operar en contextos de ejecución multihilo. Asimismo, cada tipo de colección existente en java.util
dispone de una versión sincronizada. Son las colecciones thread-safe, cuyo uso veremos a continuación.
Colecciones sincronizadas en Java
La clase Collections
cuenta con varios métodos estáticos cuyo nombre sigue el patrón synchronizedXXX()
, siendo XXX
un tipo de colección como puede ser List
, Map
, Set
, etc. Estos métodos toman como argumento un objeto, la colección a sincronizar, y devuelven como resultado una referencia del tipo adecuado: List
, Map
, Set
, etc:
Lo que hacen esos métodos es crear un objeto de una clase interna a Collections
, por ejemplo SynchronizedList
. Esta se caracteriza por implementar la interfaz correspondiente (List
en ese caso). Se limita a delegar las operaciones en la colección que se facilitó originalmente como parámetro, pero lo hace dentro de un bloque de código precedido por la palabra clave synchronized
. De esta forma se asegura que la inserción, eliminación, cambios, etc., solo puedan llevarse a cabo por un hilo en cada momento.
Ejemplo de colección sincronizada en Java
Supongamos que vamos a usar una lista de tareas en una aplicación con múltiples hilos de ejecución, cada uno de los cuales puede agregar elementos a la misma. Para evitar problemas podríamos recurrir a una lista sincronizada, como se muestra en el siguiente fragmento:
var tareas = Collections.synchronizedList(new LinkedList<>());
...
tareas.add("Finalizar estudio módulo sobre concurrencia"); // Operación sincronizada
Observa cómo al invocar al método synchronizedList()
se entrega como parámetro una lista doblemente enlazada que creamos en ese mismo momento. De igual manera podríamos crear otro tipo de lista, conjunto o diccionario. Todas las operaciones de acceso se efectúan indirectamente a través de los métodos de la clase SynchronizedList
, que pasa inadvertida.
Nota: esta última afirmación tiene una excepción: la enumeración de los elementos. Al enumerar una colección, es responsabilidad nuestra incluir el código en un bloque synchronized
, de forma que no puedan modificarse mientras se recorre su contenido. El esquema es el indicado en la propia documentación de estas clases, mostrada en la anterior figura de jshell
. En ella se aprecia un ejemplo de cómo recorrer sin problemas la lista.
Colecciones thread-safe sin sincronización
La sincronización de todas las operaciones de acceso a una colección, en especial la enumeración de sus elementos, por el tiempo que puede mantener bloqueado ese acceso para el resto de hilos, es una opción que suele afectar de forma importante al rendimiento de las aplicaciones. Dado que, en la mayoría de los casos, los accesos para lectura y recorrido de una colección suelen ser mucho más frecuentes que las operaciones de modificación, una alternativa a las colecciones sincronizadas son las representadas por las clases CopyOnWriteArrayList
y CopyOnWriteArraySet
.
La peculiaridad de estas colecciones estriba en que no hay ningún mecanismo de sincronización. En su lugar, las operaciones que modifican la colección generan una nueva copia interna de la misma, mientras la antigua se mantiene para cualquier operación de lectura que estuviese en curso. Esto significa que es posible recorrer los elementos de una lista mientras se efectúan cambios en esta, si bien dichos cambios no serían visibles hasta un acceso posterior.
Nota: CopyOnWriteArrayList
y CopyOnWriteArraySet
usan un ReentrantLock
para controlar la solicitud de cambios sobre el contenido de la colección, garantizando así un correcto funcionamiento en aplicaciones con múltiples hilos de ejecución que modifican simultáneamente el contenido.
Para comprobar el funcionamiento de las clases antes citadas, ni siquiera necesitamos tener varios hilos de ejecución, basta con que intentemos cambiar el contenido de la colección mientras estamos enumerándola, tal y como se aprecia en el fragmento de código de la parte superior de la siguiente imagen:
En la parte inferior de la imagen anterior, aparece la excepción que ha generado el programa al ser ejecutado. En este caso valores2
es un ArrayList
. No tenemos más que cambiarlo por un CopyOnWriteArrayList
y comprobaremos que la operación se permite y funciona como sería de esperar.
Colecciones con acceso concurrente sin bloqueos
Además de las ya descritas, en java.util.concurrent
encontramos varias clases más de colección cuya denominación sigue el patrón ConcurrentXXX
, por ejemplo ConcurrentLinkedQueue
. Se caracterizan porque contemplan el acceso concurrente a los elementos de la colección, incluso para operaciones de modificación, pero no recurren a elementos de sincronización como la palabra clave synchronized
o los cerrojos de tipo ReentrantLock
.
Los microprocesadores modernos cuentan con instrucciones que permiten comparar dos datos y asignar a uno de ellos un valor según el resultado de dicha comparación, todo ello de manera atómica, en una sola operación. El rendimiento es muy superior al que ofrecería el bloqueo de acceso al dato para su comparación y, si es preciso, posterior asignación. Clases como la citada ConcurrentLinkedQueue
se apoyan en esas instrucciones para ir enlazando los nodos de la colección a medida que se agregan o eliminan en un entorno multihilo, sin precisar mecanismos de sincronización.
Además de ConcurrentLinkedQueue
y ConcurrentLinkedDeque
, también nos serán útiles en entornos multihilo la clase ConcurrentHashMap
. Esta nos permite trabajar con un diccionario accesible simultáneamente desde varios hilos de ejecución, efectuando operaciones de lectura y modificación de forma concurrente.