Saltar al contenido principal

Tema 5: Sincronización en Java y construcciones de alto nivel

1. Introducción: De los semáforos a los monitores​

En el tema anterior estudiamos los semáforos como mecanismo del sistema operativo para evitar la espera activa. Aunque son muy potentes, los semáforos son primitivas de bajo nivel:

  • Las llamadas a wait() y signal() quedan dispersas por el código fuente.
  • Un olvido involuntario de un signal() o una inversión en el orden de los wait() desencadena inmediatamente un interbloqueo (deadlock) o corrompe la sección crítica.

Para resolver este problema, Hoare y Brinch Hansen propusieron en los años 70 el concepto de Monitor: una estructura de alto nivel que encapsula las variables compartidas y los métodos que las manipulan, garantizando que únicamente un hilo pueda ejecutar código dentro del monitor en un instante dado.

Java integra el modelo de monitores en la propia sintaxis del lenguaje mediante:

  1. Cerrojos intrínsecos (intrinsic locks) gestionados por la palabra clave synchronized.
  2. Variables de condición integradas en cada objeto a través de los métodos wait(), notify() y notifyAll().

2. Exclusión mutua en Java: cerrojos intrínsecos y synchronized​

Java no dispone de semáforos como palabras reservadas del lenguaje (aunque sí los proporciona en su biblioteca estándar java.util.concurrent.Semaphore). En su lugar, todo objeto en Java posee un cerrojo intrínseco (monitor lock).

2.1. Métodos sincronizados​

Al marcar un método con el modificador synchronized, el hilo debe adquirir automáticamente el cerrojo del objeto antes de ejecutar el cuerpo del método, y lo libera al salir (incluso si se produce una excepción no controlada):

public class Sincronizados {
public void m1() {} // Acceso libre sin bloqueo
public synchronized void m2() {} // Requiere el cerrojo de "this"
public void m3() {} // Acceso libre
public synchronized void m4() {} // Requiere el cerrojo de "this"
public void m5() {} // Acceso libre
}

Ejemplo de sincronización en Java

Los métodos m2() y m4() compiten por el mismo cerrojo de la instancia (this). Si el Hilo A está ejecutando m2(), ningún otro hilo podrá ejecutar m2() ni m4() sobre ese mismo objeto hasta que el Hilo A termine.

2.2. Bloques sincronizados explícitos​

En muchas ocasiones conviene sincronizar únicamente una sección crítica breve en lugar del método entero, o sincronizar respecto a un objeto cerrojo específico:

public class SeccionCritica {
private final Object cerrojoAuxiliar = new Object();
private int contador = 0;

public void incrementar() {
// Trabajo previo no crítico...

synchronized (this) {
contador++; // Exclusión mutua sobre la instancia actual
}

synchronized (cerrojoAuxiliar) {
// Exclusión mutua independiente protegida por otro cerrojo
}
}
}

2.3. Sincronización a nivel de clase (métodos estáticos)​

Cuando un método es static synchronized, el cerrojo adquirido no pertenece a ninguna instancia concreta, sino al objeto Class cargado en la JVM (NombreClase.class):

public class ContadorGlobal {
private static int total = 0;

public static synchronized void sumar() {
total++;
}

// Equivalente exacto utilizando un bloque explícito:
public static void sumarEquivalente() {
synchronized (ContadorGlobal.class) {
total++;
}
}
}
Reentrancia de los cerrojos en Java

Los cerrojos intrínsecos de Java son reentrantes: si un hilo ya posee el cerrojo de un objeto y realiza una llamada a otro método sincronizado del mismo objeto, se le permite el acceso inmediato sin auto-bloquearse. La JVM incrementa un contador interno de adquisiciones que se decrementa al salir de cada bloque sincronizado.


3. Variables volatile y el modelo de memoria de Java (JMM)​

En arquitecturas multiprocesador modernas, cada núcleo dispone de memorias caché (L1/L2/L3) y registros locales ultrarrápidos. Además, el compilador JIT (Just-In-Time) de la máquina virtual reordena instrucciones para maximizar el paralelismo de instrucciones en la CPU.

Esto introduce dos problemas fundamentales: falta de visibilidad y reordenamiento de instrucciones.

public class EjemploVisibilidad {
private boolean activo = true;

public void detener() {
activo = false;
}

public void bucleTrabajo() {
while (activo) {
// El compilador JIT puede optimizar este bucle como "while (true)"
// si carga la variable "activo" en un registro y nunca vuelve a leer la RAM.
}
}
}

3.1. Semántica de volatile​

Al declarar una variable con el modificador volatile:

  1. Visibilidad inmediata: Cualquier escritura en la variable se vuelca de inmediato a la memoria principal (RAM), y cualquier lectura se realiza siempre desde la RAM, invalidando las copias cacheadas en registros locales.
  2. Relación happens-before: Una escritura en una variable volatile ocurre antes (happens-before) de cualquier lectura posterior de esa misma variable por parte de cualquier hilo. Además, hace visibles todas las escrituras ordinarias que el hilo realizó antes de escribir en la variable volatile.
  3. Prohibición de reordenamientos: El compilador y la CPU insertan barreras de memoria (memory barriers) para evitar que las instrucciones circundantes se reordenen cruzando la lectura/escritura de la variable volatile.
