Hilos y concurrencia en Java
En esta lección
Hasta ahora, todos tus programas hacían una cosa detrás de otra: una línea, luego la siguiente. Pero un programa real a menudo necesita hacer varias cosas a la vez: un servidor web atiende a cientos de usuarios simultáneamente, una aplicación descarga un archivo mientras sigues usando la interfaz, un juego mueve a los enemigos mientras lee tu teclado. Eso se consigue con hilos. En esta lección verás qué es la concurrencia, cómo crear y esperar hilos, el problema más típico que aparece al compartir datos entre ellos (las condiciones de carrera) y las herramientas modernas para trabajar con hilos sin complicarte: ExecutorService y los hilos virtuales de Java 21.
Nota: la concurrencia es un tema amplio y difícil. Esta lección es una introducción para que entiendas las ideas y sepas por dónde empezar, no una guía completa.
Concurrencia: varias tareas a la vez
Piensa en la cocina de un restaurante. Si solo hay un cocinero, prepara los platos uno tras otro: mientras espera a que hierva el agua, no hace nada más. Si hay varios cocineros, cada uno puede encargarse de un plato y la comida sale antes. Eso sí, ahora tienen que coordinarse: no pueden usar la misma sartén a la vez ni echar sal dos veces a la misma olla.
En programación:
- Un proceso es un programa en ejecución, con su propia memoria. Cada vez que lanzas
java MiPrograma, se crea un proceso. - Un hilo (thread) es una línea de ejecución dentro de un proceso. Un proceso puede tener muchos hilos, y todos comparten la misma memoria (los mismos objetos).
- La concurrencia es tener varias tareas en marcha al mismo tiempo. Si tu ordenador tiene varios núcleos, algunas se ejecutan literalmente a la vez (paralelismo); si no, el sistema operativo va alternando entre ellas muy rápido.
Todo programa Java tiene ya un hilo: el que ejecuta el método main.
System.out.println(Thread.currentThread().getName()); // main
Crear un hilo: Thread y Runnable
La tarea que hará el hilo se describe con un Runnable, una interfaz funcional con un único método, run(), sin parámetros ni resultado. Como es funcional, se puede escribir con una lambda. Luego se crea un objeto Thread con esa tarea y se arranca con start():
public class Cocina {
public static void main(String[] args) throws InterruptedException {
Runnable tarea = () -> {
for (int i = 1; i <= 3; i++) {
System.out.println(Thread.currentThread().getName() + ": paso " + i);
}
};
Thread h1 = new Thread(tarea, "Cocinero-1"); // el segundo argumento es el nombre
Thread h2 = new Thread(tarea, "Cocinero-2");
h1.start();
h2.start();
}
}
// Una ejecución posible (puede variar):
// Cocinero-2: paso 1
// Cocinero-2: paso 2
// Cocinero-2: paso 3
// Cocinero-1: paso 1
// Cocinero-1: paso 2
// Cocinero-1: paso 3
Fíjate en que el Cocinero-2 ha empezado antes aunque se arrancó después. El orden en que se ejecutan los hilos no está garantizado: lo decide el sistema operativo. Si lo ejecutas varias veces verás salidas distintas, a veces mezcladas línea a línea. Por eso, en esta lección, las salidas de los ejemplos con varios hilos son solo una ejecución posible.
Otra forma: heredar de Thread
También puedes crear una clase que herede de Thread y sobrescriba run(). La verás en apuntes y código antiguo:
class Descarga extends Thread {
private final String archivo;
Descarga(String archivo) {
this.archivo = archivo;
}
@Override
public void run() {
System.out.println("Descargando " + archivo + " en " + getName());
}
}
Descarga d = new Descarga("fotos.zip");
d.start();
// Descargando fotos.zip en Thread-0
Hoy se prefiere Runnable: separa la tarea (qué hay que hacer) del mecanismo que la ejecuta, y tu clase queda libre para heredar de otra. Además, como verás enseguida, las herramientas modernas reciben tareas, no hilos.
start() frente a run()
Un error muy habitual al empezar es llamar a run() en lugar de start(). Compila y funciona… pero no crea ningún hilo nuevo: ejecuta el código en el hilo actual, como un método cualquiera.
Runnable quien = () -> System.out.println("Me ejecuta: " + Thread.currentThread().getName());
Thread h = new Thread(quien, "Ayudante");
h.run(); // Me ejecuta: main (¡no hay hilo nuevo!)
h.start(); // Me ejecuta: Ayudante
| Llamada | Qué hace |
|---|---|
hilo.start() | Crea un hilo nuevo y, dentro de él, ejecuta run(). Vuelve al instante |
hilo.run() | Ejecuta run() en el hilo actual y espera a que termine |
Cuidado: un mismo
Threadsolo se puede arrancar una vez. Llamar astart()por segunda vez lanzaIllegalThreadStateException. Si quieres repetir la tarea, crea otroThread.
Pausar y esperar: sleep y join
Thread.sleep(ms)detiene el hilo actual durante esos milisegundos. Sirve para simular tareas lentas (una descarga, una consulta).hilo.join()hace que el hilo actual espere hasta quehilotermine.
Las dos pueden lanzar InterruptedException (otro hilo puede “despertar” al que duerme o espera), que es una excepción comprobada: hay que capturarla o declararla con throws.
Ejemplo: el horno tarda 1 segundo y el lavavajillas 0,7. Los ponemos a la vez y, mientras, el hilo main pone la mesa:
long inicio = System.currentTimeMillis();
Thread horno = new Thread(() -> {
try {
Thread.sleep(1000);
System.out.println("Horno: pizza lista");
} catch (InterruptedException e) {
System.out.println("Horno interrumpido");
}
});
Thread lavavajillas = new Thread(() -> {
try {
Thread.sleep(700);
System.out.println("Lavavajillas: platos limpios");
} catch (InterruptedException e) {
System.out.println("Lavavajillas interrumpido");
}
});
horno.start();
lavavajillas.start();
System.out.println("Main: mientras tanto, pongo la mesa");
horno.join(); // espera a que termine el horno
lavavajillas.join(); // y el lavavajillas
long ms = System.currentTimeMillis() - inicio;
System.out.println("Todo listo en unos " + (ms / 100 * 100) + " ms");
// Main: mientras tanto, pongo la mesa
// Lavavajillas: platos limpios
// Horno: pizza lista
// Todo listo en unos 1000 ms
Las dos tareas se han hecho a la vez: el total es aproximadamente 1 segundo, no 1,7. Sin los join(), el mensaje “Todo listo” saldría antes de que la pizza estuviera hecha, porque main no esperaría a nadie.
El gran problema: condiciones de carrera
Los hilos comparten memoria, y eso es a la vez su ventaja y su peligro. Mira este programa: dos taquillas venden entradas a la vez y cada una suma 1 a un contador compartido 100 000 veces. El resultado debería ser 200 000:
public class Taquillas {
static int entradas = 0;
public static void main(String[] args) throws InterruptedException {
Runnable vender = () -> {
for (int i = 0; i < 100_000; i++) {
entradas++;
}
};
Thread t1 = new Thread(vender);
Thread t2 = new Thread(vender);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Entradas vendidas: " + entradas);
}
}
// Tres ejecuciones en el mismo ordenador:
// Entradas vendidas: 141837
// Entradas vendidas: 200000
// Entradas vendidas: 106674
A veces sale bien y a veces no, y cada vez da un número distinto. Esto es una condición de carrera (race condition): el resultado depende de cómo se intercalen los hilos.
¿Por qué ocurre? Porque entradas++ parece una sola operación, pero en realidad son tres: leer el valor, sumarle 1 y guardarlo. Si los dos hilos se intercalan así, se pierde una venta:
Hilo 1: lee entradas (50)
Hilo 2: lee entradas (50)
Hilo 1: suma 1 y guarda 51
Hilo 2: suma 1 y guarda 51 <- debería ser 52
Lo más traicionero es que el programa puede funcionar bien en tus pruebas y fallar en producción, con más carga. Por eso conviene evitar el problema desde el diseño.
Solución 1: synchronized
Un método synchronized solo puede ejecutarlo un hilo a la vez. Si otro hilo intenta entrar mientras está ocupado, espera su turno, como en el probador de una tienda:
static int entradas = 0;
static synchronized void venderUna() {
entradas++;
}
Runnable vender = () -> {
for (int i = 0; i < 100_000; i++) {
venderUna();
}
};
// ...mismo main que antes...
// Entradas vendidas: 200000 (siempre)
También existen los bloques synchronized (objeto) { ... }, que protegen solo un trozo de código. El precio de sincronizar es que los hilos se esperan unos a otros, así que el programa puede ir más lento: protege solo lo imprescindible.
Solución 2: AtomicInteger
Para contadores, el paquete java.util.concurrent.atomic trae clases atómicas: sus operaciones se hacen de una vez, sin que otro hilo pueda colarse en medio. Son más rápidas que synchronized y más cómodas:
AtomicInteger visitas = new AtomicInteger(); // empieza en 0
Runnable visitar = () -> {
for (int i = 0; i < 100_000; i++) {
visitas.incrementAndGet(); // el ++ seguro
}
};
// ...dos hilos, start y join...
System.out.println("Visitas: " + visitas.get()); // Visitas: 200000 (siempre)
Método de AtomicInteger | Equivale a |
|---|---|
incrementAndGet() | ++x |
getAndIncrement() | x++ |
addAndGet(5) | x += 5 |
get() / set(v) | Leer / escribir |
Consejo: la forma más segura de evitar condiciones de carrera es no compartir datos modificables. Si cada hilo trabaja con sus propios datos y al final se combinan los resultados, no hay nada que proteger. Los objetos inmutables, como los records, se pueden compartir sin miedo.
ExecutorService: deja que Java gestione los hilos
Crear un Thread a mano para cada tarea tiene problemas: crear hilos cuesta, si lanzas miles el ordenador se satura, y hay que acordarse de hacer join() de todos. En programas reales se usa un ExecutorService: un grupo de hilos (pool) reutilizables al que le entregas tareas y él se encarga de repartirlas.
Es como la cocina con dos cocineros fijos: les pasas comandas y cada uno coge la siguiente cuando termina la anterior.
ExecutorService cocina = Executors.newFixedThreadPool(2); // 2 hilos
for (String plato : List.of("sopa", "paella", "flan", "ensalada")) {
cocina.submit(() -> System.out.println(Thread.currentThread().getName() + " prepara " + plato));
}
cocina.shutdown(); // no acepta más tareas
cocina.awaitTermination(5, TimeUnit.SECONDS); // espera a que acaben
// Una ejecución posible:
// pool-1-thread-1 prepara sopa
// pool-1-thread-2 prepara paella
// pool-1-thread-2 prepara flan
// pool-1-thread-1 prepara ensalada
Cuatro tareas, solo dos hilos: se han reutilizado. Si no llamas a shutdown(), los hilos del pool se quedan esperando más trabajo y el programa no termina.
Tareas que devuelven un resultado: Callable y Future
Si la tarea devuelve algo, en lugar de un Runnable se usa un Callable (una lambda que devuelve un valor y puede lanzar excepciones). submit devuelve entonces un Future: un “resguardo” con el que recoger el resultado más tarde. Su método get() espera a que la tarea termine.
Ejemplo: consultar las ventas de tres tiendas a la vez. Cada consulta tarda medio segundo:
static int contarVentas(String tienda) throws InterruptedException {
Thread.sleep(500); // simula una consulta lenta
return tienda.length() * 10;
}
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
Future<Integer> f1 = pool.submit(() -> contarVentas("Santander"));
Future<Integer> f2 = pool.submit(() -> contarVentas("Torrelavega"));
Future<Integer> f3 = pool.submit(() -> contarVentas("Laredo"));
int total = f1.get() + f2.get() + f3.get();
System.out.println("Ventas totales: " + total);
}
// Ventas totales: 260
Las tres consultas se hacen en paralelo, así que el total tarda medio segundo y no un segundo y medio. Desde Java 19, ExecutorService se puede usar en un try-with-resources (como los ficheros en ficheros): al salir del bloque, espera a las tareas y cierra el pool solo. En código anterior verás shutdown() y awaitTermination() a mano.
Hilos virtuales (Java 21)
Los hilos normales de Java (hilos de plataforma) corresponden cada uno a un hilo del sistema operativo, que es un recurso caro: un programa puede tener unos pocos miles como mucho. Eso es un problema en un servidor que atiende a decenas de miles de clientes, cada uno esperando a una base de datos o a otra web.
Java 21 incorporó los hilos virtuales: hilos muy ligeros que gestiona la propia JVM. Puedes crear millones, y cuando uno se queda esperando (un sleep, una consulta, una descarga) deja libre su hilo real para otro. Se usan casi igual que los normales:
Thread virtual = Thread.ofVirtual().start(() ->
System.out.println("Soy virtual: " + Thread.currentThread().isVirtual()));
virtual.join();
// Soy virtual: true
Lo más práctico es un executor que crea un hilo virtual por tarea. Aquí lanzamos 10 000 tareas que esperan un segundo cada una:
AtomicInteger hechas = new AtomicInteger();
long t0 = System.currentTimeMillis();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
ex.submit(() -> {
Thread.sleep(1000);
hechas.incrementAndGet();
return null;
});
}
}
System.out.println(hechas.get() + " tareas en " + (System.currentTimeMillis() - t0) + " ms");
// 10000 tareas en 1101 ms (el tiempo varía un poco)
Diez mil esperas de un segundo en poco más de un segundo. Con un pool de 2 hilos normales tardaría más de una hora y media.
Nota: los hilos virtuales brillan cuando las tareas pasan mucho tiempo esperando (red, ficheros, bases de datos). Para cálculos pesados que tienen la CPU ocupada no aportan nada: ahí lo que manda es el número de núcleos.
Errores frecuentes
- Llamar a
run()en lugar destart(). No se crea ningún hilo; el código se ejecuta en el hilo actual. - Suponer un orden de ejecución. Los hilos se ejecutan en el orden que decide el sistema. Si necesitas esperar a uno, usa
join()oFuture.get(). - Modificar datos compartidos sin protección.
contador++desde varios hilos pierde actualizaciones. Usasynchronized, clases atómicas o, mejor, no compartas. - Olvidar
shutdown()en unExecutorService. El programa no termina. Usa try-with-resources. - Ignorar
InterruptedExceptioncon uncatchvacío. Al menos informa o termina la tarea. - Usar
ArrayListoHashMapdesde varios hilos a la vez. No están preparadas; existen versiones concurrentes comoConcurrentHashMap. - Pensar que más hilos siempre es más rápido. Crear y coordinar hilos tiene un coste; para tareas cortas, un solo hilo suele ganar.
Resumen
| Concepto | Código |
|---|---|
| Hilo actual | Thread.currentThread().getName() |
| Definir tarea | Runnable tarea = () -> { ... }; |
| Crear y arrancar | Thread h = new Thread(tarea); h.start(); |
| Pausar | Thread.sleep(ms) |
| Esperar a otro hilo | h.join() |
| Exclusión mutua | synchronized void metodo() { ... } |
| Contador seguro | AtomicInteger c = new AtomicInteger(); c.incrementAndGet(); |
| Pool de hilos | Executors.newFixedThreadPool(n), submit(...), shutdown() |
| Resultado futuro | Future<T> f = pool.submit(() -> ...); f.get(); |
| Hilos virtuales | Thread.ofVirtual().start(...), Executors.newVirtualThreadPerTaskExecutor() |
En la siguiente lección aprenderás a comprobar automáticamente que tu código funciona con pruebas unitarias: pruebas con JUnit.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.