Exclusión mutua en JAVA
-
Java no tiene semáforos a nivel del lenguaje (sí en la biblioteca estándar).
-
Proporciona primitivas para gestionar la concurrencia integradas en la propia sintaxis.
-
Utiliza monitores (intrinsic locks) asociados a cada objeto.
-
En un enfoque orientado a objetos, la concurrencia implica que varios hilos pueden ejecutar métodos del mismo objeto.
-
Conseguimos la exclusión mutua mediante la palabra reservada
synchronized. -
Un método con el modificador
synchronizedse ejecuta en exclusión mutua respecto de otros métodos/bloquessynchronizeddel mismo objeto. -
Es posible sincronizar bloques (
synchronized(this)u otro candado) y también a nivel de clase (static/NombreDeClase.class). -
Los candados son reentrantes: el mismo hilo puede adquirir el mismo cerrojo varias veces.
-
synchronizedtambién establece orden y visibilidad (relación happens-before) entre hilos: al salir de un bloquesynchronized, todas las escrituras realizadas dentro quedan garantizadas como visibles para el hilo que entre después con el mismo cerrojo.
Utilización de synchronized
-
En este ejemplo tenemos una clase con 2 bloques
synchronized(m2ym4).public class Sincronizados { public void m1() {} public synchronized void m2() {} public void m3() {} public synchronized void m4() {} public void m5() {} }
Figure 1: Ejemplo de sincronización en Java.
Estas funciones serán accedidas por exclusión mutua.
-
Un bloque de código también puede ser sincronizado. En este caso necesitamos hacer referencia a un objeto:
public class Sincronizada { public void metodo1() { // instrucciones synchronized(this) { // instrucciones que se ejecutarán en exclusión mutua con // otro bloque synchronized } // instrucciones } } -
El caso anterior lo podemos generalizar a cualquier objeto, no sólo
this:public class Sincronizada { public void metodo1() { //instrucciones synchronized(otroObjeto) { //instrucciones que se ejecutarán en exclusión mutua con //el mutex de otroObjeto } //instrucciones } } -
Podemos sincronizar a nivel de métodos de clase también:
public class Sincronizada { public synchronized static void metodo1() { //instrucciones } } // O tambien ... synchronized (nombreClase.class) { //instrucciones }
Variables volatile
-
Las asignaciones de tipos primitivos y de referencias son atómicas; no hace falta
synchronizedsi únicamente se escribe/lee un valor simple.
(Nota: en algunas JVM/arquitecturas antiguaslongydoublepodían no ser atómicos si no eranvolatile; convolatilesí lo son). -
Las dos clases siguientes son equivalentes cuando solo hay una asignación simple:
public class S { public void f() { synchronized (this) { boolean b = true; } } } public class S1 { public void f() { boolean b = true; } }Usar
synchronizedno aporta nada si solo hay una escritura atómica y no se mantienen invariantes. -
Las optimizaciones del compilador/JIT pueden provocar reordenamientos o problemas de visibilidad si varios hilos acceden a la misma variable sin una relación happens-before. Ejemplo:
public class C { private boolean v = false; public void cambiaV() { v = true; } public synchronized void esperaVCierto() { v = false; if (v) // Código que no será ejecutado } }synchronizedgarantiza que no se ejecuten a la vez otros métodos sincronizados del mismo objeto, pero cambiaV no lo está, así que puede ejecutarse concurrentemente con esperaVCierto.- El compilador/JIT puede eliminar el cuerpo del
ifporque dentro de esperaVCierto se asignav = falsey, sin una relación de orden/visibilidad concambiaV, “asume” quevseguirá siendofalseen ese punto.
-
Para evitar esas optimizaciones y asegurar visibilidad, declara
vcomovolatile. Indicar que una variable esvolatilecomunica a la JVM/JIT que puede ser modificada por otros hilos y garantiza que la escrituravolatileocurre antes (happens-before) de la lecturavolatileposterior de esa misma variable.
Sincronización
- Además de la exclusión mutua (proteger el estado con
synchronized), necesitamos coordinar cuándo los hilos deben esperar a que una condición del programa se cumpla. - Esta coordinación se realiza con las primitivas del monitor del objeto:
wait(),notify()ynotifyAll(), que siempre se usan dentro de una sección sincronizada con el mismo cerrojo. - En Java, cada objeto funciona como monitor y tiene un wait set que actúa como variable de condición asociada a su candado:
wait(): el hilo espera en la variable de condición (libera el cerrojo y se suspende).notify()/notifyAll(): señalizan la variable de condición (despiertan uno o a todos; al reanudar, deben recompetir el candado).
Sincronización, guarda boolena
synchronized void hacerCuandoCondicion() {
while(!c)
try {
wait();
} catch(InterruptedException e) {}
// Aquí irá código que se ejecutará
// cuando la condicion 'c' sea cierta
}
synchronized void hacerCondicionVerdadera() {
c = true;
notify(); // o notifyAll()
}
-
Vamos a tener dos conjuntos de hilos en espera:
- los que quieren acceder a la zona de exclusión mutua
- los que están dormidos esperando a ser despertados por un
notifyonotifyAll
-
Cuando un hilo hace
wait, primero se suspende el hilo y luego se libera el candado (synchronized), con lo cual otro hilo puede entrar. -
El
whilees necesario, ya que que se haya despertado un hilo no nos garantiza que la variable c pase a valertrue -
Un hilo despertado por
notifytendrá que volver a luchar por conseguir el cerrojo.
Figure 2: Ejemplo de guarda booleana.
-
Puedes ver un ejemplo completo en este vídeo.
Problemas clásicos
Productor-Consumidor:
-
Buffer limitado:
public class Buffer { private int cima, capacidad, vector[]; public Buffer(int i) { cima = 0; capacidad = i; vector = new int[i]; } synchronized public int extraer(int id) { System.out.println(id+": espera para leer"); while (cima == 0) try { wait(); } catch (InterruptedException e){} notifyAll(); return vector[--cima]; } synchronized public void insertar(int elem, int id) { System.out.println(id+": espera para insertar"); while (cima == capacidad) try { wait(); } catch (InterruptedException e){} vector[cima] = elem; cima++; notifyAll(); } } -
Consumidor:
public class Consumidor extends Thread { int elem, id; Buffer buffer; Consumidor(Buffer b, int numHilo) { buffer = b; id = numHilo; System.out.println("CONSUMIDOR " + id +": entra"); } public void run () { try { elem = buffer.extraer(id); System.out.println(id + ": he leido " + elem); } catch (Exception e) {} } } -
Productor:
public class Productor extends Thread { Buffer buffer; int elem, id; Productor(Buffer b, int i, int numHilo) { elem = i; buffer = b; id = numHilo; System.out.println("PRODUCTOR " + id + ": entra"); } public void run () { try { buffer.insertar(elem, id); System.out.println(id + ": inserta " + elem); } catch (Exception e) {} } }
-
Clase Principal:
public class ProductorConsumidor { static Buffer buf = new Buffer(3); static int numcons = 7; static int numprods = 5; public static void main(String[] args) { for(int i = 1; i <= numprods; i++) new Productor(buf,i,i).start(); for(int k = 1; k <= 1_000_000_000; k++); System.out.println(); for(int j = 1; j <= numcons; j++) new Consumidor(buf, j).start(); System.out.println("Fin del hilo main"); } } -
Traza:
| PRODUCTOR 1: entra | CONSUMIDOR 1: entra | CONSUMIDOR 5: entra | | PRODUCTOR 2: entra | CONSUMIDOR 2: entra | 4: espera para leer | | 1: espera para insertar | 1: espera para leer | 4: he leido 2 | | 1: inserta 1 | 4: inserta 4 | CONSUMIDOR 6: entra | | PRODUCTOR 3: entra | 1: he leido 3 | 5: espera para leer | | 2: espera para insertar | CONSUMIDOR 3: entra | 5: he leido 1 | | 2: inserta 2 | 2: espera para leer | 6: espera para leer | | PRODUCTOR 4: entra | 5: inserta 5 | CONSUMIDOR 7: entra | | 3: espera para insertar | 2: he leido 4 | Fin del hilo main | | 3: inserta 3 | CONSUMIDOR 4: entra | 7: espera para leer | | PRODUCTOR 5: entra | 3: espera para leer | | | 4: espera para insertar | 3: he leido 5 | | | 5: espera para insertar | | |
Otros problemas clásicos
- Lector-Escritor: ReaderWriter.java
- Comida de filósofos: Philosopher.java
Semáforos
- Java no tiene semáforos en el lenguaje, pero sí en la biblioteca estándar (
java.util.concurrent.Semaphore). Aquí los definimos con monitores para ver su funcionamiento básico.
Semáforo binario
public class SemaforoBinario {
protected int contador = 0;
public SemaforoBinario(int valorInicial) {
contador = valorInicial; // 1 o 0
}
synchronized public void wait() {
while (contador == 0 )
try {
wait();
} catch (Exception e) {}
contador--;
}
synchronized public void signal() {
contador = 1;
notify();
}
}
Semáforo general
public class SemaforoGeneral {
public SemaforoGeneral(int valorInicial) {
super(valorInicial); // numero natural
}
synchronized public void wait() {
while (contador == 0 )
try {
wait();
} catch (Exception e) {}
contador--;
}
synchronized public void signal() {
contador++;
notify();
}
}
ThreadPools
Conceptos básicos
-
En Java, los hilos de la JVM se mapean a hilos del S.O., por lo que son un recurso del sistema.
-
Si creamos hilos “sin control”:
- Podemos agotar recursos del S.O. (demasiados hilos).
- Aunque crear hilos es más barato que procesos, sigue habiendo sobrecarga para el S.O..
-
Más hilos ≠ más rendimiento: el S.O. hace cambios de contexto y con muchos hilos una parte relevante del tiempo se va en planificación, no en trabajo útil.
-
Un ThreadPool aparece para ahorrar recursos y contener el paralelismo dentro de unos límites.
- En lugar de crear y destruir un hilo para cada tarea, un ThreadPool mantiene un grupo fijo o dinámico de hilos que se reutilizan para ejecutar varias tareas.
- Controla cuántos hilos crea la aplicación, planifica tareas y mantiene una cola hasta que puedan atenderse.
- Reutiliza hilos para múltiples tareas cortas y optimiza el rendimiento en situaciones de alta carga o alta concurrencia.
Implementación en Java
-
La clase auxiliar
Executorsofrece factorías para crear ThreadPools preconfigurados (útiles si no necesitamos ajustes finos). -
Trabajamos contra las interfaces
ExecutoryExecutorServicepara desacoplar el código de la implementación concreta del pool.
Ejemplo en Java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
// Crear un ThreadPool con 4 hilos
ExecutorService executor = Executors.newFixedThreadPool(4);
// Enviar tareas al ThreadPool
for (int i = 1; i <= 10; i++) {
int taskNumber = i;
executor.submit(() -> {
System.out.println("Ejecutando tarea " + taskNumber + " en " + Thread.currentThread().getName());
});
}
// Apagar el pool
executor.shutdown();
}
}
ThreadPool en otros lenguajes
- Lo vas a encontrar prácticamente en cualquier lenguaje que admita concurrencia.
- Incluso implementaciones a nivel de biblioteca como GLib y con adaptaciones con un estilo más orientado a objetos como esta para el lenguaje Vala.
Ejemplo en Python
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def trabajo(i):
time.sleep(0.2)
return i * i
if __name__ == "__main__":
with ThreadPoolExecutor(max_workers=4) as pool:
futures = [pool.submit(trabajo, i) for i in range(10)]
for f in as_completed(futures):
print("Resultado:", f.result())
También se suele usar concurrent.futures.ProcessPoolExecutor para evitar el bloqueo del GIL.
Ejemplo en Rust
// Cargo.toml
// [dependencies]
// threadpool = "1.8.1"
use threadpool::ThreadPool;
use std::sync::mpsc::channel;
fn main() {
let pool = ThreadPool::new(4);
let (tx, rx) = channel();
for i in 0..10 {
let tx = tx.clone();
pool.execute(move || {
let out = i * i; // realizar trabajo
tx.send(out).unwrap(); // publicar resultado
});
}
drop(tx); // cerrar emisor para terminar el bucle
for r in rx {
println!("Resultado: {r}");
}
}
Más sobre concurrencia en Java
- El API de Java 10:
- La documentación general la puedes consultar en: java.util.concurrent
- Allí encontrarás documentación sobre los cierres de exclusión mutua, barreras, semáforos, colecciones concurrentes (vectores, conjuntos, tablas, colas).
Candados (Locks) y Variables de Condición (Condition)
- java.util.concurrent.locks
- Interfaces Lock y Condition
- Lock permite gestionar los cierres de forma explícita
lock()unlock()tryLock()newCondition()
- Condition permite establecer más de una cola asociada a un
Lock (ejemplo BoundedBuffer)
await()awaitUntil()signal()signalAll()
- Lock permite gestionar los cierres de forma explícita
Colecciones seguras
- BlockingQueue: Estructura FIFO que se bloquea (o espera un tiempo máximo) al insertar en cola llena o retirar de cola vacía. Varias implementaciones, p. ej.
ArrayBlockingQueue - ConcurrentMap: Tabla hash con operaciones atómicas. Implementaciones como
ConcurrentHashMap.
Tipos de datos atómicos
-
Conjunto de clases que permiten realizar operaciones atómicas con variables de tipos básicos
AtomicBooleanAtomicIntegerAtomicIntegerArrayAtomicLongAtomicLongArray- …
-
Por ejemplo,
AtomicIntegerdispone de:addAndGetcompareAndSetdecrementAndGetgetAndAddgetAndDecrementgetAndIncrementgetAndSetincrementAndGetintValuelazySetset
-
Ver código de
PhilosopherConditions.java
Executors
- Representan el concepto de asincronía (async/await, promesas, futuros) en Java.
- Echa un vistazo aquí y a este vídeo.
- También se emplean en la implementación de ThreadPools.
Para saber más
- Echa un vistazo al tutorial sobre concurrencia de Java.
Aclaraciones
- En ningún caso estas transparencias son la bibliografía de la asignatura, por lo tanto debes estudiar, aclarar y ampliar los conceptos que en ellas encuentres empleando los enlaces web y bibliografía recomendada que puedes consultar en la página web de la ficha de la asignatura y en la web propia de la asignatura.