¡Atención! volatile no garantiza atomicidad en operaciones compuestas

volatile asegura que todos los hilos lean el valor más actualizado, pero no proporciona exclusión mutua.

Una operación como contador++ consta de tres instrucciones en bytecode:

  1. GETFIELD (leer contador de memoria).
  2. IADD (sumar 1 en registro de CPU).
  3. PUTFIELD (escribir nuevo valor).

Si dos hilos ejecutan contador++ simultáneamente sobre una variable volatile, se producirá una condición de carrera y se perderán incrementos. Para operaciones compuestas es obligatorio emplear synchronized o clases atómicas (AtomicInteger).


4. Sincronización condicional: wait(), notify() y notifyAll()​

Además de proteger datos con exclusión mutua, los hilos necesitan esperar a que se cumplan condiciones del estado del programa (por ejemplo, esperar a que un buffer tenga espacio libre o a que lleguen datos).

Para ello, cada objeto de Java dispone de los métodos heredados de java.lang.Object:

  • wait(): El hilo actual libera el cerrojo del monitor y queda suspendido en la cola de espera (wait set) del objeto.
  • notify(): Despierta a uno de los hilos que se encuentren esperando en el wait set del objeto. Al despertar, el hilo debe competir nuevamente por el cerrojo antes de reanudar su ejecución.
  • notifyAll(): Despierta a todos los hilos en espera en el wait set.
Regla obligatoria de invocación

Las llamadas a wait(), notify() y notifyAll() solo pueden ejecutarse dentro de un método o bloque synchronized sobre el mismo objeto cuyo monitor se está utilizando. De lo contrario, la JVM lanzará inmediatamente una excepción IllegalMonitorStateException.

4.1. Guarda booleana y patrón de espera (while frente a if)​

El patrón correcto para esperar una condición en Java requiere evaluar siempre el predicado dentro de un bucle while:

synchronized void hacerCuandoCondicion() {
// CORRECTO: Siempre en un bucle while
while (!condicion) {
try {
wait(); // Libera el cerrojo y duerme el hilo
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restablece estado de interrupción
return;
}
}

// Sección crítica: se ejecuta únicamente cuando 'condicion' es verdadera
realizarAccionCritica();
}

synchronized void hacerCondicionVerdadera() {
condicion = true;
notifyAll(); // Despierta a los hilos esperando en este objeto
}

Esquema de guarda booleana y monitor

Dinámica de los dos conjuntos de hilos: el conjunto de entrada (esperando el cerrojo) y el conjunto de espera wait set (dormidos esperando la condición).

¿Por qué es obligatorio usar while y no if?​

  1. Disciplinas de señalización: Hoare frente a Mesa (Signal-and-Continue): A nivel teórico (que formalizaremos en el Tema 6), existen dos grandes disciplinas de señalización en monitores:
    • Semántica de Hoare (Signal-and-Wait): Propuesta teórica original (C.A.R. Hoare, 1974). El hilo emisor de la señal suspende su ejecución de inmediato y cede el procesador al hilo despertado. En este modelo idealizado, un condicional if bastaría porque ningún otro hilo puede alterar el estado entre la señal y la ejecución del receptor.
    • Semántica de Mesa (Signal-and-Continue): Diseñada en Xerox PARC para el sistema operativo y lenguaje Mesa (Lampson & Redell, 1980), es la disciplina universal adoptada por Java, POSIX (pthread_cond) y C#. El hilo que emite notify() o notifyAll() no cede la CPU: continúa ejecutándose reteniendo el cerrojo hasta salir de su método o bloque synchronized. Los hilos despertados simplemente pasan a la cola para competir por el cerrojo. En ese lapso de tiempo, un tercer hilo puede adelantarse, obtener el cerrojo, consumir el dato y volver a invalidar la condición. Por ello, es imperativo re-evaluar la condición con un bucle while.
  2. Despertares espurios (spurious wakeups): Tanto la especificación de la JVM como la del estándar POSIX permiten que un hilo bloqueado en wait() despierte sin que ningún otro hilo haya emitido un notify (debido a señales internas del sistema operativo o cambios de contexto del planificador). Por tanto, es imperativo re-evaluar siempre el predicado dentro del bucle while.

4.2. Cancelación e interrupción cooperativa (InterruptedException)​

En los ejemplos anteriores observamos que invocar wait() (al igual que Thread.sleep() o Thread.join()) obliga por contrato a capturar la excepción comprobada InterruptedException. Esto responde al modelo de cancelación de Java.

¿Por qué Thread.stop() y Thread.suspend() están obsoletos?​

En las primeras versiones de Java existían métodos como Thread.stop() que permitían matar un hilo arbitrario desde otro hilo. Fueron rápidamente declarados obsoletos (@Deprecated) y desaconsejados por una razón crítica de concurrencia:

  • Matar un hilo de forma abrupta fuerza la liberación inmediata de todos los cerrojos que posee.
  • Si el hilo estaba a mitad de una modificación (por ejemplo, actualizando punteros en una lista enlazada o transfiriendo saldo entre cuentas), los objetos compartidos quedan en un estado corrupto e inconsistente.
  • Los demás hilos continuarían accediendo a estructuras rotas sin que nadie pueda detectar el fallo.

