Módulo 06: Concurrencia: fundamentos
Objetivos del módulo
- Distinguir procesos e hilos, y programación concurrente, paralela y distribuida
- Lanzar procesos del sistema operativo desde Java con
ProcessBuilder - Crear hilos con
Thread,Runnable, lambdas y los constructores de Java 21 - Conocer el ciclo de vida de un hilo y controlarlo con
sleep,joineinterrupt - Entender las prioridades y los hilos daemon
- Reproducir condiciones de carrera reales y corregirlas con
synchronized - Comprender la visibilidad de memoria y el papel de
volatile - Coordinar hilos con
wait/notifyAlly resolver el problema productor-consumidor - Provocar, detectar y evitar interbloqueos (deadlocks)
- Usar cerrojos explícitos (
ReentrantLock,ReadWriteLock,Condition) y clases atómicas (AtomicInteger,LongAdder) - Resolver problemas clásicos como la cena de los filósofos
1. Procesos e hilos
📘 Concepto: Un proceso es un programa en ejecución: tiene su propio espacio de memoria, sus ficheros abiertos y al menos un flujo de ejecución. Un hilo (thread) es un flujo de ejecución dentro de un proceso. Todos los hilos de un proceso comparten la memoria del proceso (en Java, el heap), pero cada uno tiene su propia pila de llamadas y su propio contador de programa.
Piensa en un restaurante. Cada restaurante es un proceso: tiene su cocina, su almacén y su caja, y no comparte nada con el restaurante de enfrente. Los cocineros son hilos: trabajan a la vez en la misma cocina, usan los mismos fogones y la misma nevera… y precisamente por eso tienen que coordinarse para no coger la misma sartén al mismo tiempo.
Figura 1. Procesos e hilos: los hilos de una JVM comparten el heap; cada proceso tiene su memoria aislada.
| Proceso | Hilo | |
|---|---|---|
| Memoria | Propia y aislada | Compartida con los demás hilos del proceso |
| Coste de creación | Alto (el SO reserva memoria, tablas…) | Bajo (solo una pila y estructuras de control) |
| Comunicación | IPC: tuberías, ficheros, sockets | Directa: leyendo y escribiendo los mismos objetos |
| Un fallo grave… | Solo afecta a ese proceso | Puede tumbar todo el proceso |
| Riesgo principal | Comunicación lenta | Condiciones de carrera e interbloqueos |
| En Java | ProcessBuilder, Process, ProcessHandle | Thread, Runnable, executors |
Concurrente, paralelo y distribuido
- Programación concurrente: varias tareas progresan en el mismo periodo de tiempo, aunque no necesariamente en el mismo instante. Con un solo núcleo, el sistema operativo alterna rápidamente entre ellas (multitarea).
- Programación paralela: varias tareas se ejecutan literalmente a la vez en núcleos distintos. Requiere varios núcleos y busca rendimiento.
- Programación distribuida: las tareas se ejecutan en máquinas distintas que se comunican por red (lo verás en el módulo 09).
La concurrencia es una forma de estructurar un programa (un servidor que atiende a muchos clientes, una interfaz que no se congela mientras descarga); el paralelismo es una forma de ejecutarlo más rápido. Un programa concurrente puede o no ejecutarse en paralelo, según los núcleos disponibles.
Lanzar procesos desde Java
Aunque este módulo trata sobre hilos, en Programación de Servicios y Procesos también se lanzan procesos del sistema operativo. Java lo hace con ProcessBuilder:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.concurrent.TimeUnit;
public class Procesos {
public static void main(String[] args) throws IOException, InterruptedException {
// Información del proceso actual (Java 9: ProcessHandle)
ProcessHandle yo = ProcessHandle.current();
System.out.println("PID de esta JVM: " + yo.pid());
System.out.println("Núcleos disponibles: " + Runtime.getRuntime().availableProcessors());
System.out.println("Hilo actual: " + Thread.currentThread().getName());
// Lanzar un proceso hijo: el comando "ls -1 /etc/apt" del sistema operativo
ProcessBuilder pb = new ProcessBuilder("ls", "-1", "/etc/apt");
pb.redirectErrorStream(true); // mezcla stderr con stdout
Process hijo = pb.start();
System.out.println("Lanzado proceso hijo con PID " + hijo.pid());
try (BufferedReader salida = new BufferedReader(new InputStreamReader(hijo.getInputStream()))) {
String linea;
while ((linea = salida.readLine()) != null) {
System.out.println(" [hijo] " + linea);
}
}
int codigo = hijo.waitFor(); // espera a que termine
System.out.println("El hijo terminó con código " + codigo);
// Un proceso que tarda: lo esperamos con tiempo máximo y lo matamos
Process lento = new ProcessBuilder("sleep", "10").start();
boolean acabo = lento.waitFor(1, TimeUnit.SECONDS);
System.out.println("¿sleep 10 acabó en 1 s? " + acabo);
lento.destroy();
System.out.println("¿Terminó tras destroy()? " + lento.waitFor(2, TimeUnit.SECONDS) + " -> código " + lento.exitValue());
}
}
Ejecución real en el contenedor Linux del JDK 25:
docker run --rm -v "$PWD:/w" -w /w maven:3.9-eclipse-temurin-25 java Procesos.java
PID de esta JVM: 775
Núcleos disponibles: 8
Hilo actual: main
Lanzado proceso hijo con PID 803
[hijo] apt.conf.d
[hijo] auth.conf.d
[hijo] keyrings
[hijo] preferences.d
[hijo] sources.list
[hijo] sources.list.d
[hijo] trusted.gpg.d
El hijo terminó con código 0
¿sleep 10 acabó en 1 s? false
¿Terminó tras destroy()? true -> código 143
El código de salida 0 significa “terminado correctamente”. El 143 es 128 + 15: el proceso murió por la señal SIGTERM que envía destroy() (destroyForcibly() envía SIGKILL, 137). En Windows usarías comandos como new ProcessBuilder("cmd", "/c", "dir").
⚠️ Importante: Lee siempre la salida del proceso hijo (o redirígela con
pb.redirectOutput(...)opb.inheritIO()). Si el hijo escribe mucho y nadie lee, el búfer de la tubería se llena, el hijo se bloquea ywaitFor()no vuelve nunca.
2. Crear hilos
En Java un hilo se representa con un objeto java.lang.Thread. El código que ejecuta es un Runnable: una interfaz funcional con un único método void run(). Hay varias formas de juntar ambos.
public class CrearHilos {
// Forma 1: heredar de Thread y sobrescribir run()
static class HiloContador extends Thread {
HiloContador(String nombre) {
super(nombre);
}
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
System.out.println(getName() + " -> " + i);
}
}
}
// Forma 2: implementar Runnable (la tarea) y dársela a un Thread (el trabajador)
static class Saludo implements Runnable {
@Override
public void run() {
System.out.println("Hola desde " + Thread.currentThread().getName());
}
}
public static void main(String[] args) throws InterruptedException {
Thread h1 = new HiloContador("Contador-A");
Thread h2 = new Thread(new Saludo(), "Saludador");
// Forma 3: lambda (Runnable es una interfaz funcional)
Thread h3 = new Thread(() -> System.out.println("Lambda en " + Thread.currentThread().getName()), "Lambda");
// Forma 4: constructores de Java 21 (Thread.Builder)
Thread h4 = Thread.ofPlatform().name("Builder").unstarted(
() -> System.out.println("Builder en " + Thread.currentThread().getName()));
h1.start(); // start() crea el hilo del SO y ejecuta run() en él
h2.start();
h3.start();
h4.start();
System.out.println("main sigue trabajando mientras tanto");
h1.join(); // esperamos a que terminen todos
h2.join();
h3.join();
h4.join();
// ERROR típico: llamar a run() en lugar de start()
Thread mal = new Thread(() -> System.out.println("run() ejecutado por: " + Thread.currentThread().getName()));
mal.run(); // ¡no crea ningún hilo! lo ejecuta main
System.out.println("Fin de main");
}
}
Una ejecución real:
Hola desde Saludador
Lambda en Lambda
Contador-A -> 1
Contador-A -> 2
Contador-A -> 3
main sigue trabajando mientras tanto
Builder en Builder
run() ejecutado por: main
Fin de main
⚠️ Importante: El orden de las líneas no está garantizado. Fíjate en que “main sigue trabajando” salió después de los mensajes de tres hilos que se arrancaron después… en esta ejecución. Si lo lanzas varias veces verás órdenes distintos. Solo las dos últimas líneas tienen orden fijo, porque se ejecutan después de los
join().
| Forma | Ventajas | Inconvenientes |
|---|---|---|
extends Thread | Sencilla, acceso directo a getName() | Gastas la herencia (Java no tiene herencia múltiple); mezcla “qué hacer” con “quién lo hace” |
implements Runnable | Separa la tarea del trabajador; la tarea se puede reutilizar en executors | Algo más de código |
| Lambda | Muy concisa para tareas cortas | Solo para tareas simples |
Thread.ofPlatform() / Thread.ofVirtual() (21) | API fluida: nombre, daemon, prioridad…; también hilos virtuales | Requiere Java 21 |
💡 Consejo: Prefiere
Runnable(o lambdas) a heredar deThread. Además, en el código profesional casi nunca se crean hilos “a mano”: se usan pools de hilos (ExecutorService) e hilos virtuales, que verás en el módulo 07. Pero para entenderlos necesitas dominar antes los fundamentos de este módulo.
⚠️ Importante:
start()crea un hilo nuevo y llama arun()en ese hilo. Llamar arun()directamente es una llamada normal a un método que ejecuta el hilo actual (la salida muestrarun() ejecutado por: main). Y un hilo solo se puede arrancar una vez: un segundostart()lanzaIllegalThreadStateException.
3. El ciclo de vida de un hilo
Un hilo pasa por varios estados, definidos en el enumerado Thread.State:
Figura 2. Ciclo de vida de un hilo y las transiciones entre sus estados.
| Estado | Significado | Se entra con… |
|---|---|---|
NEW | Creado pero no arrancado | new Thread(...) |
RUNNABLE | Ejecutándose o listo para ejecutarse (esperando CPU) | start() |
BLOCKED | Esperando un cerrojo de monitor que tiene otro hilo | Entrar en un bloque synchronized ocupado |
WAITING | Esperando indefinidamente a otro hilo | wait(), join(), LockSupport.park() |
TIMED_WAITING | Esperando con tiempo máximo | sleep(ms), wait(ms), join(ms) |
TERMINATED | run() ha terminado (normalmente o con excepción) | Fin de run() |
Java no distingue entre “listo” y “en ejecución”: ambos son RUNNABLE, y es el planificador del sistema operativo quien decide qué hilo usa cada núcleo en cada momento.
💻 Ejemplo: un programa que hace pasar a un hilo por todos los estados y los consulta con
getState().
public class EstadosHilo {
private static final Object CERROJO = new Object();
public static void main(String[] args) throws InterruptedException {
Thread dormilon = new Thread(() -> {
try {
Thread.sleep(500); // TIMED_WAITING
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
synchronized (CERROJO) { // BLOCKED si main tiene el cerrojo
try {
CERROJO.wait(); // WAITING hasta notify()
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
long suma = 0;
for (long i = 0; i < 2_000_000_000L; i++) { // RUNNABLE: cálculo intensivo
suma += i;
}
System.out.println("Suma calculada: " + suma);
}, "Dormilon");
System.out.println("1. Tras new: " + dormilon.getState());
dormilon.start();
Thread.sleep(100);
System.out.println("2. Durante sleep: " + dormilon.getState());
synchronized (CERROJO) {
Thread.sleep(600); // retenemos el cerrojo
System.out.println("3. Esperando cerrojo: " + dormilon.getState());
}
Thread.sleep(100);
System.out.println("4. Dentro de wait(): " + dormilon.getState());
synchronized (CERROJO) {
CERROJO.notify();
}
Thread.sleep(100);
System.out.println("5. Calculando: " + dormilon.getState());
dormilon.join();
System.out.println("6. Tras terminar: " + dormilon.getState());
}
}
1. Tras new: NEW
2. Durante sleep: TIMED_WAITING
3. Esperando cerrojo: BLOCKED
4. Dentro de wait(): WAITING
5. Calculando: RUNNABLE
Suma calculada: 1999999999000000000
6. Tras terminar: TERMINATED
💡 Consejo:
getState()es útil para depurar y para aprender, pero no para sincronizar: el estado puede cambiar justo después de consultarlo. LosThread.sleep(100)del ejemplo solo sirven para “dar tiempo” a que el otro hilo llegue a cada punto; en código real nunca se coordinan hilos consleep.
4. Controlar hilos: sleep, join, interrupción, prioridades y daemon
import java.time.Duration;
public class ControlHilos {
static final long INICIO = System.nanoTime();
// Muestra los milisegundos transcurridos desde el arranque y el hilo que escribe
static void log(String msg) {
long ms = (System.nanoTime() - INICIO) / 1_000_000;
System.out.printf("%5d ms [%s] %s%n", ms, Thread.currentThread().getName(), msg);
}
public static void main(String[] args) throws InterruptedException {
// --- join con tiempo máximo ---
Thread descarga = new Thread(() -> {
log("descargando fichero...");
try {
Thread.sleep(Duration.ofMillis(800)); // sleep(Duration) existe desde Java 19
} catch (InterruptedException e) {
log("¡interrumpido!");
return;
}
log("descarga completada");
}, "descarga");
descarga.start();
descarga.join(300); // esperamos como mucho 300 ms
log("tras join(300): ¿sigue vivo? " + descarga.isAlive());
descarga.join(); // ahora sí, hasta que acabe
log("tras join(): ¿sigue vivo? " + descarga.isAlive());
// --- interrupción de un hilo dormido ---
Thread vigilante = new Thread(() -> {
while (true) {
try {
log("vigilando...");
Thread.sleep(400);
} catch (InterruptedException e) {
log("me piden parar: limpio y termino");
return;
}
}
}, "vigilante");
vigilante.start();
Thread.sleep(1000);
vigilante.interrupt(); // solicitud cortés de parada
vigilante.join();
// --- interrupción de un hilo que calcula (no duerme): debe consultar la marca ---
Thread calculador = new Thread(() -> {
long n = 0;
while (!Thread.currentThread().isInterrupted()) {
n++;
}
log("detectada la interrupción tras " + n + " vueltas");
}, "calculador");
calculador.start();
Thread.sleep(200);
calculador.interrupt();
calculador.join();
// --- hilos daemon: no impiden que la JVM termine ---
Thread latido = new Thread(() -> {
while (true) {
log("latido");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
return;
}
}
}, "latido");
latido.setDaemon(true); // antes de start()
latido.setPriority(Thread.MIN_PRIORITY); // solo una sugerencia al planificador
latido.start();
log("prioridad de latido = " + latido.getPriority() + ", de main = " + Thread.currentThread().getPriority());
Thread.sleep(700);
log("main termina; el daemon muere con la JVM");
}
}
126 ms [descarga] descargando fichero...
430 ms [main] tras join(300): ¿sigue vivo? true
1204 ms [descarga] descarga completada
1215 ms [main] tras join(): ¿sigue vivo? false
1222 ms [vigilante] vigilando...
1625 ms [vigilante] vigilando...
2036 ms [vigilante] vigilando...
2223 ms [vigilante] me piden parar: limpio y termino
2433 ms [calculador] detectada la interrupción tras 68046982 vueltas
2461 ms [latido] latido
2587 ms [main] prioridad de latido = 1, de main = 5
2793 ms [latido] latido
3127 ms [latido] latido
3321 ms [main] main termina; el daemon muere con la JVM
Los tiempos son reales y variarán en tu equipo: esta ejecución se hizo en un ordenador muy cargado, por eso el sleep(800) de la descarga tardó casi 1100 ms. sleep garantiza que el hilo duerme al menos ese tiempo, no exactamente ese tiempo.
sleep y join
| Método | Qué hace |
|---|---|
Thread.sleep(ms) / Thread.sleep(Duration) | El hilo actual duerme (pasa a TIMED_WAITING) sin soltar los cerrojos que tenga |
t.join() | El hilo actual espera a que t termine |
t.join(ms) | Espera como mucho ms milisegundos; luego comprueba con t.isAlive() |
📘 Concepto:
join()no solo espera: también establece una relación happens-before. Todo lo que escribió el hilotantes de terminar es visible para el hilo que hacet.join(). Por eso, tras unjoin, se pueden leer sin problemas los resultados que el otro hilo dejó en sus campos.
Interrupción: parar un hilo correctamente
Java no tiene una forma segura de matar un hilo (Thread.stop() era peligrosísimo: dejaba objetos a medio modificar y en Java 20 pasó a lanzar siempre UnsupportedOperationException). En su lugar existe la interrupción, una petición cortés:
- Otro hilo llama a
t.interrupt(): se activa una marca ent. - Si
testá dormido o esperando (sleep,wait,join…), se despierta con unaInterruptedException(y la marca se borra). - Si
testá calculando, nadie le detiene: debe consultar periódicamenteThread.currentThread().isInterrupted(). tdecide cómo terminar: libera recursos, guarda el trabajo… y sale derun().
⚠️ Importante: Nunca “te tragues” una
InterruptedExceptioncon uncatchvacío. Si no puedes terminar en ese punto, restaura la marca conThread.currentThread().interrupt()para que el código de más arriba se entere. Es lo que hacen los ejemplos de este módulo.
// MAL: la petición de parada se pierde
try { Thread.sleep(1000); } catch (InterruptedException e) { }
// BIEN: o terminas, o restauras la marca
try { Thread.sleep(1000); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
Prioridades
Cada hilo tiene una prioridad entre Thread.MIN_PRIORITY (1) y Thread.MAX_PRIORITY (10); por defecto, NORM_PRIORITY (5), heredada del hilo que lo crea. La prioridad es solo una sugerencia al planificador del sistema operativo: en Linux, la JVM la ignora por defecto, y en Windows tiene un efecto limitado. Nunca bases la corrección de un programa en las prioridades: si un algoritmo solo funciona cuando un hilo “va primero”, está mal sincronizado.
Hilos daemon
La JVM termina cuando acaban todos los hilos no daemon (los “de usuario”). Los hilos daemon son “de servicio”: se usan para tareas de fondo (latidos, limpieza de cachés, monitorización) y mueren automáticamente cuando la JVM termina. En la salida, el hilo latido estaba en un bucle infinito, pero el programa acabó cuando terminó main.
💡 Consejo:
setDaemon(true)debe llamarse antes destart(). No uses hilos daemon para tareas que deban completarse (escribir un fichero, enviar un correo): se cortan a mitad sin ejecutar ni siquiera los bloquesfinally.
5. Condiciones de carrera
Ha llegado el momento del problema central de la concurrencia. Cuatro hilos incrementan un millón de veces cada uno un contador compartido. El resultado debería ser 4 000 000.
public class CondicionCarrera {
static class Contador {
private int valor = 0;
void incrementar() {
valor++; // ¡NO es atómico! leer, sumar 1 y escribir
}
int getValor() {
return valor;
}
}
public static void main(String[] args) throws InterruptedException {
final int HILOS = 4;
final int INCREMENTOS = 1_000_000;
for (int intento = 1; intento <= 5; intento++) {
Contador c = new Contador();
Thread[] hilos = new Thread[HILOS];
for (int i = 0; i < HILOS; i++) {
hilos[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTOS; j++) {
c.incrementar();
}
});
hilos[i].start();
}
for (Thread h : hilos) {
h.join();
}
int esperado = HILOS * INCREMENTOS;
System.out.printf("Intento %d: esperado %,d obtenido %,d (perdidos %,d)%n",
intento, esperado, c.getValor(), esperado - c.getValor());
}
}
}
Salida real:
Intento 1: esperado 4.000.000 obtenido 3.982.425 (perdidos 17.575)
Intento 2: esperado 4.000.000 obtenido 4.000.000 (perdidos 0)
Intento 3: esperado 4.000.000 obtenido 3.999.172 (perdidos 828)
Intento 4: esperado 4.000.000 obtenido 3.996.533 (perdidos 3.467)
Intento 5: esperado 4.000.000 obtenido 4.000.000 (perdidos 0)
Se han perdido incrementos, y en una cantidad distinta cada vez. Y lo peor: en dos de los cinco intentos el resultado fue correcto. Ese es el peligro real de las condiciones de carrera: el programa pasa las pruebas en tu equipo y falla en producción, con más carga o en otra máquina. Cuando lo ejecutes tú, obtendrás otros números.
📘 Concepto: Hay una condición de carrera (race condition) cuando el resultado de un programa depende del orden en que se intercalan las instrucciones de varios hilos que acceden a datos compartidos y al menos uno de ellos escribe.
¿Por qué ocurre? Porque valor++ parece una instrucción, pero son tres: leer valor de memoria a un registro, sumarle 1 y escribir el resultado. El planificador puede quitarle la CPU a un hilo entre cualquiera de esos pasos:
Figura 3. Intercalado de dos valor++ que provoca una actualización perdida.
“Comprobar y actuar”
El segundo patrón peligroso es comprobar una condición y actuar después (check-then-act). Diez clientes intentan sacar 30 € a la vez de una cuenta con 100 €:
import java.util.ArrayList;
import java.util.List;
// La misma cuenta del ejemplo Sincronizado, pero SIN synchronized
public class CuentaInsegura {
private long saldo = 10_000;
boolean retirar(long cantidad) {
if (saldo >= cantidad) { // comprobar...
pausa(20); // otro hilo se cuela aquí
saldo -= cantidad; // ...y actuar
return true;
}
return false;
}
static void pausa(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
CuentaInsegura cuenta = new CuentaInsegura();
List<Thread> hilos = new ArrayList<>();
int[] exitos = new int[1];
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
pausa(100); // esperamos a que arranquen todos
if (cuenta.retirar(3_000)) {
synchronized (exitos) {
exitos[0]++;
}
}
});
hilos.add(t);
t.start();
}
for (Thread t : hilos) {
t.join();
}
System.out.println("Retiradas con éxito: " + exitos[0] + ", saldo final: " + cuenta.saldo + " céntimos");
}
}
Retiradas con éxito: 8, saldo final: -14000 céntimos
Ocho clientes vieron “hay saldo suficiente” antes de que nadie restara: el banco ha entregado 240 € de una cuenta con 100 € y el saldo es de −140 €. La pausa de 20 ms exagera la ventana para que el fallo sea visible en todas las ejecuciones, pero el problema existe aunque la ventana sea de nanosegundos.
| Patrón peligroso | Ejemplo | Por qué falla |
|---|---|---|
| Leer-modificar-escribir | contador++, saldo += x | Dos hilos leen el mismo valor viejo |
| Comprobar y actuar | if (saldo >= x) saldo -= x; | La condición deja de ser cierta antes de actuar |
| Inicialización perezosa | if (instancia == null) instancia = new X(); | Se crean dos instancias |
| Colecciones no seguras | Varios hilos haciendo list.add(...) en un ArrayList | Elementos perdidos, ArrayIndexOutOfBoundsException |
6. Sección crítica y synchronized
📘 Concepto: Una sección crítica es un fragmento de código que accede a un recurso compartido y que, por tanto, no debe ejecutar más de un hilo a la vez. La garantía de que solo un hilo está dentro se llama exclusión mutua.
En Java, todo objeto tiene asociado un monitor con un cerrojo (lock intrínseco). La palabra clave synchronized hace que un hilo adquiera ese cerrojo al entrar y lo libere al salir (incluso si sale por una excepción). Mientras lo tiene, cualquier otro hilo que intente entrar en un bloque synchronized sobre el mismo objeto queda en estado BLOCKED.
import java.util.ArrayList;
import java.util.List;
public class Sincronizado {
// 1. Método sincronizado: el cerrojo es "this"
static class ContadorSeguro {
private int valor = 0;
synchronized void incrementar() {
valor++;
}
synchronized int getValor() { // también la lectura, por visibilidad
return valor;
}
}
// 2. Bloque sincronizado con un cerrojo privado: solo protege la sección crítica
static class CuentaBancaria {
private final Object cerrojo = new Object();
private final String iban;
private long saldoCentimos;
private final List<String> movimientos = new ArrayList<>();
CuentaBancaria(String iban, long saldoInicial) {
this.iban = iban;
this.saldoCentimos = saldoInicial;
}
boolean retirar(long cantidad, String quien) {
String texto = quien + " intenta retirar " + cantidad; // fuera: no necesita cerrojo
synchronized (cerrojo) {
movimientos.add(texto);
if (saldoCentimos >= cantidad) { // comprobar...
pausa(20); // (simula trabajo)
saldoCentimos -= cantidad; // ...y actuar, sin que nadie se cuele
return true;
}
return false;
}
}
long getSaldo() {
synchronized (cerrojo) {
return saldoCentimos;
}
}
}
static void pausa(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
ContadorSeguro c = new ContadorSeguro();
List<Thread> hilos = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 1_000_000; j++) {
c.incrementar();
}
});
hilos.add(t);
t.start();
}
for (Thread t : hilos) {
t.join();
}
System.out.printf("Contador sincronizado: %,d%n", c.getValor());
// 10 personas intentan sacar 30 € a la vez de una cuenta con 100 €
CuentaBancaria cuenta = new CuentaBancaria("ES12 3456", 10_000);
hilos.clear();
int[] exitos = new int[1];
for (int i = 1; i <= 10; i++) {
String quien = "Cliente-" + i;
Thread t = new Thread(() -> {
pausa(100); // esperamos a que arranquen todos
if (cuenta.retirar(3_000, quien)) {
synchronized (exitos) {
exitos[0]++;
}
}
});
hilos.add(t);
t.start();
}
for (Thread t : hilos) {
t.join();
}
System.out.println("Retiradas con éxito: " + exitos[0] + ", saldo final: " + cuenta.getSaldo() + " céntimos");
}
}
Contador sincronizado: 4.000.000
Retiradas con éxito: 3, saldo final: 1000 céntimos
Ahora el resultado es correcto en todas las ejecuciones: 4 000 000 incrementos, y exactamente 3 retiradas de 30 € con 10 € de saldo final.
Métodos frente a bloques
| Forma | Cerrojo usado | Cuándo usarla |
|---|---|---|
synchronized void m() | this | Clases sencillas donde todo el método es sección crítica |
static synchronized void m() | El objeto Clase.class | Proteger datos static |
synchronized (obj) { ... } | obj | Proteger solo la parte necesaria; usar un cerrojo privado |
💡 Consejo: Prefiere bloques sobre un cerrojo privado (
private final Object cerrojo = new Object();): nadie de fuera puede usar tu cerrojo y provocarte un bloqueo, y la sección crítica es lo más corta posible (en el ejemplo, la construcción del texto queda fuera). Cuanto más corta la sección crítica, menos tiempo esperan los demás hilos.
Reglas del monitor
- Todos los accesos a un dato compartido mutable (lecturas incluidas) deben protegerse con el mismo cerrojo. Proteger las escrituras con un cerrojo y las lecturas con otro (o con ninguno) no sirve.
- Los cerrojos intrínsecos son reentrantes: el hilo que ya tiene el cerrojo puede volver a entrar. Si un método
synchronizedllama a otro métodosynchronizeddel mismo objeto, no se bloquea a sí mismo. synchronizedgarantiza también visibilidad: al salir de un bloque, los cambios se publican en memoria; al entrar, se ven los cambios del último hilo que salió.- No sincronices sobre objetos que puedan compartirse sin que lo sepas: cadenas literales (
"lock"),IntegeroBooleanen caché, ni objetos que cambian de referencia (synchronized (lista)si luego haceslista = new ArrayList<>()).
⚠️ Importante:
synchronizedtiene un coste: los hilos esperan en fila. Si sincronizas un método que tarda 1 segundo, diez hilos tardarán 10 segundos. Sincroniza lo justo: el acceso a los datos compartidos, no los cálculos ni la E/S.
7. volatile y la visibilidad
Hay un segundo problema, más sutil que las carreras: la visibilidad. Cada núcleo tiene sus cachés y el compilador JIT puede reordenar instrucciones o mantener valores en registros. Sin sincronización, nada garantiza que un hilo vea lo que escribe otro.
public class Visibilidad {
static boolean parar = false; // SIN volatile
static volatile boolean pararV = false; // CON volatile
public static void main(String[] args) throws InterruptedException {
Thread sinVolatile = new Thread(() -> {
long vueltas = 0;
while (!parar) { // el JIT puede leer 'parar' una sola vez
vueltas++;
}
System.out.println("Sin volatile: he parado tras " + vueltas + " vueltas");
});
sinVolatile.setDaemon(true); // para que la JVM pueda terminar aunque no pare
Thread conVolatile = new Thread(() -> {
long vueltas = 0;
while (!pararV) { // cada lectura va a memoria principal
vueltas++;
}
System.out.println("Con volatile: he parado (" + (vueltas > 0 ? "muchas" : "0") + " vueltas)");
});
sinVolatile.start();
conVolatile.start();
Thread.sleep(1000); // dejamos que el JIT optimice los bucles
System.out.println("main: pongo las dos banderas a true");
parar = true;
pararV = true;
conVolatile.join(2000);
sinVolatile.join(2000);
System.out.println("¿Sigue vivo el hilo con volatile? " + conVolatile.isAlive());
System.out.println("¿Sigue vivo el hilo sin volatile? " + sinVolatile.isAlive());
}
}
main: pongo las dos banderas a true
Con volatile: he parado (muchas vueltas)
¿Sigue vivo el hilo con volatile? false
¿Sigue vivo el hilo sin volatile? true
El hilo sin volatile no se ha parado nunca: el compilador JIT vio que dentro del bucle nadie modifica parar y transformó while (!parar) vueltas++; en algo equivalente a if (!parar) while (true) vueltas++;. Es una optimización perfectamente legal según el modelo de memoria de Java (JMM), porque el programa no tenía sincronización. (Si lo pruebas tú, en algunas JVM o máquinas puede que sí pare: el comportamiento no está garantizado en ningún sentido.)
📘 Concepto: Una variable
volatilegarantiza que cada lectura ve la última escritura de cualquier hilo y que no se reordena con las operaciones de alrededor. Establece una relación happens-before: todo lo que un hilo escribió antes de escribir la variablevolatilees visible para el hilo que la lee después.
volatile | synchronized | |
|---|---|---|
| Visibilidad | Sí | Sí |
| Atomicidad de una lectura o escritura | Sí (incluso long y double) | Sí |
Atomicidad de x++ o “comprobar y actuar” | No | Sí |
| Bloquea hilos | No | Sí |
| Uso típico | Banderas de parada, estado publicado por un hilo y leído por otros | Secciones críticas con varias operaciones |
⚠️ Importante:
volatileno arregla el contador del apartado 5:valor++seguiría siendo leer-sumar-escribir, y se seguirían perdiendo incrementos. Úsalo solo cuando un hilo escribe y los demás leen, o cuando cada escritura no depende del valor anterior.
8. Coordinación: wait, notify y el productor-consumidor
La exclusión mutua impide que dos hilos estropeen un dato, pero a veces un hilo necesita esperar a que otro haga algo: que haya un pedido que procesar, que quede una plaza libre… Esperar con un bucle de sleep (espera activa) desperdicia CPU y responde tarde. Para eso, todo monitor tiene una cola de espera:
Método (de Object) | Efecto |
|---|---|
wait() | Libera el cerrojo del monitor y duerme hasta que otro hilo haga notify/notifyAll (o lo interrumpan) |
wait(ms) | Igual, pero con tiempo máximo |
notify() | Despierta a un hilo cualquiera de la cola de espera |
notifyAll() | Despierta a todos los hilos de la cola de espera |
Los tres deben llamarse dentro de un bloque synchronized sobre el mismo objeto; si no, lanzan IllegalMonitorStateException. Al despertar, el hilo tiene que volver a conseguir el cerrojo antes de continuar.
El problema productor-consumidor
Es el problema clásico de coordinación: uno o varios productores generan datos y los dejan en un buffer de capacidad limitada; uno o varios consumidores los recogen. El productor debe esperar si el buffer está lleno; el consumidor, si está vacío. Aparece en colas de impresión, servidores web (peticiones que esperan un hilo libre), pipelines de procesamiento de datos, etc.
Figura 4. El problema productor-consumidor resuelto con un monitor.
import java.util.ArrayDeque;
import java.util.Queue;
public class ProductorConsumidor {
// Buffer acotado: el monitor que coordina a productores y consumidores
static class Buffer<T> {
private final Queue<T> cola = new ArrayDeque<>();
private final int capacidad;
Buffer(int capacidad) {
this.capacidad = capacidad;
}
public synchronized void poner(T elemento) throws InterruptedException {
while (cola.size() == capacidad) { // SIEMPRE while, nunca if
System.out.println(" " + Thread.currentThread().getName() + " espera: buffer lleno");
wait(); // libera el cerrojo y duerme
}
cola.add(elemento);
System.out.println(Thread.currentThread().getName() + " produce " + elemento + " " + cola);
notifyAll(); // despierta a los consumidores que esperan
}
public synchronized T sacar() throws InterruptedException {
while (cola.isEmpty()) {
System.out.println(" " + Thread.currentThread().getName() + " espera: buffer vacío");
wait();
}
T elemento = cola.remove();
System.out.println(Thread.currentThread().getName() + " consume " + elemento + " " + cola);
notifyAll(); // despierta a los productores que esperan
return elemento;
}
}
public static void main(String[] args) throws InterruptedException {
Buffer<String> buffer = new Buffer<>(3);
Thread productor = new Thread(() -> {
try {
for (int i = 1; i <= 6; i++) {
buffer.poner("P" + i);
Thread.sleep(50); // el productor es rápido
}
buffer.poner("FIN"); // "píldora venenosa" para avisar del final
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Productor");
Thread consumidor = new Thread(() -> {
try {
while (true) {
String e = buffer.sacar();
if (e.equals("FIN")) {
break;
}
Thread.sleep(200); // el consumidor es lento
}
System.out.println("Consumidor: recibido FIN, termino");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumidor");
consumidor.start();
Thread.sleep(100); // el consumidor llega antes y encuentra el buffer vacío
productor.start();
productor.join();
consumidor.join();
}
}
Salida real (el intercalado exacto puede variar):
Consumidor espera: buffer vacío
Productor produce P1 [P1]
Consumidor consume P1 []
Productor produce P2 [P2]
Productor produce P3 [P2, P3]
Productor produce P4 [P2, P3, P4]
Productor espera: buffer lleno
Consumidor consume P2 [P3, P4]
Productor produce P5 [P3, P4, P5]
Productor espera: buffer lleno
Consumidor consume P3 [P4, P5]
Productor produce P6 [P4, P5, P6]
Productor espera: buffer lleno
Consumidor consume P4 [P5, P6]
Productor produce FIN [P5, P6, FIN]
Consumidor consume P5 [P6, FIN]
Consumidor consume P6 [FIN]
Consumidor consume FIN []
Consumidor: recibido FIN, termino
Al principio el consumidor espera porque el buffer está vacío; después, como el productor es cuatro veces más rápido, el buffer se llena y es el productor quien espera. El buffer adapta automáticamente las velocidades de ambos.
⚠️ Importante: La condición de espera va siempre en un
while, nunca en unif. Cuando un hilo despierta, la condición puede haber vuelto a cambiar: otro hilo pudo adelantarse,notifyAll()despierta a todos aunque solo uno pueda continuar, y la especificación permite incluso despertares espurios (sin que nadie haya llamado anotify).
💡 Consejo: Usa
notifyAll()salvo que estés seguro de que todos los hilos en espera esperan lo mismo y basta con despertar a uno. Con productores y consumidores en la misma cola de espera, unnotify()podría despertar a otro productor en vez de al consumidor, y el programa podría quedarse parado. En el módulo 07 verás queBlockingQueueya resuelve este problema por ti; aquí lo implementamos para entender cómo funciona por dentro.
9. Interbloqueo (deadlock)
📘 Concepto: Un interbloqueo se produce cuando dos o más hilos esperan indefinidamente recursos que tienen los otros. Ninguno puede avanzar ni soltar lo que tiene. El programa no falla con una excepción: simplemente se queda colgado.
El ejemplo típico son dos transferencias bancarias simultáneas en sentidos opuestos: una de A a B (bloquea A y luego B) y otra de B a A (bloquea B y luego A).
Figura 5. Interbloqueo por espera circular y las cuatro condiciones de Coffman.
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class Interbloqueo {
static class Cuenta {
final String id;
long saldo;
Cuenta(String id, long saldo) {
this.id = id;
this.saldo = saldo;
}
}
// Transferencia INGENUA: bloquea primero el origen y después el destino
static void transferir(Cuenta origen, Cuenta destino, long cantidad) {
synchronized (origen) {
System.out.println(Thread.currentThread().getName() + " tiene " + origen.id + ", quiere " + destino.id);
dormir(100); // agranda la ventana del problema
synchronized (destino) {
origen.saldo -= cantidad;
destino.saldo += cantidad;
System.out.println(Thread.currentThread().getName() + " transferencia hecha");
}
}
}
static void dormir(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
Cuenta a = new Cuenta("A", 1000);
Cuenta b = new Cuenta("B", 1000);
Thread t1 = new Thread(() -> transferir(a, b, 100), "Hilo-1"); // A -> B
Thread t2 = new Thread(() -> transferir(b, a, 50), "Hilo-2"); // B -> A
t1.setDaemon(true); // para que la JVM pueda terminar aunque sigan bloqueados
t2.setDaemon(true);
t1.start();
t2.start();
t1.join(2000);
t2.join(2000);
// Detectamos el interbloqueo con la API de gestión de la JVM (lo mismo que hace jstack)
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] ids = mx.findDeadlockedThreads();
if (ids == null) {
System.out.println("No hay interbloqueo");
} else {
System.out.println("¡INTERBLOQUEO detectado entre " + ids.length + " hilos!");
for (ThreadInfo info : mx.getThreadInfo(ids)) {
System.out.println(" " + info.getThreadName() + " está " + info.getThreadState()
+ " esperando " + info.getLockName() + " que tiene " + info.getLockOwnerName());
}
}
}
}
Hilo-2 tiene B, quiere A
Hilo-1 tiene A, quiere B
¡INTERBLOQUEO detectado entre 2 hilos!
Hilo-1 está BLOCKED esperando Interbloqueo$Cuenta@79924b que tiene Hilo-2
Hilo-2 está BLOCKED esperando Interbloqueo$Cuenta@4cc451f2 que tiene Hilo-1
Ninguna transferencia llega a hacerse. En un programa real no tendrías el código de detección: verías un servidor que deja de responder. Para diagnosticarlo, obtén el PID con jps y vuelca los hilos con jstack <pid>: al final del informe aparece Found one Java-level deadlock: con el ciclo de hilos y cerrojos. También lo muestran VisualVM, JDK Mission Control y el depurador de IntelliJ IDEA.
Condiciones necesarias (Coffman, 1971)
Un interbloqueo solo es posible si se cumplen las cuatro a la vez. Basta con romper una:
| Condición | Significado | Cómo romperla |
|---|---|---|
| Exclusión mutua | Un recurso solo lo usa un hilo a la vez | Usar estructuras sin cerrojos (atómicas, inmutables) |
| Retención y espera | Un hilo retiene recursos mientras pide otros | Pedir todos los recursos de golpe |
| Sin expropiación | Nadie puede quitarle un recurso a un hilo | tryLock con tiempo: si no lo consigo, suelto lo que tengo |
| Espera circular | Hay un ciclo de hilos esperándose | Ordenar los recursos y pedirlos siempre en el mismo orden |
Cómo evitarlo
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class SinInterbloqueo {
static class Cuenta {
final int id; // identificador numérico para ordenar
long saldo;
final ReentrantLock lock = new ReentrantLock();
Cuenta(int id, long saldo) {
this.id = id;
this.saldo = saldo;
}
}
// Solución 1: adquirir SIEMPRE los cerrojos en el mismo orden global (por id)
static void transferirOrdenado(Cuenta origen, Cuenta destino, long cantidad) {
Cuenta primera = origen.id < destino.id ? origen : destino;
Cuenta segunda = origen.id < destino.id ? destino : origen;
synchronized (primera) {
dormir(100);
synchronized (segunda) {
origen.saldo -= cantidad;
destino.saldo += cantidad;
}
}
System.out.println(Thread.currentThread().getName() + ": transferidos " + cantidad + " de " + origen.id + " a " + destino.id);
}
// Solución 2: tryLock con tiempo máximo; si no consigo los dos, suelto y reintento
static void transferirConTryLock(Cuenta origen, Cuenta destino, long cantidad) throws InterruptedException {
int intentos = 0;
while (true) {
intentos++;
if (origen.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
dormir(100);
if (destino.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
origen.saldo -= cantidad;
destino.saldo += cantidad;
System.out.println(Thread.currentThread().getName() + ": transferencia con tryLock en " + intentos + " intento(s)");
return;
} finally {
destino.lock.unlock();
}
}
} finally {
origen.lock.unlock();
}
}
dormir((long) (Math.random() * 50)); // espera aleatoria para no repetir el choque (livelock)
}
}
static void dormir(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
Cuenta a = new Cuenta(1, 1000);
Cuenta b = new Cuenta(2, 1000);
Thread t1 = new Thread(() -> transferirOrdenado(a, b, 100), "Hilo-1");
Thread t2 = new Thread(() -> transferirOrdenado(b, a, 50), "Hilo-2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Saldos tras ordenar cerrojos: A=" + a.saldo + " B=" + b.saldo);
Thread t3 = new Thread(() -> {
try {
transferirConTryLock(a, b, 100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Hilo-3");
Thread t4 = new Thread(() -> {
try {
transferirConTryLock(b, a, 50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Hilo-4");
t3.start();
t4.start();
t3.join();
t4.join();
System.out.println("Saldos tras tryLock: A=" + a.saldo + " B=" + b.saldo);
}
}
Hilo-1: transferidos 100 de 1 a 2
Hilo-2: transferidos 50 de 2 a 1
Saldos tras ordenar cerrojos: A=950 B=1050
Hilo-3: transferencia con tryLock en 1 intento(s)
Hilo-4: transferencia con tryLock en 2 intento(s)
Saldos tras tryLock: A=900 B=1100
Con el orden global, los dos hilos intentan coger primero la cuenta 1: uno la consigue y el otro espera sin retener nada, así que no hay ciclo. Con tryLock, el Hilo-4 no pudo conseguir el segundo cerrojo a la primera, soltó el que tenía y lo consiguió en el segundo intento.
💡 Consejo: La espera aleatoria antes de reintentar evita el bloqueo activo (livelock): dos hilos que, por educación, sueltan y reintentan exactamente al mismo ritmo y chocan una y otra vez sin avanzar nunca, como dos personas que se apartan hacia el mismo lado en un pasillo. Otro problema relacionado es la inanición (starvation): un hilo que nunca consigue el recurso porque otros siempre se le adelantan.
10. Cerrojos explícitos: java.util.concurrent.locks
Desde Java 5, el paquete java.util.concurrent.locks ofrece cerrojos como objetos, con más posibilidades que synchronized.
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cerrojos {
// 1. ReadWriteLock: muchos lectores a la vez, un único escritor
static class Cache {
private final Map<String, String> datos = new HashMap<>();
private final ReadWriteLock rw = new ReentrantReadWriteLock();
String leer(String clave) {
rw.readLock().lock();
try {
dormir(100); // lectura lenta: varios lectores la hacen a la vez
return datos.get(clave);
} finally {
rw.readLock().unlock();
}
}
void escribir(String clave, String valor) {
rw.writeLock().lock();
try {
dormir(100);
datos.put(clave, valor);
} finally {
rw.writeLock().unlock();
}
}
}
// 2. Condition: buffer acotado con dos colas de espera separadas
static class BufferConCondiciones<T> {
private final Queue<T> cola = new ArrayDeque<>();
private final int capacidad;
private final ReentrantLock lock = new ReentrantLock();
private final Condition noLleno = lock.newCondition();
private final Condition noVacio = lock.newCondition();
BufferConCondiciones(int capacidad) {
this.capacidad = capacidad;
}
void poner(T e) throws InterruptedException {
lock.lock();
try {
while (cola.size() == capacidad) {
noLleno.await(); // equivale a wait(), pero en la cola "noLleno"
}
cola.add(e);
noVacio.signal(); // despierta solo a un consumidor
} finally {
lock.unlock();
}
}
T sacar() throws InterruptedException {
lock.lock();
try {
while (cola.isEmpty()) {
noVacio.await();
}
T e = cola.remove();
noLleno.signal(); // despierta solo a un productor
return e;
} finally {
lock.unlock();
}
}
}
static void dormir(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static long medir(Runnable r) throws InterruptedException {
long t0 = System.nanoTime();
r.run();
return (System.nanoTime() - t0) / 1_000_000;
}
public static void main(String[] args) throws InterruptedException {
// ReadWriteLock: 5 lecturas simultáneas tardan lo mismo que una
Cache cache = new Cache();
cache.escribir("es", "Hola");
long ms = medir(() -> {
Thread[] lectores = new Thread[5];
for (int i = 0; i < 5; i++) {
lectores[i] = new Thread(() -> cache.leer("es"));
lectores[i].start();
}
for (Thread l : lectores) {
try { l.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
});
System.out.println("5 lecturas concurrentes de 100 ms: " + ms + " ms");
ms = medir(() -> {
Thread[] escritores = new Thread[5];
for (int i = 0; i < 5; i++) {
int n = i;
escritores[i] = new Thread(() -> cache.escribir("k" + n, "v"));
escritores[i].start();
}
for (Thread e : escritores) {
try { e.join(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); }
}
});
System.out.println("5 escrituras concurrentes de 100 ms: " + ms + " ms");
// Condition
BufferConCondiciones<Integer> buf = new BufferConCondiciones<>(2);
Thread prod = new Thread(() -> {
try {
for (int i = 1; i <= 5; i++) buf.poner(i);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
prod.start();
int suma = 0;
for (int i = 0; i < 5; i++) suma += buf.sacar();
prod.join();
System.out.println("Suma recibida a través del buffer con Condition: " + suma);
}
}
5 lecturas concurrentes de 100 ms: 116 ms
5 escrituras concurrentes de 100 ms: 648 ms
Suma recibida a través del buffer con Condition: 15
La medición del ReadWriteLock lo dice todo: cinco lecturas de 100 ms se hicieron a la vez (116 ms en total), mientras que las cinco escrituras se hicieron una detrás de otra (648 ms). El uso básico de ReentrantLock (lock()/unlock() y tryLock) ya lo viste en SinInterbloqueo y lo practicarás en el ejercicio 5.
synchronized | ReentrantLock | |
|---|---|---|
| Liberación | Automática al salir del bloque | Manual: unlock() en un finally |
| Intentar sin esperar | No | tryLock() y tryLock(tiempo, unidad) |
| Esperar de forma interrumpible | No | lockInterruptibly() |
| Equidad (fairness) | No | new ReentrantLock(true): atiende por orden de llegada |
| Varias colas de espera | Una por monitor (wait/notify) | Varias Condition por cerrojo (await/signal) |
| Consultas | No | isLocked(), getQueueLength(), isHeldByCurrentThread() |
⚠️ Importante: Con
Lock, el patrón es siemprelock(); try { ... } finally { unlock(); }. Si olvidas elunlock()o una excepción lo salta, el cerrojo queda cogido para siempre y todos los demás hilos se bloquean.
💡 Consejo: Usa
synchronizedpor defecto: es más sencillo y el compilador te protege de olvidar el desbloqueo. Recurre aReentrantLockcuando necesitestryLock, interrupción, equidad o varias condiciones. UsaReadWriteLockcuando las lecturas sean mucho más frecuentes que las escrituras (cachés, configuraciones). Existe tambiénStampedLock(Java 8), con lecturas optimistas aún más rápidas, para casos muy exigentes.
11. Variables atómicas
Para casos sencillos como contadores, banderas o referencias, el paquete java.util.concurrent.atomic ofrece clases que hacen operaciones compuestas de forma atómica y sin cerrojos, usando una instrucción especial de la CPU: CAS (compare-and-swap, comparar e intercambiar).
📘 Concepto:
compareAndSet(esperado, nuevo)cambia el valor anuevosolo si su valor actual esesperado, y devuelve si lo consiguió, todo en una única operación indivisible del procesador. Si otro hilo se adelantó, el CAS falla y se reintenta con el valor nuevo. Es una estrategia optimista: en vez de pedir permiso (cerrojo), lo intenta y comprueba.
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.LongAdder;
public class Atomicas {
interface Incrementable {
void incrementar();
long valor();
}
static long probar(String nombre, Incrementable c, int hilos, int veces) throws InterruptedException {
Thread[] hs = new Thread[hilos];
long t0 = System.nanoTime();
for (int i = 0; i < hilos; i++) {
hs[i] = new Thread(() -> {
for (int j = 0; j < veces; j++) c.incrementar();
});
hs[i].start();
}
for (Thread h : hs) h.join();
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("%-14s resultado=%,d tiempo=%4d ms%n", nombre, c.valor(), ms);
return ms;
}
public static void main(String[] args) throws InterruptedException {
// Operaciones básicas de AtomicInteger
AtomicInteger ai = new AtomicInteger(10);
System.out.println("incrementAndGet: " + ai.incrementAndGet()); // ++x
System.out.println("getAndIncrement: " + ai.getAndIncrement()); // x++
System.out.println("addAndGet(5): " + ai.addAndGet(5));
System.out.println("compareAndSet(17, 100): " + ai.compareAndSet(17, 100) + " -> " + ai.get());
System.out.println("compareAndSet(17, 200): " + ai.compareAndSet(17, 200) + " -> " + ai.get());
System.out.println("updateAndGet(x*2): " + ai.updateAndGet(x -> x * 2));
System.out.println("accumulateAndGet(max con 500): " + ai.accumulateAndGet(500, Math::max));
AtomicReference<String> estado = new AtomicReference<>("PENDIENTE");
boolean cambiado = estado.compareAndSet("PENDIENTE", "PAGADO");
System.out.println("AtomicReference: " + cambiado + " -> " + estado.get());
// Comparativa: 8 hilos x 2 000 000 incrementos
int hilos = 8, veces = 2_000_000;
System.out.println("\n" + hilos + " hilos x " + veces + " incrementos:");
Object cerrojo = new Object();
long[] sync = {0};
probar("synchronized", new Incrementable() {
public void incrementar() { synchronized (cerrojo) { sync[0]++; } }
public long valor() { return sync[0]; }
}, hilos, veces);
AtomicLong al = new AtomicLong();
probar("AtomicLong", new Incrementable() {
public void incrementar() { al.incrementAndGet(); }
public long valor() { return al.get(); }
}, hilos, veces);
LongAdder adder = new LongAdder();
probar("LongAdder", new Incrementable() {
public void incrementar() { adder.increment(); }
public long valor() { return adder.sum(); }
}, hilos, veces);
}
}
incrementAndGet: 11
getAndIncrement: 11
addAndGet(5): 17
compareAndSet(17, 100): true -> 100
compareAndSet(17, 200): false -> 100
updateAndGet(x*2): 200
accumulateAndGet(max con 500): 500
AtomicReference: true -> PAGADO
8 hilos x 2000000 incrementos:
synchronized resultado=16.000.000 tiempo=1132 ms
AtomicLong resultado=16.000.000 tiempo= 428 ms
LongAdder resultado=16.000.000 tiempo= 293 ms
Las tres soluciones son correctas; cambia el rendimiento (los tiempos dependen mucho de la máquina y de su carga, pero el orden suele mantenerse). LongAdder (Java 8) es el más rápido con mucha contención porque reparte la cuenta en varias celdas, una por hilo aproximadamente, y solo las suma al pedir el resultado con sum().
| Clase | Uso típico |
|---|---|
AtomicInteger, AtomicLong | Contadores, generadores de identificadores, secuencias |
AtomicBoolean | Banderas que se activan una sola vez (“¿ya se ha inicializado?”) |
AtomicReference<V> | Cambiar de forma atómica una referencia (estado inmutable) |
AtomicIntegerArray, AtomicLongArray | Arrays con elementos atómicos |
LongAdder, DoubleAdder | Estadísticas muy escritas y poco leídas (contadores de visitas, métricas) |
LongAccumulator | Igual que LongAdder con otra operación (máximo, mínimo…) |
⚠️ Importante: Cada operación de una variable atómica es atómica, pero dos operaciones seguidas no lo son.
if (ai.get() > 0) ai.decrementAndGet();vuelve a ser un “comprobar y actuar” con carrera. UsaupdateAndGet(x -> x > 0 ? x - 1 : x)ocompareAndSeten un bucle.
12. Un problema clásico: la cena de los filósofos
Planteado por Edsger Dijkstra en 1965, es el ejemplo de manual del interbloqueo. Cinco filósofos se sientan a una mesa redonda; entre cada dos hay un tenedor. Cada filósofo alterna entre pensar y comer, y para comer necesita los dos tenedores que tiene a sus lados.
Figura 6. La cena de los filósofos: cinco hilos y cinco recursos compartidos en círculo.
import java.lang.management.ManagementFactory;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.concurrent.locks.ReentrantLock;
public class Filosofos {
static final int N = 5;
static final String[] NOMBRES = {"Platón", "Aristóteles", "Sócrates", "Kant", "Descartes"};
static void dormir(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// Ejecuta la cena; si 'ordenado' es true, cada filósofo coge primero el tenedor de número menor
static void cena(boolean ordenado, int comidasPorFilosofo) throws InterruptedException {
ReentrantLock[] tenedores = new ReentrantLock[N];
for (int i = 0; i < N; i++) tenedores[i] = new ReentrantLock();
AtomicIntegerArray comidas = new AtomicIntegerArray(N);
Thread[] filosofos = new Thread[N];
for (int i = 0; i < N; i++) {
final int id = i;
int izq = i; // tenedor a su izquierda
int der = (i + 1) % N; // tenedor a su derecha
int primero = ordenado ? Math.min(izq, der) : izq;
int segundo = ordenado ? Math.max(izq, der) : der;
filosofos[i] = new Thread(() -> {
for (int c = 0; c < comidasPorFilosofo; c++) {
dormir(10); // pensar
tenedores[primero].lock();
try {
dormir(20); // tarda un poco en coger el segundo
tenedores[segundo].lock();
try {
comidas.incrementAndGet(id); // comer
dormir(5);
} finally {
tenedores[segundo].unlock();
}
} finally {
tenedores[primero].unlock();
}
}
}, NOMBRES[i]);
filosofos[i].setDaemon(true);
filosofos[i].start();
}
for (Thread f : filosofos) f.join(3000);
// Contamos cuántos filósofos de ESTA cena siguen vivos y en un interbloqueo
long[] bloqueados = ManagementFactory.getThreadMXBean().findDeadlockedThreads();
java.util.Set<Long> idsBloqueados = new java.util.HashSet<>();
if (bloqueados != null) for (long id : bloqueados) idsBloqueados.add(id);
long atascados = java.util.Arrays.stream(filosofos).filter(f -> idsBloqueados.contains(f.threadId())).count();
System.out.println((ordenado ? "Con orden de recursos" : "Versión ingenua") + ": comidas = " + comidas
+ (atascados == 0 ? " -> todos han cenado" : " -> INTERBLOQUEO de " + atascados + " filósofos"));
}
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 3; i++) {
cena(false, 20); // la versión ingenua puede bloquearse... o no
}
cena(true, 20);
}
}
Versión ingenua: comidas = [8, 8, 8, 7, 7] -> INTERBLOQUEO de 5 filósofos
Versión ingenua: comidas = [3, 3, 3, 3, 2] -> INTERBLOQUEO de 5 filósofos
Versión ingenua: comidas = [0, 0, 0, 0, 0] -> INTERBLOQUEO de 5 filósofos
Con orden de recursos: comidas = [20, 20, 20, 20, 20] -> todos han cenado
En la versión ingenua cada filósofo coge primero el tenedor izquierdo. Tarde o temprano los cinco lo cogen a la vez, y ninguno puede coger el derecho: espera circular. Fíjate en que el interbloqueo llega en momentos distintos (tras 8 comidas, tras 3, o en la primera); en una ejecución anterior de este mismo programa, con otro ajuste de tiempos, ¡los cinco consiguieron cenar sin bloquearse! Los fallos de concurrencia son no deterministas.
En la versión ordenada, cuatro filósofos cogen primero su izquierdo, pero Descartes (F4, tenedores T4 y T0) coge primero el T0, el de número menor. Así se rompe el ciclo: es imposible que los cinco tengan un tenedor y esperen otro.
💡 Consejo: La técnica de numerar los recursos y adquirirlos siempre en orden creciente es la forma más práctica de evitar interbloqueos en aplicaciones reales: en bases de datos, por ejemplo, actualizar las filas siempre en orden de clave primaria evita muchos deadlocks entre transacciones.
13. Buenas prácticas
| Práctica | Motivo |
|---|---|
| Minimiza el estado compartido mutable | Lo que no se comparte no hay que sincronizarlo |
Prefiere objetos inmutables (record, List.of, java.time) | Son seguros entre hilos por diseño |
| Usa variables locales y confinamiento por hilo | Cada hilo trabaja con sus datos y se combinan al final (como en el ejercicio 1) |
| Protege todos los accesos a un dato con el mismo cerrojo | Si no, la sincronización no sirve |
| Secciones críticas cortas, sin E/S ni llamadas a código desconocido | Menos espera y menos riesgo de interbloqueo |
| Adquiere varios cerrojos siempre en el mismo orden | Evita la espera circular |
wait() siempre dentro de un while | Despertares espurios y condiciones que cambian |
No ignores InterruptedException | Termina o restaura la marca de interrupción |
Da nombre a los hilos; no los coordines con sleep ni con prioridades | Logs legibles; sleep y prioridades no garantizan nada |
| Usa las utilidades de alto nivel cuando puedas | ExecutorService, BlockingQueue, ConcurrentHashMap (módulo 07) |
⚠️ Importante: Las pruebas no demuestran la ausencia de errores de concurrencia: un programa puede pasar mil veces y fallar la mil una. La corrección de un programa concurrente se razona: identifica los datos compartidos, decide qué los protege y comprueba que todos los accesos respetan esa decisión.
14. Ejercicios
Ejercicio 1: Contar primos en paralelo
Cuenta cuántos números primos hay por debajo de 2 000 000 repartiendo el rango entre N hilos (N = 1, 2, 4 y 8). Cada hilo debe guardar su resultado parcial en un campo propio (sin sincronización) y el hilo main debe combinarlos tras join(). Mide el tiempo de cada configuración y calcula la aceleración respecto a 1 hilo.
Ver solución
public class Ej1Primos {
static boolean esPrimo(int n) {
if (n < 2) return false;
for (int d = 2; (long) d * d <= n; d++) {
if (n % d == 0) return false;
}
return true;
}
// Cada trabajador cuenta los primos de su trozo [desde, hasta) y guarda su propio resultado
static class Contador extends Thread {
private final int desde, hasta;
private int primos; // solo lo escribe este hilo; main lo lee tras join()
Contador(int desde, int hasta) {
this.desde = desde;
this.hasta = hasta;
}
@Override
public void run() {
int c = 0;
for (int n = desde; n < hasta; n++) {
if (esPrimo(n)) c++;
}
primos = c;
}
}
static int contarEnParalelo(int limite, int numHilos) throws InterruptedException {
Contador[] hilos = new Contador[numHilos];
int trozo = (limite + numHilos - 1) / numHilos; // redondeo hacia arriba
for (int i = 0; i < numHilos; i++) {
int desde = i * trozo;
int hasta = Math.min(desde + trozo, limite);
hilos[i] = new Contador(desde, hasta);
hilos[i].start();
}
int total = 0;
for (Contador h : hilos) {
h.join(); // join garantiza que vemos el valor escrito por el hilo
total += h.primos;
}
return total;
}
public static void main(String[] args) throws InterruptedException {
int limite = 2_000_000;
System.out.println("Núcleos: " + Runtime.getRuntime().availableProcessors());
long base = 0;
for (int n : new int[]{1, 2, 4, 8}) {
long t0 = System.nanoTime();
int primos = contarEnParalelo(limite, n);
long ms = (System.nanoTime() - t0) / 1_000_000;
if (n == 1) base = ms;
System.out.printf("%d hilo(s): %,d primos menores que %,d en %4d ms (aceleración x%.1f)%n",
n, primos, limite, ms, (double) base / ms);
}
}
}
Salida real en un equipo de 8 núcleos:
Núcleos: 8
1 hilo(s): 148.933 primos menores que 2.000.000 en 1163 ms (aceleración x1,0)
2 hilo(s): 148.933 primos menores que 2.000.000 en 879 ms (aceleración x1,3)
4 hilo(s): 148.933 primos menores que 2.000.000 en 565 ms (aceleración x2,1)
8 hilo(s): 148.933 primos menores que 2.000.000 en 532 ms (aceleración x2,2)
El resultado (148 933 primos) es idéntico en todos los casos: no hay datos compartidos que se escriban a la vez. La aceleración, en cambio, queda muy lejos de la ideal (x8), y merece la pena analizar por qué:
- El reparto en bloques no es equilibrado: el último trozo contiene los números más grandes, que cuestan más de comprobar, y todos los hilos esperan al más lento. Con 2 hilos, el segundo hace bastante más trabajo que el primero.
- La primera medición incluye el calentamiento del JIT (el código empieza interpretado).
- El equipo compartía sus núcleos con otros programas. En una ejecución anterior, con la máquina muy cargada, el mismo programa tardó casi 10 s con un hilo y empeoró al pasar de 4 a 8 hilos.
Prueba a repartir de forma intercalada (el hilo i comprueba i, i + N, i + 2N…) y compara.
Ejercicio 2: Ping-pong
Dos hilos deben imprimir alternativamente PING y PONG cinco veces cada uno, empezando siempre por PING, aunque el hilo PONG se arranque primero. Usa un monitor con wait()/notifyAll().
Ver solución
public class Ej2PingPong {
// Monitor que obliga a alternar los turnos entre dos hilos
static class Mesa {
private boolean turnoPing = true;
synchronized void golpear(boolean soyPing, String sonido) throws InterruptedException {
while (turnoPing != soyPing) { // no es mi turno: espero
wait();
}
System.out.println(sonido);
turnoPing = !turnoPing; // cedo el turno
notifyAll();
}
}
public static void main(String[] args) throws InterruptedException {
Mesa mesa = new Mesa();
int golpes = 5;
Thread ping = new Thread(() -> {
try {
for (int i = 1; i <= golpes; i++) mesa.golpear(true, "PING " + i);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread pong = new Thread(() -> {
try {
for (int i = 1; i <= golpes; i++) mesa.golpear(false, " PONG " + i);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
pong.start(); // aunque pong arranque primero, debe esperar su turno
ping.start();
ping.join();
pong.join();
System.out.println("Fin del partido");
}
}
PING 1
PONG 1
PING 2
PONG 2
PING 3
PONG 3
PING 4
PONG 4
PING 5
PONG 5
Fin del partido
Este programa, a diferencia de casi todos los del módulo, tiene una salida determinista: el monitor fuerza el orden. El estado compartido es un único boolean (turnoPing) protegido por el cerrojo de mesa, y cada hilo espera en un while a que sea su turno.
Ejercicio 3: Aparcamiento con plazas limitadas
Simula un aparcamiento de 3 plazas al que llegan 6 coches escalonados. Cada coche entra (si hay plaza; si no, espera en la barrera), permanece un tiempo distinto y sale. Muestra en cada momento las plazas ocupadas. Implementa el aparcamiento como un monitor.
Ver solución
public class Ej3Aparcamiento {
static class Aparcamiento {
private final int plazas;
private int ocupadas = 0;
Aparcamiento(int plazas) {
this.plazas = plazas;
}
synchronized void entrar(String coche) throws InterruptedException {
while (ocupadas == plazas) {
System.out.println(" " + coche + " espera en la barrera (lleno)");
wait();
}
ocupadas++;
System.out.println(coche + " entra -> ocupadas " + ocupadas + "/" + plazas);
}
synchronized void salir(String coche) {
ocupadas--;
System.out.println(coche + " sale -> ocupadas " + ocupadas + "/" + plazas);
notifyAll();
}
}
public static void main(String[] args) throws InterruptedException {
Aparcamiento parking = new Aparcamiento(3);
Thread[] coches = new Thread[6];
for (int i = 0; i < coches.length; i++) {
String matricula = "Coche-" + (i + 1);
long estancia = 200 + i * 100L;
coches[i] = new Thread(() -> {
try {
parking.entrar(matricula);
Thread.sleep(estancia); // tiempo aparcado
parking.salir(matricula);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
coches[i].start();
Thread.sleep(20); // llegan escalonados
}
for (Thread c : coches) c.join();
System.out.println("Aparcamiento vacío al cerrar");
}
}
Coche-1 entra -> ocupadas 1/3
Coche-2 entra -> ocupadas 2/3
Coche-3 entra -> ocupadas 3/3
Coche-4 espera en la barrera (lleno)
Coche-5 espera en la barrera (lleno)
Coche-6 espera en la barrera (lleno)
Coche-1 sale -> ocupadas 2/3
Coche-4 entra -> ocupadas 3/3
Coche-5 espera en la barrera (lleno)
Coche-6 espera en la barrera (lleno)
Coche-2 sale -> ocupadas 2/3
Coche-5 entra -> ocupadas 3/3
Coche-6 espera en la barrera (lleno)
Coche-3 sale -> ocupadas 2/3
Coche-6 entra -> ocupadas 3/3
Coche-4 sale -> ocupadas 2/3
Coche-5 sale -> ocupadas 1/3
Coche-6 sale -> ocupadas 0/3
Aparcamiento vacío al cerrar
Observa el efecto de notifyAll() y del while: cuando sale el Coche-1 se despiertan los tres que esperan; uno consigue la plaza y los otros dos comprueban de nuevo la condición, la encuentran falsa y vuelven a esperar (por eso reaparecen sus mensajes). Este patrón, un contador de permisos, es lo que implementa la clase Semaphore del módulo 07. El coche concreto que entra en cada momento puede variar entre ejecuciones.
Ejercicio 4: Frecuencia de palabras concurrente
Varios hilos (uno por línea de texto) cuentan las palabras de un fragmento del Quijote y acumulan las frecuencias en un HashMap compartido. Haz que la sección crítica sea lo más pequeña posible y muestra el total de palabras, las distintas y las que se repiten.
Ver solución
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class Ej4Palabras {
static final String[] TEXTO = {
"en un lugar de la mancha de cuyo nombre no quiero acordarme",
"no ha mucho tiempo que vivía un hidalgo de los de lanza en astillero",
"adarga antigua rocín flaco y galgo corredor",
"una olla de algo más vaca que carnero salpicón las más noches",
"duelos y quebrantos los sábados lantejas los viernes",
"algún palomino de añadidura los domingos consumían las tres partes de su hacienda"
};
// Recurso compartido: el mapa y su cerrojo
private static final Map<String, Integer> frecuencias = new HashMap<>();
static void contar(String linea) {
// Primero contamos en un mapa LOCAL (sin cerrojo) y luego volcamos de golpe
Map<String, Integer> local = new HashMap<>();
for (String p : linea.split("\\s+")) {
local.merge(p, 1, Integer::sum);
}
synchronized (frecuencias) { // sección crítica mínima
local.forEach((k, v) -> frecuencias.merge(k, v, Integer::sum));
}
}
public static void main(String[] args) throws InterruptedException {
List<Thread> hilos = new ArrayList<>();
for (String linea : TEXTO) {
Thread t = new Thread(() -> contar(linea));
hilos.add(t);
t.start();
}
for (Thread t : hilos) t.join();
int total = frecuencias.values().stream().mapToInt(Integer::intValue).sum();
System.out.println("Palabras totales: " + total + ", distintas: " + frecuencias.size());
Map<String, Integer> repetidas = new TreeMap<>();
frecuencias.forEach((k, v) -> { if (v > 1) repetidas.put(k, v); });
System.out.println("Repetidas: " + repetidas);
}
}
Palabras totales: 66, distintas: 50
Repetidas: {de=7, en=2, las=2, los=4, más=2, no=2, que=2, un=2, y=2}
La clave es confinar el trabajo pesado (partir la línea y contar) en un mapa local de cada hilo, que no necesita sincronización, y entrar en la sección crítica solo para fusionar. El main lee el mapa después de los join(), así que ve todos los cambios. En el módulo 07 lo harás aún mejor con ConcurrentHashMap.merge, que no necesita cerrojo explícito.
Ejercicio 5: Banco con transferencias concurrentes
Un banco tiene 4 cuentas con 1000 € cada una. Ocho cajeros (hilos) hacen 100 000 transferencias aleatorias cada uno entre cuentas aleatorias (rechazando las que dejarían saldo negativo). Usa un ReentrantLock por cuenta, evita el interbloqueo y comprueba que el dinero total se conserva.
Ver solución
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class Ej5Banco {
static class Cuenta {
final int id;
private long saldo;
final ReentrantLock lock = new ReentrantLock();
Cuenta(int id, long saldo) {
this.id = id;
this.saldo = saldo;
}
}
static final AtomicInteger rechazadas = new AtomicInteger();
static void transferir(Cuenta origen, Cuenta destino, long cantidad) {
if (origen == destino) return;
Cuenta primera = origen.id < destino.id ? origen : destino; // orden global
Cuenta segunda = primera == origen ? destino : origen;
primera.lock.lock();
try {
segunda.lock.lock();
try {
if (origen.saldo < cantidad) {
rechazadas.incrementAndGet();
return;
}
origen.saldo -= cantidad;
destino.saldo += cantidad;
} finally {
segunda.lock.unlock();
}
} finally {
primera.lock.unlock();
}
}
static long total(Cuenta[] cuentas) {
long t = 0;
for (Cuenta c : cuentas) t += c.saldo;
return t;
}
public static void main(String[] args) throws InterruptedException {
Cuenta[] cuentas = {new Cuenta(1, 1000), new Cuenta(2, 1000), new Cuenta(3, 1000), new Cuenta(4, 1000)};
System.out.println("Dinero total inicial: " + total(cuentas));
Thread[] cajeros = new Thread[8];
for (int i = 0; i < cajeros.length; i++) {
long semilla = i;
cajeros[i] = new Thread(() -> {
Random r = new Random(semilla);
for (int j = 0; j < 100_000; j++) {
transferir(cuentas[r.nextInt(4)], cuentas[r.nextInt(4)], r.nextInt(200));
}
});
cajeros[i].start();
}
for (Thread c : cajeros) c.join();
for (Cuenta c : cuentas) System.out.println(" Cuenta " + c.id + ": " + c.saldo);
System.out.println("Dinero total final: " + total(cuentas) + " (transferencias rechazadas: " + rechazadas.get() + ")");
}
}
Dinero total inicial: 4000
Cuenta 1: 135
Cuenta 2: 347
Cuenta 3: 1841
Cuenta 4: 1677
Dinero total final: 4000 (transferencias rechazadas: 43775)
Los saldos individuales y el número de rechazos cambian en cada ejecución (dependen del intercalado), pero el invariante se cumple siempre: el total es 4000. Ese es el tipo de propiedad que debes comprobar en un programa concurrente. Cada Random es local a su hilo (con su semilla): compartir un único Random entre hilos funcionaría, pero con contención; para eso existe ThreadLocalRandom.current().
Ejercicio 6: Barra de progreso cancelable
Un hilo trabajador procesa 20 ficheros (100 ms cada uno) y otro hilo daemon muestra una barra de progreso cada 250 ms. Si el trabajo no ha terminado en 1,3 segundos, el programa principal lo cancela con interrupt(), y el trabajador debe terminar de forma ordenada. Usa un AtomicInteger para el progreso.
Ver solución
import java.util.concurrent.atomic.AtomicInteger;
public class Ej6Progreso {
public static void main(String[] args) throws InterruptedException {
AtomicInteger procesados = new AtomicInteger();
int totalFicheros = 20;
// Trabajador: "procesa" ficheros hasta acabar o hasta que lo interrumpan
Thread trabajador = new Thread(() -> {
for (int i = 1; i <= totalFicheros; i++) {
try {
Thread.sleep(100); // procesar un fichero
} catch (InterruptedException e) {
System.out.println("\nTrabajador: cancelado, dejo el trabajo a medias de forma ordenada");
return;
}
procesados.incrementAndGet();
}
}, "trabajador");
// Monitor (daemon): dibuja la barra de progreso cada 250 ms
Thread monitor = new Thread(() -> {
while (true) {
int p = procesados.get();
int porcentaje = p * 100 / totalFicheros;
String barra = "#".repeat(porcentaje / 5) + ".".repeat(20 - porcentaje / 5);
System.out.printf("[%s] %3d %% (%d/%d)%n", barra, porcentaje, p, totalFicheros);
try {
Thread.sleep(250);
} catch (InterruptedException e) {
return;
}
}
}, "monitor");
monitor.setDaemon(true);
trabajador.start();
monitor.start();
trabajador.join(1300); // el usuario se cansa a los 1,3 s
if (trabajador.isAlive()) {
trabajador.interrupt(); // pulsa "Cancelar"
trabajador.join();
}
System.out.println("Ficheros procesados: " + procesados.get() + " de " + totalFicheros);
}
}
[....................] 0 % (0/20)
[####................] 20 % (4/20)
[#######.............] 35 % (7/20)
[#########...........] 45 % (9/20)
[###########.........] 55 % (11/20)
Trabajador: cancelado, dejo el trabajo a medias de forma ordenada
Ficheros procesados: 11 de 20
En un equipo sin carga se procesarían unos 13 ficheros en 1,3 s; aquí fueron 11. El AtomicInteger resuelve a la vez la atomicidad del incremento y la visibilidad para el hilo monitor. El monitor es daemon, así que no hace falta pararlo: muere al terminar main.
Ejercicio 7 (propuesto): Biblioteca con lectores y escritores
Modela el catálogo de una biblioteca (Map<String, Libro>) protegido con un ReentrantReadWriteLock. Lanza 10 hilos lectores que consultan libros continuamente y 2 escritores que añaden y prestan libros. Mide cuántas consultas por segundo se hacen con ReadWriteLock y con un único synchronized, y explica la diferencia.
Ejercicio 8 (propuesto): El barbero dormilón
Una barbería tiene un barbero, una silla de corte y 3 sillas de espera. Si no hay clientes, el barbero duerme; cuando llega un cliente lo despierta; si llegan clientes y las sillas de espera están ocupadas, se marchan. Implementa el problema con un monitor (wait/notifyAll) y simula la llegada de 10 clientes en momentos aleatorios.
Ejercicio 9 (propuesto): Puente de un solo carril
Un puente estrecho solo admite coches en un sentido a la vez (pueden cruzar varios del mismo sentido simultáneamente) y como máximo 3 coches a la vez. Implementa la clase Puente con entrar(Sentido) y salir(Sentido) usando ReentrantLock y Condition. Después, evita la inanición: si hay coches esperando en el sentido contrario, deja de admitir nuevos coches en el sentido actual.
Ejercicio 10 (propuesto): Filósofos con camarero
Resuelve la cena de los filósofos con otra estrategia: un “camarero” (monitor) que solo permite que haya 4 filósofos sentados a la vez. Demuestra que no hay interbloqueo ejecutando 100 cenas seguidas y comprueba con ThreadMXBean que ningún hilo queda bloqueado. ¿Puede haber inanición?
Ejercicio 11 (propuesto): Caza el interbloqueo con jstack
Escribe un programa con tres hilos y tres recursos que formen un ciclo (H1 tiene R1 y pide R2, H2 tiene R2 y pide R3, H3 tiene R3 y pide R1) sin código de detección. Ejecútalo, obtén su PID con jps y analiza la salida de jstack <pid>. Copia la sección Found one Java-level deadlock y explica qué indica cada línea. Después, corrígelo.
Ejercicio 12 (propuesto): Ejecutar comandos en paralelo
Con ProcessBuilder, lanza en paralelo tres procesos del sistema (por ejemplo, ping -c 3 a tres servidores distintos, o ping -n 3 en Windows), cada uno controlado por un hilo Java que lee su salida y la muestra con un prefijo ([google] ...). El programa principal debe esperar a los tres, mostrar el código de salida de cada uno y matar cualquier proceso que tarde más de 10 segundos.
Resumen
| Concepto | Clase / palabra clave | Idea clave |
|---|---|---|
| Proceso | ProcessBuilder, Process, ProcessHandle | Memoria aislada; waitFor(), destroy() |
| Hilo | Thread, Runnable, Thread.ofPlatform() | Comparten el heap; arrancar con start(), nunca run() |
| Estados | Thread.State | NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED |
| Esperar a otro hilo | join(), join(ms) | Además garantiza la visibilidad de sus resultados |
| Parar un hilo | interrupt(), isInterrupted() | Petición cortés; nunca ignorar InterruptedException |
| Hilos de fondo | setDaemon(true) | No impiden que la JVM termine |
| Condición de carrera | — | Resultado dependiente del intercalado; x++ no es atómico |
| Exclusión mutua | synchronized (métodos y bloques) | Un hilo a la vez por monitor; reentrante; da visibilidad |
| Visibilidad | volatile | Lecturas siempre actualizadas; no da atomicidad de x++ |
| Coordinación | wait(), notifyAll() | Dentro de synchronized y en un while |
| Productor-consumidor | Buffer acotado | El productor espera si está lleno; el consumidor, si está vacío |
| Interbloqueo | ThreadMXBean, jstack | Cuatro condiciones; romper la espera circular ordenando cerrojos |
| Cerrojo explícito | ReentrantLock | lock(); try { } finally { unlock(); }, tryLock |
| Lectores-escritores | ReentrantReadWriteLock | Muchos lectores a la vez, un escritor |
| Varias colas de espera | Condition | await(), signal(), signalAll() |
| Operaciones atómicas | AtomicInteger, AtomicReference | CAS sin cerrojos: incrementAndGet, compareAndSet |
| Contadores con contención | LongAdder | Celdas por hilo; sum() al final |