El modelo cooperativo: interrupt()​

En Java, la cancelación es cooperativa: un hilo no puede forzar a otro a morir; solo puede solicitarle cortésmente que se detenga:

  1. El hilo solicitante invoca hiloObjetivo.interrupt().
  2. Si hiloObjetivo está dormido en una operación bloqueante (wait(), sleep(), join()), el método bloqueante aborta de inmediato lanzando una excepción InterruptedException.
  3. Si hiloObjetivo está ejecutando cómputo activo en CPU, debe sondear periódicamente su bandera de interrupción mediante Thread.currentThread().isInterrupted().
Gestión adecuada de InterruptedException

Cuando un método bloqueante lanza InterruptedException, la JVM limpia automáticamente la bandera de interrupción (vuelve a false). Si se captura la excepción y se silencia con un bloque vacío (catch (InterruptedException e) {}), la solicitud de cancelación se pierde y el hilo continuará ejecutándose sin atender la orden de parada.

La forma correcta de gestionarlo es:

  1. Propagar la excepción añadiendo throws InterruptedException en la cabecera del método (ideal si el llamador puede decidir cómo reaccionar).
  2. O bien restablecer la bandera de interrupción antes de retornar para que los niveles superiores se enteren:
try {
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restablece la bandera booleana en el hilo actual
return; // Salir limpiamente del método
}

5. Problemas clásicos implementados con monitores​

5.1. Productor-consumidor con buffer acotado​

En este ejemplo modelamos un buffer acotado de tipo LIFO (pila) sincronizado con monitores en ProductorConsumidor.java:

class Buffer {
private int cima, capacidad;
private final int[] vector;

public Buffer(int capacidad) {
this.cima = 0;
this.capacidad = capacidad;
this.vector = new int[capacidad];
}

public synchronized int extraer(int id) {
System.out.println("Consumidor " + id + ": espera para leer");
while (cima == 0) { // Buffer vacío: el consumidor debe esperar
try {
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return -1;
}
}
int elem = vector[--cima];
notifyAll(); // Notifica a productores bloqueados por buffer lleno
return elem;
}

public synchronized void insertar(int elem, int id) {
System.out.println("Productor " + id + ": espera para insertar");
while (cima == capacidad) { // Buffer lleno: el productor debe esperar
try {
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
vector[cima++] = elem;
notifyAll(); // Notifica a consumidores bloqueados por buffer vacío
}
}

Traza de ejecución del programa:​

$ javac ProductorConsumidor.java && java ProductorConsumidor
Productor 1: entra
Productor 2: entra
Productor 3: entra
Productor 4: entra
Productor 5: entra
Productor 1: espera para insertar
Productor 1: inserta 10
Productor 2: espera para insertar
Productor 2: inserta 20
Productor 3: espera para insertar
Productor 3: inserta 30
Productor 4: espera para insertar (Buffer lleno: se bloquea)
CONSUMIDOR 1: entra
Consumidor 1: espera para leer
Consumidor 1: ha leido 30 (Libera hueco y notifica)
Productor 4: inserta 40
...
Fin del hilo main: todas las transferencias completadas.
¿Por qué notifyAll() en lugar de notify()?

En el problema del productor-consumidor, tanto los productores (esperando hueco libre) como los consumidores (esperando elementos) duermen en el mismo conjunto de espera (wait set) del objeto Buffer. Si un consumidor llamase a notify(), la JVM podría despertar accidentalmente a otro consumidor en lugar de a un productor, provocando un interbloqueo si el buffer está vacío. notifyAll() garantiza que todos despierten y el hilo apropiado avance.


5.2. Lectores-escritores (ReaderWriter.java)​

En ReaderWriter.java implementamos un monitor para regular lecturas concurrentes y escrituras exclusivas:

class RWMonitor {
private int readers = 0;
private boolean writing = false;

public synchronized void readerLock() {
while (writing) { // No se permite leer mientras se escribe
try {
wait();
} catch (InterruptedException e) {}
}
readers++;
}

public synchronized void readerUnlock() {
readers--;
if (readers == 0) {
notifyAll(); // Despierta a escritores esperando
}
}

public synchronized void writerLock() {
while (writing || readers > 0) { // Escritor requiere exclusividad total
try {
wait();
} catch (InterruptedException e) {}
}
writing = true;
}

public synchronized void writerUnlock() {
writing = false;
notifyAll(); // Despierta a lectores y escritores en espera
}
}

5.3. Filósofos comensales (Philosopher.java)​

En el Tema 4 estudiamos que la solución directa de los filósofos con semáforos puede desembocar fácilmente en un interbloqueo (deadlock) si todos los comensales toman a la vez su palillo izquierdo y esperan indefinidamente el derecho. Para resolverlo con semáforos, tuvimos que recurrir a soluciones asimétricas o a esquemas de estados con mutexes globales.

Con los monitores de Java, la solución resulta notablemente más compacta y elegante. En lugar de manipular los palillos en pasos dispersos, la adquisición se encapsula en una operación atómica dentro del monitor:

class Table {
private final boolean[] forks;

public Table(int numForks) {
this.forks = new boolean[numForks];
Arrays.fill(this.forks, true); // true = palillo libre en la mesa
}

public synchronized void pick(int left, int right) {
while (!forks[left] || !forks[right]) { // Requiere ambos palillos libres
try {
wait(); // Si falta algún palillo, duerme y LIBERA el cerrojo de Table
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
forks[left] = false;
forks[right] = false;
}

public synchronized void release(int left, int right) {
forks[left] = true;
forks[right] = true;
notifyAll(); // Notifica a los demás filósofos esperando
}
}

Código completo del filósofo (Philosopher.java):​

import java.util.Arrays;

public class Philosopher implements Runnable {
static final int PHILOSOPHERS = 5;
static final int EAT_COUNT = 100;
Table table;
int id, leftFork, rightFork;

public Philosopher(int id, Table table) {
this.id = id;
this.table = table;
this.leftFork = id;
this.rightFork = (id + 1) % PHILOSOPHERS;
}

void think() {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

void eat() {
System.out.printf("Filósofo %d: COMIENDO (palillos %d y %d)\n", id, leftFork, rightFork);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.printf("Filósofo %d: TERMINÓ DE COMER\n", id);
}

@Override
public void run() {
for (int i = 0; i < EAT_COUNT; i++) {
think();
table.pick(leftFork, rightFork);
eat();
table.release(leftFork, rightFork);
}
}

public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[PHILOSOPHERS];
Table table = new Table(PHILOSOPHERS);

for (int i = 0; i < PHILOSOPHERS; i++) {
threads[i] = new Thread(new Philosopher(i, table));
threads[i].start();
}

for (int i = 0; i < PHILOSOPHERS; i++) {
threads[i].join();
}
System.out.println("Fin de la simulación: todos los filósofos han comido.");
}
}

Traza de compilación y ejecución:​

$ javac Philosopher.java && java Philosopher
Filósofo 0: COMIENDO (palillos 0 y 1)
Filósofo 2: COMIENDO (palillos 2 y 3)
Filósofo 0: TERMINÓ DE COMER
Filósofo 2: TERMINÓ DE COMER
Filósofo 1: COMIENDO (palillos 1 y 2)
Filósofo 3: COMIENDO (palillos 3 y 4)
...
Fin de la simulación: todos los filósofos han comido.

Código fuente disponible en Philosopher.java.

Análisis de concurrencia:​

  1. Eliminación del interbloqueo (Deadlock): Al entrar en table.pick(left, right), el filósofo verifica la disponibilidad de ambos palillos en exclusión mutua. Si alguno de ellos no está disponible, invoca wait(), soltando el cerrojo del monitor sin retener ningún palillo. De este modo, se rompe de raíz la condición de retención y espera (hold and wait) de Coffman: un filósofo jamás retiene un palillo mientras espera por el otro.
  2. Posibilidad de inanición (Starvation): Aunque no hay peligro de interbloqueo, la inanición sigue siendo teóricamente posible si dos comensales no contiguos (por ejemplo, los filósofos 0 y 2) alternan sus turnos de comida continuamente sin descanso, manteniendo los palillos 1 y 2 alternativamente ocupados y bloqueando indefinidamente al filósofo 1.
  3. ¿Por qué es indispensable notifyAll() en release()? En el método release(), es obligatorio usar notifyAll() en lugar de notify(). Si usáramos notify(), la máquina virtual despertaría a un único filósofo arbitrario de la cola. Si ese filósofo despierta pero comprueba que su otro palillo todavía está ocupado por su otro vecino, volverá a dormirse en el bucle while. La señal se habrá perdido y el vecino cuyos dos palillos sí estaban libres no llegará a enterarse, pudiendo congelar el sistema innecesariamente.

6. Implementación de semáforos mediante monitores​

Podemos construir semáforos clásicos de Dijkstra utilizando los monitores de Java como primitivas base.

Restricción sintáctica en Java: Object.wait() es un método final

En la clase base java.lang.Object, el método public final void wait() está declarado como final. El compilador de Java prohíbe taxativamente sobrescribir un método denominado public void wait() en cualquier clase de Java. Por ello, las operaciones del semáforo se denominan convencionalmente acquire() y release() (idénticas a la biblioteca estándar java.util.concurrent.Semaphore).

6.1. Semáforo binario (SemaforoBinario.java)​

public class SemaforoBinario {
protected int contador = 0;

public SemaforoBinario(int valorInicial) {
if (valorInicial < 0 || valorInicial > 1) {
throw new IllegalArgumentException("Un semáforo binario solo admite 0 o 1.");
}
this.contador = valorInicial;
}

public synchronized void acquire() {
while (contador == 0) {
try {
wait(); // Suspende el hilo en el monitor intrínseco y cede el cerrojo
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
contador--;
}

public synchronized void release() {
contador = 1;
notify(); // Notifica a un hilo bloqueado
}

public synchronized int getContador() {
return contador;
}
}

6.2. Semáforo general contador (SemaforoGeneral.java)​

Heredando de SemaforoBinario, podemos admitir un número arbitrario de permisos iniciales:

public class SemaforoGeneral extends SemaforoBinario {

public SemaforoGeneral(int valorInicial) {
super(valorInicial > 0 ? 1 : 0);
this.contador = Math.max(0, valorInicial);
}

@Override
public synchronized void acquire() {
while (contador == 0) {
try {
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
contador--;
}

@Override
public synchronized void release() {
contador++;
notify(); // Despierta exactamente a un hilo esperando
}
}
¿Por qué en los semáforos sí es seguro y eficiente notify()?

En el problema del productor-consumidor y en los filósofos comensales vimos que notify() es peligroso porque los hilos en el wait set son heterogéneos (esperan condiciones lógicas distintas).

En un semáforo, sin embargo, todos los hilos que duermen en el wait set son homogéneos: todos esperan exactamente la misma condición (contador > 0). Cuando un hilo invoca release(), se añade exactamente 1 permiso. Despertar a un único hilo mediante notify() es 100% seguro y óptimo: ese único hilo consumirá el permiso liberado.

Si usáramos notifyAll(), despertaríamos a los NN hilos que esperan en cola para que N−1N-1 de ellos comprueben en su bucle while que el contador volvió a 0 y regresen de inmediato al wait set. Este fenómeno de despertar masivo e inútil se conoce en sistemas como el problema del rebaño atronador (thundering herd problem).


7. Grupos de hilos (thread pools) y el marco de ejecutores​

7.1. Motivación: El coste de los hilos del sistema operativo​

En la máquina virtual de Java estándar (HotSpot en Linux, macOS y Windows), cada hilo de Java (Thread) se mapea en una correspondencia 1:1 con un hilo nativo del kernel del sistema operativo.

Crear un hilo nuevo para cada tarea pequeña resulta sumamente ineficiente por tres razones físicas:

  1. Sobrecarga de creación y destrucción: Asignar memoria para la pila del hilo (por defecto 1 MB por hilo) y realizar las llamadas al kernel (clone/pthread_create) consume tiempo de CPU.
  2. Consumo excesivo de memoria: 1.000 hilos inactivos consumen ~1 GB de memoria RAM únicamente en pilas de llamadas (call stacks).
  3. Sobrecarga por cambios de contexto (context switching): Si hay muchos más hilos que núcleos físicos de CPU, el planificador del sistema operativo invierte la mayor parte de su tiempo guardando y restaurando registros de procesador en lugar de ejecutar trabajo útil.

Un ThreadPool resuelve este problema manteniendo un grupo reutilizable de hilos trabajadores (workers) asociados a una cola de tareas pendiente:

Tareas entrantes Cola FIFO de tareas Hilos trabajadores (Pool)
[ Tarea 1 ] ──┐
[ Tarea 2 ] ──┼──► [ T1 | T2 | T3 | T4 ] ──► [ Worker 1 ] [ Worker 2 ] [ Worker 3 ]
[ Tarea 3 ] ──┘ │ │ │
└────────────┼────────────┘
▼
Núcleos de CPU

7.2. La interfaz ExecutorService y la factoría Executors​

El paquete java.util.concurrent proporciona interfaces y clases factoría para gestionar pools de hilos de forma desacoplada:

  • Executors.newFixedThreadPool(n): Pool con un número fijo de hilos y cola ilimitada.
  • Executors.newCachedThreadPool(): Crea nuevos hilos según sea necesario y reutiliza los inactivos; los hilos sin uso durante 60 segundos se destruyen.
  • Executors.newSingleThreadExecutor(): Un único hilo secuencial garantizado.
  • Executors.newScheduledThreadPool(n): Permite programar tareas con retrasos o ejecuciones periódicas.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
public static void main(String[] args) {
// Pool con 4 hilos trabajadores reutilizables
ExecutorService pool = Executors.newFixedThreadPool(4);

// Envío de 10 tareas concurrentes
for (int i = 1; i <= 10; i++) {
final int taskId = i;
pool.submit(() -> {
System.out.println("Tarea " + taskId + " ejecutada por: "
+ Thread.currentThread().getName());
});
}

// Cierre ordenado: no acepta nuevas tareas y espera a que terminen las encoladas
pool.shutdown();
}
}

Salida de ejecución:​

$ javac ThreadPoolExample.java && java ThreadPoolExample
Tarea 1 ejecutada por: pool-1-thread-1
Tarea 2 ejecutada por: pool-1-thread-2
Tarea 3 ejecutada por: pool-1-thread-3
Tarea 4 ejecutada por: pool-1-thread-4
Tarea 5 ejecutada por: pool-1-thread-1
Tarea 6 ejecutada por: pool-1-thread-2
Tarea 7 ejecutada por: pool-1-thread-3
Tarea 8 ejecutada por: pool-1-thread-4
Tarea 9 ejecutada por: pool-1-thread-1
Tarea 10 ejecutada por: pool-1-thread-2

Código fuente disponible en ThreadPoolExample.java.

Arquitectura interna de ThreadPoolExecutor:​

Por debajo de las factorías de Executors, la clase central es ThreadPoolExecutor. Su flujo de toma de decisiones al recibir una tarea es el siguiente:

TAREA ENTRANTE (Runnable / Callable)
│
▼
┌───────────────────────────────────────┐
│ ¿Hilos activos < corePoolSize? │
└───────┬───────────────────────┬───────┘
│ SÍ │ NO
▼ ▼
┌───────────────────────┐ ┌────────────────────────────────┐
│ Crea nuevo Hilo Worker│ │ ¿workQueue (BlockingQueue) │
│ y ejecuta de inmediato│ │ tiene espacio libre? │
└───────────────────────┘ └───────┬────────────────┬───────┘
│ SÍ │ NO
▼ ▼
┌──────────────────┐ ┌───────────────────────┐
│ Encola la tarea │ │ ¿Hilos < maxPoolSize? │
│ en workQueue │ └──────┬────────┬───────┘
└──────────────────┘ │ SÍ │ NO
▼ ▼
┌──────────────┐ ┌───────────────┐
│ Crea Worker │ │ RECHAZO: │
│ adicional │ │ Rejected- │
│ temporal │ │ Execution- │
│ (burst) │ │ Handler │
└──────────────┘ └───────────────┘

Políticas de saturación y rechazo (RejectedExecutionHandler):​

Cuando la cola está llena y se alcanza el número máximo de hilos, entran en juego las políticas de rechazo:

  1. AbortPolicy (por defecto): Lanza una excepción en tiempo de ejecución RejectedExecutionException.
  2. CallerRunsPolicy: El hilo que envió la tarea (main, por ejemplo) la ejecuta él mismo. Esto ralentiza de forma natural al productor generando backpressure.
  3. DiscardPolicy: Descarta la tarea silenciosamente sin avisar.
  4. DiscardOldestPolicy: Expulsa la tarea más antigua de la cola y vuelve a intentar encolar la nueva.

Dimensionamiento óptimo de hilos:​

  • Tareas intensivas en CPU (CPU-bound): Nhilos=Ncores+1N_{\text{hilos}} = N_{\text{cores}} + 1. Minimiza los cambios de contexto manteniendo saturados todos los núcleos físicos.
  • Tareas intensivas en E/S (I/O-bound): Nhilos=Ncores×(1+Tiempo de esperaTiempo de coˊmputo)N_{\text{hilos}} = N_{\text{cores}} \times \left(1 + \frac{\text{Tiempo de espera}}{\text{Tiempo de cómputo}}\right). Si las tareas pasan el 80% del tiempo esperando datos de red o disco y el 20% computando, podemos mantener 5 veces más hilos que núcleos sin degradación.

7.3. Tareas con retorno de valor: Callable<V> y Future<V>​

En la sección anterior enviamos tareas mediante Runnable. Sin embargo, Runnable presenta dos limitaciones notables:

  1. Su método run() devuelve void (no puede devolver ningún resultado del cómputo).
  2. No puede lanzar ninguna excepción comprobada (checked exception).

Para cálculos que devuelven resultados o propagan errores, Java proporciona la interfaz funcional Callable<V>, cuyo método V call() throws Exception devuelve un tipo genérico V:

import java.util.concurrent.*;

public class CallableFutureExample {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);

// Definimos una tarea asíncrona que produce un resultado
Callable<Long> tareaPesada = () -> {
System.out.println("[" + Thread.currentThread().getName() + "] Iniciando cálculo...");
long suma = 0;
for (long i = 1; i <= 20_000_000L; i++) {
suma += i;
}
Thread.sleep(200); // Simula latencia de procesamiento
System.out.println("[" + Thread.currentThread().getName() + "] Cálculo terminado.");
return suma;
};

// pool.submit() devuelve de inmediato un Future que representa el resultado pendiente
Future<Long> futuro = pool.submit(tareaPesada);

System.out.println("[main] Tarea enviada. El hilo principal continúa trabajando...");

try {
// Consulta no bloqueante de estado
if (!futuro.isDone()) {
System.out.println("[main] La tarea sigue computando en segundo plano...");
}

// future.get() BLOQUEA al hilo llamador hasta obtener el resultado o expirar el timeout
Long resultado = futuro.get(2, TimeUnit.SECONDS);
System.out.println("[main] Resultado obtenido con éxito: " + resultado);

} catch (TimeoutException e) {
System.err.println("[main] La tarea excedió el tiempo límite.");
futuro.cancel(true); // Cancela la tarea enviando una interrupción al worker
} catch (InterruptedException e) {
System.err.println("[main] Hilo principal interrumpido mientras esperaba.");
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
System.err.println("[main] La tarea lanzó una excepción: " + e.getCause());
} finally {
pool.shutdown();
}
}
}

Salida de ejecución:​

$ javac CallableFutureExample.java && java CallableFutureExample
[main] Tarea enviada. El hilo principal continúa trabajando...
[main] La tarea sigue computando en segundo plano...
[pool-1-thread-1] Iniciando cálculo...
[pool-1-thread-1] Cálculo terminado.
[main] Resultado obtenido con éxito: 200000010000000

Código fuente disponible en CallableFutureExample.java.

Métodos esenciales de la interfaz Future<V>:​

  • V get(): Espera bloqueante indefinida hasta que la tarea finaliza y retorna el valor. Si la tarea falló, relanza la causa envuelta en una ExecutionException.
  • V get(long timeout, TimeUnit unit): Espera como máximo el tiempo indicado; si no concluye en ese intervalo, lanza TimeoutException evitando congelaciones indefinidas.
  • boolean isDone(): Consulta no bloqueante; devuelve true si la tarea ha concluido (con éxito, error o cancelación).
  • boolean cancel(boolean mayInterruptIfRunning): Intenta cancelar la ejecución de la tarea. Si se pasa true, envía una señal de interrupción al hilo trabajador si ya estaba corriendo.

7.4. Thread pools en otros lenguajes​

En Python (concurrent.futures)​

En Python, el módulo estándar concurrent.futures ofrece ejecutores de alto nivel con una interfaz muy similar a la de Java (submit devuelve un objeto Future y result() equivale a get()):

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def tarea(id_tarea):
time.sleep(0.3) # Simula latencia de E/S (durante la cual CPython cede el GIL)
return f"Tarea {id_tarea} completada por un hilo del pool"

if __name__ == "__main__":
# Creamos un pool de 4 hilos trabajadores
with ThreadPoolExecutor(max_workers=4) as pool:
# Enviamos 8 tareas asíncronas (devuelve una lista de objetos Future)
futuros = [pool.submit(tarea, i) for i in range(1, 9)]

# as_completed() itera sobre los futuros según van finalizando
for f in as_completed(futuros):
print(f.result())
Hilos frente a Procesos en Python: el GIL
  • ThreadPoolExecutor (E/S intensiva): Ideal para operaciones que pasan tiempo esperando a la red o al disco (open, read, peticiones HTTP). Durante las llamadas al sistema operativo, CPython libera el GIL (Global Interpreter Lock), permitiendo una concurrencia real muy ligera.
  • ProcessPoolExecutor (CPU intensiva): Si la tarea requiere cómputo matemático o procesamiento pesado en CPU, el GIL serializaría los hilos en un único núcleo. Basta con sustituir ThreadPoolExecutor por ProcessPoolExecutor para distribuir el trabajo en procesos separados en todos los núcleos de CPU, manteniendo exactamente la misma API (submit, map, as_completed).

En Rust (threadpool crate):​

use threadpool::ThreadPool;
use std::sync::mpsc::channel;

fn main() {
let pool = ThreadPool::new(4);
let (tx, rx) = channel();

for i in 0..6 {
let tx = tx.clone();
pool.execute(move || {
tx.send(i * i).unwrap();
});
}
drop(tx); // Cierra el transmisor para que el iterador finalice

for res in rx {
println!("Resultado: {}", res);
}
}

8. Concurrencia moderna: el paquete java.util.concurrent​

A partir de Java 5, la plataforma introdujo el paquete java.util.concurrent (JUC), diseñado por Doug Lea, ofreciendo herramientas de alto rendimiento que superan las limitaciones históricas de los monitores intrínsecos.

8.1. Candados explícitos (Lock) y múltiples condiciones (Condition)​

Los monitores intrínsecos de Java tienen una limitación de diseño fundamental: cada objeto solo posee una única cola de espera condicional (wait set). Si distintos hilos esperan por razones diferentes (por ejemplo, productores esperando hueco libre y consumidores esperando datos), un notify() despierta indistintamente a cualquiera de ellos, obligando a emplear el costoso notifyAll().

La interfaz Lock y sus variables de condición (Condition) permiten asociar múltiples colas de espera independientes a un mismo cerrojo:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;

class BoundedBufferJUC {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // Cola exclusiva para productores
private final Condition notEmpty = lock.newCondition(); // Cola exclusiva para consumidores

private final Object[] items = new Object[100];
private int putptr, takeptr, count;

public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await(); // Espera ÚNICAMENTE en la cola notFull
items[putptr] = x;
if (++putptr == items.length) putptr = 0;
count++;
notEmpty.signal(); // Despierta ÚNICAMENTE a un consumidor esperando
} finally {
lock.unlock();
}
}

public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await(); // Espera ÚNICAMENTE en la cola notEmpty
Object x = items[takeptr];
if (++takeptr == items.length) takeptr = 0;
count--;
notFull.signal(); // Despierta ÚNICAMENTE a un productor esperando
return x;
} finally {
lock.unlock();
}
}
}

Filósofos con una Condition independiente por comensal (PhilosopherConditions.java):​

En PhilosopherConditions.java, el monitor crea un array de condiciones canEat.get(i) donde cada comensal espera en su propia cola. Al liberar los palillos, el comensal puede despertar quirúrgicamente a sus dos vecinos (canEat.get(vecino).signal()) sin necesidad de despertar a todos los demás filósofos de la mesa:

class TableConditions {
final Lock lock = new ReentrantLock();
final int n;
final Integer[] forks;
final ArrayList<Condition> canEat = new ArrayList<>();

public TableConditions(int n) {
this.n = n;
this.forks = new Integer[n];
Arrays.fill(this.forks, 2); // 2 = ambos palillos adyacentes están libres
for (int i = 0; i < n; i++) {
canEat.add(lock.newCondition()); // Una condición independiente por comensal
}
}

void pick(int i) {
lock.lock();
try {
while (forks[i] != 2) {
canEat.get(i).await(); // Duerme únicamente en su propia cola
}
forks[left(i)]--;
forks[right(i)]--;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}

void release(int i) {
lock.lock();
try {
forks[left(i)]++;
forks[right(i)]++;
// Señalización selectiva y quirúrgica:
if (forks[left(i)] == 2) canEat.get(left(i)).signal();
if (forks[right(i)] == 2) canEat.get(right(i)).signal();
} finally {
lock.unlock();
}
}
// ... métodos auxiliares left(i) y right(i)
}

8.2. Prevención de interbloqueos con adquisición no bloqueante (tryLock)​

Uno de los mayores inconvenientes de la palabra clave synchronized es su falta de flexibilidad ante la contención: si un hilo intenta entrar en un bloque synchronized y el cerrojo está ocupado, el hilo queda bloqueado indefinidamente. No existe forma de abortar la espera ni de fijar un tiempo límite (timeout), lo que propicia situaciones de interbloqueo (deadlock) difíciles de depurar.

La interfaz Lock solventa esta debilidad proporcionando métodos de adquisición condicional y temporizada:

1. Intento inmediato sin espera (tryLock())​

Permite implementar el algoritmo de "dar un paso atrás" (back-off) para evitar el interbloqueo al necesitar dos o más recursos simultáneamente:

Lock cerrojoA = new ReentrantLock();
Lock cerrojoB = new ReentrantLock();

public void transferirRecursos() {
if (cerrojoA.tryLock()) {
try {
if (cerrojoB.tryLock()) {
try {
// Adquiridos ambos cerrojos con éxito en exclusión mutua
realizarOperacionCritica();
} finally {
cerrojoB.unlock();
}
} else {
// No pudo obtener el cerrojo B: libera A y reintenta más tarde sin bloquear
System.out.println("Cerrojo B ocupado. Liberando A para evitar interbloqueo...");
}
} finally {
cerrojoA.unlock();
}
}
}

2. Adquisición con tiempo límite (tryLock(timeout, unit))​

if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
// Cerrojo adquirido en menos de 500 ms
ejecutarTarea();
} finally {
lock.unlock();
}
} else {
// Transcurrido el timeout sin éxito: registrar aviso o seguir camino alternativo
System.err.println("No se pudo adquirir el cerrojo tras 500 ms");
}

3. Adquisición interrumpible (lockInterruptibly())​

A diferencia de synchronized (que ignora las interrupciones mientras espera adquirir el cerrojo), un hilo bloqueado en lock.lockInterruptibly() despierta inmediatamente arrojando InterruptedException si otro hilo invoca thread.interrupt().


8.3. Colecciones concurrentes seguras​

En lugar de sincronizar manualmente colecciones estándar (como Vector o envoltorios Collections.synchronizedList), JUC ofrece estructuras concurrentes optimizadas sin contención global:

  • BlockingQueue<E> (ej. ArrayBlockingQueue, LinkedBlockingQueue): Colas con operaciones bloqueantes put() y take() integradas, ideales para arquitecturas productor-consumidor directas en una sola línea de código.
  • ConcurrentHashMap<K, V>: Tabla hash particionada en segmentos independientes mediante cerrojos de grano fino y lecturas libres de cerrojo (lock-free), ofreciendo un rendimiento inmensamente superior a colecciones sincronizadas globales.
  • CopyOnWriteArrayList<E>: Crea una copia completa del array subyacente ante cada escritura, permitiendo que miles de lectores iteren concurrentemente a máxima velocidad sin ningún bloqueo ni riesgo de ConcurrentModificationException.

8.4. Tipos atómicos sin cerrojo (java.util.concurrent.atomic)​

Para contadores y variables de estado simples, el paquete java.util.concurrent.atomic proporciona tipos como AtomicInteger, AtomicLong y AtomicBoolean.

  • Se fundamentan en instrucciones atómicas del procesador como Compare-And-Swap (CAS) (LOCK CMPXCHG en x86, LDREX/STREX en ARM).
  • Permiten actualizar el valor sin necesidad de bloquear hilos ni incurrir en el sobrecoste de llamadas al sistema operativo (lock-free):
import java.util.concurrent.atomic.AtomicInteger;

public class ContadorAtomico {
private final AtomicInteger total = new AtomicInteger(0);

public void incrementar() {
total.incrementAndGet(); // Operación atómica indivisible por hardware CAS
}

public int obtener() {
return total.get();
}
}

¿Cómo opera internamente el bucle CAS optimista?​

Bucle interno en AtomicInteger:
┌────────────────────────────────────────────────────────────────────────┐
│ do { │
│ actual = total.get(); // Paso 1: Lee el valor actual │
│ siguiente = actual + 1; // Paso 2: Calcula localmente en CPU │
│ } while (!total.compareAndSet(actual, siguiente)); // Paso 3: CAS │
└───────────────────────────────────┬────────────────────────────────────┘
│
¿Falló compareAndSet porque otro hilo modificó
el valor en RAM entre el Paso 1 y el Paso 3?
├──► SÍ: Reintenta de inmediato en CPU sin ir al kernel.
└──► NO: Éxito atómico al primer intento (sin bloqueo).

9. Código fuente disponible​

Todos los ejemplos analizados a lo largo de este tema se encuentran disponibles para su consulta, descarga y ejecución:

Monitores intrínsecos y problemas clásicos en Java​

Concurrencia moderna y ThreadPools​