Programación multihilo: threads y concurrencia
Introducción
La programación multihilo es una de las características más potentes de Java, permitiendo que nuestras aplicaciones realicen múltiples tareas de forma simultánea. Cuando escribimos programas que solo utilizan un hilo (thread), estamos limitando su capacidad de aprovechamiento de los procesadores modernos, que disponen de múltiples núcleos. En este artículo, exploraremos cómo Java nos permite crear y gestionar hilos para desarrollar aplicaciones concurrentes más eficientes, capaces de realizar múltiples operaciones en paralelo y aprovechar mejor los recursos del sistema.
Los conocimientos de programación multihilo son fundamentales para el desarrollo de aplicaciones modernas, especialmente aquellas que requieren alta capacidad de respuesta o procesan grandes volúmenes de datos. Desde servidores web hasta aplicaciones de escritorio, el manejo adecuado de hilos permite mejorar considerablemente el rendimiento y la experiencia del usuario.
¿Qué es un hilo?
Un hilo (thread) es la unidad más pequeña de procesamiento que puede ser gestionada por el sistema operativo. Podemos entenderlo como una secuencia de instrucciones que se ejecutan de forma independiente dentro de un proceso más amplio (nuestra aplicación Java).
Diferencia entre proceso e hilo
Para entender mejor el concepto, es útil comparar:
- Proceso: Es una instancia de un programa en ejecución. Tiene su propio espacio de memoria y recursos asignados.
- Hilo: Es una unidad de ejecución dentro de un proceso. Los hilos de un mismo proceso comparten el espacio de memoria y recursos.
Una analogía sencilla sería pensar en un proceso como una fábrica, y los hilos como los trabajadores dentro de ella. Todos trabajan bajo el mismo techo (comparten recursos), pero pueden realizar tareas diferentes de forma simultánea.
Creación de hilos en Java
Java ofrece dos formas principales de crear hilos:
1. Extendiendo la clase Thread
public class MiHilo extends Thread {
@Override
public void run() {
// Código que se ejecutará en el nuevo hilo
for (int i = 0; i < 5; i++) {
System.out.println("Hilo extendido: " + i);
try {
// Pausa la ejecución durante 1 segundo
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Hilo interrumpido");
}
}
}
}
// Uso
public class PruebaHilos {
public static void main(String[] args) {
MiHilo hilo = new MiHilo();
hilo.start(); // Inicia la ejecución del hilo
// Este código se ejecuta en el hilo principal
for (int i = 0; i < 5; i++) {
System.out.println("Hilo principal: " + i);
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
System.out.println("Hilo principal interrumpido");
}
}
}
}
2. Implementando la interfaz Runnable
public class MiRunnable implements Runnable {
@Override
public void run() {
// Código que se ejecutará en el nuevo hilo
for (int i = 0; i < 5; i++) {
System.out.println("Runnable: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Runnable interrumpido");
}
}
}
}
// Uso
public class PruebaRunnable {
public static void main(String[] args) {
MiRunnable tarea = new MiRunnable();
Thread hilo = new Thread(tarea);
hilo.start(); // Inicia la ejecución del hilo
// Este código se ejecuta en el hilo principal
for (int i = 0; i < 5; i++) {
System.out.println("Hilo principal: " + i);
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
System.out.println("Hilo principal interrumpido");
}
}
}
}
¿Cuál método elegir?
En general, se recomienda implementar Runnable
en lugar de extender Thread
porque:
- Java no permite herencia múltiple, por lo que si extiendes
Thread
, no podrás extender ninguna otra clase. - Implementar
Runnable
separa mejor la tarea (qué hacer) del mecanismo de ejecución (cómo hacerlo). Runnable
puede ser usado con el framework Executor, pools de hilos y otras utilidades de concurrencia.
Ciclo de vida de un hilo
Un hilo en Java pasa por varios estados durante su ejecución:
- Nuevo (New): El hilo ha sido creado pero aún no ha sido iniciado.
- Ejecutable (Runnable): El hilo está listo para ejecutarse y compite por tiempo de CPU.
- Bloqueado (Blocked): El hilo está esperando por un monitor (lock).
- En espera (Waiting): El hilo está esperando indefinidamente a que otro hilo realice una acción.
- Esperando por tiempo (Timed Waiting): Similar al estado anterior pero con un tiempo máximo de espera.
- Terminado (Terminated): El hilo ha finalizado su ejecución.
// Ejemplo de transición de estados
public class EstadosHilo {
public static void main(String[] args) throws InterruptedException {
Thread hilo = new Thread(() -> {
try {
// El hilo entra en estado TIMED_WAITING
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Hilo finalizado");
});
// Estado: NEW
System.out.println("Estado antes de iniciar: " + hilo.getState());
hilo.start();
// Estado: RUNNABLE
System.out.println("Estado después de iniciar: " + hilo.getState());
Thread.sleep(1000);
// Estado: TIMED_WAITING (porque está ejecutando sleep)
System.out.println("Estado durante sleep: " + hilo.getState());
hilo.join(); // Espera a que el hilo termine
// Estado: TERMINATED
System.out.println("Estado después de terminar: " + hilo.getState());
}
}
Métodos importantes de la clase Thread
Java proporciona varios métodos para controlar el comportamiento de los hilos:
public class MetodosThread {
public static void main(String[] args) throws InterruptedException {
Thread hilo = new Thread(() -> {
for (int i = 0; i < 10; i++) {
System.out.println("Contador: " + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
System.out.println("¡Hilo interrumpido!");
return; // Termina el hilo si es interrumpido
}
}
});
hilo.start(); // Inicia el hilo
// Obtiene el nombre del hilo
System.out.println("Nombre del hilo: " + hilo.getName());
// Establece el nombre del hilo
hilo.setName("MiHiloPersonalizado");
System.out.println("Nuevo nombre: " + hilo.getName());
// Comprueba si el hilo está vivo
System.out.println("¿Está vivo? " + hilo.isAlive());
// Espera 2 segundos y luego interrumpe el hilo
Thread.sleep(2000);
hilo.interrupt();
// Espera a que el hilo termine antes de continuar
hilo.join();
System.out.println("Hilo finalizado, continuamos...");
}
}
Sincronización y problemas de concurrencia
Cuando múltiples hilos acceden a recursos compartidos, pueden surgir problemas como:
Condiciones de carrera (Race conditions)
Ocurren cuando el resultado de un programa depende del orden en que se ejecutan los hilos.
public class ContadorInseguro {
private int contador = 0;
public void incrementar() {
contador++; // No es una operación atómica
}
public int getContador() {
return contador;
}
public static void main(String[] args) throws InterruptedException {
ContadorInseguro contador = new ContadorInseguro();
// Creamos 10 hilos que incrementarán el contador 1000 veces cada uno
Thread[] hilos = new Thread[10];
for (int i = 0; i < 10; i++) {
hilos[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
contador.incrementar();
}
});
hilos[i].start();
}
// Esperamos a que todos los hilos terminen
for (Thread hilo : hilos) {
hilo.join();
}
// Debería ser 10000, pero probablemente sea menor
System.out.println("Valor final del contador: " + contador.getContador());
}
}
Solución: sincronización con synchronized
Java proporciona la palabra clave synchronized
para evitar que múltiples hilos accedan simultáneamente a secciones críticas de código:
public class ContadorSeguro {
private int contador = 0;
// Método sincronizado
public synchronized void incrementar() {
contador++;
}
public int getContador() {
return contador;
}
public static void main(String[] args) throws InterruptedException {
ContadorSeguro contador = new ContadorSeguro();
// Creamos 10 hilos que incrementarán el contador 1000 veces cada uno
Thread[] hilos = new Thread[10];
for (int i = 0; i < 10; i++) {
hilos[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
contador.incrementar();
}
});
hilos[i].start();
}
// Esperamos a que todos los hilos terminen
for (Thread hilo : hilos) {
hilo.join();
}
// Ahora sí debería ser 10000
System.out.println("Valor final del contador: " + contador.getContador());
}
}
Bloqueos (Deadlocks)
Un deadlock ocurre cuando dos o más hilos se bloquean mutuamente esperando recursos que el otro tiene:
public class EjemploDeadlock {
private static final Object recurso1 = new Object();
private static final Object recurso2 = new Object();
public static void main(String[] args) {
Thread hilo1 = new Thread(() -> {
synchronized (recurso1) {
System.out.println("Hilo 1: Tiene recurso 1, esperando recurso 2");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (recurso2) {
System.out.println("Hilo 1: Tiene ambos recursos");
}
}
});
Thread hilo2 = new Thread(() -> {
// Aquí está el problema: el orden de adquisición de los recursos es inverso
synchronized (recurso2) {
System.out.println("Hilo 2: Tiene recurso 2, esperando recurso 1");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (recurso1) {
System.out.println("Hilo 2: Tiene ambos recursos");
}
}
});
hilo1.start();
hilo2.start();
}
}
Para evitar deadlocks, es recomendable:
- Adquirir los recursos siempre en el mismo orden
- Limitar el tiempo de bloqueo (usar
tryLock
con un tiempo máximo) - Detectar deadlocks mediante herramientas de diagnóstico
API de concurrencia de Java (java.util.concurrent)
Java proporciona un conjunto de utilidades en el paquete java.util.concurrent
que facilitan el trabajo con programación multihilo:
Executor Framework
Permite gestionar la ejecución de tareas asíncronas sin necesidad de manipular hilos directamente:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class EjemploExecutor {
public static void main(String[] args) {
// Crea un pool con 3 hilos
ExecutorService executor = Executors.newFixedThreadPool(3);
// Envía 10 tareas al pool
for (int i = 0; i < 10; i++) {
final int id = i;
executor.submit(() -> {
System.out.println("Tarea " + id + " iniciada por " + Thread.currentThread().getName());
try {
// Simula trabajo
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Tarea " + id + " completada");
});
}
// Finaliza ordenadamente el executor
executor.shutdown();
try {
// Espera hasta 10 segundos para que terminen todas las tareas
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
// Fuerza la terminación si no han acabado en ese tiempo
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
}
Clases atómicas
Proporcionan operaciones atómicas sin necesidad de sincronización explícita:
import java.util.concurrent.atomic.AtomicInteger;
public class ContadorAtomico {
private AtomicInteger contador = new AtomicInteger(0);
public void incrementar() {
contador.incrementAndGet(); // Operación atómica
}
public int getContador() {
return contador.get();
}
public static void main(String[] args) throws InterruptedException {
ContadorAtomico contador = new ContadorAtomico();
Thread[] hilos = new Thread[10];
for (int i = 0; i < 10; i++) {
hilos[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
contador.incrementar();
}
});
hilos[i].start();
}
for (Thread hilo : hilos) {
hilo.join();
}
// Siempre será 10000
System.out.println("Valor final del contador: " + contador.getContador());
}
}
Colecciones concurrentes
Java proporciona implementaciones thread-safe de las colecciones estándar:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class EjemploColeccionesConcurrentes {
public static void main(String[] args) throws InterruptedException {
// ConcurrentHashMap es thread-safe
Map<String, Integer> mapa = new ConcurrentHashMap<>();
Thread[] hilos = new Thread[5];
for (int i = 0; i < 5; i++) {
final int id = i;
hilos[i] = new Thread(() -> {
for (int j = 0; j < 100; j++) {
mapa.put("Hilo" + id + "-" + j, j);
}
});
hilos[i].start();
}
for (Thread hilo : hilos) {
hilo.join();
}
System.out.println("Tamaño del mapa: " + mapa.size()); // Debería ser 500
}
}
Locks y Conditions
Proporcionan un control más fino sobre la sincronización que synchronized
:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class EjemploLock {
private int contador = 0;
private final Lock lock = new ReentrantLock();
public void incrementar() {
lock.lock(); // Adquiere el lock
try {
contador++;
} finally {
lock.unlock(); // Siempre libera el lock
}
}
public int getContador() {
return contador;
}
public static void main(String[] args) throws InterruptedException {
EjemploLock contador = new EjemploLock();
Thread[] hilos = new Thread[10];
for (int i = 0; i < 10; i++) {
hilos[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
contador.incrementar();
}
});
hilos[i].start();
}
for (Thread hilo : hilos) {
hilo.join();
}
System.out.println("Valor final: " + contador.getContador()); // 10000
}
}
Patrones de concurrencia comunes
Productor-Consumidor
Este patrón se usa cuando tienes procesos que generan datos (productores) y otros que los procesan (consumidores):
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ProductorConsumidor {
private final Queue<Integer> buffer = new LinkedList<>();
private final int CAPACIDAD = 10;
private final Lock lock = new ReentrantLock();
private final Condition noLleno = lock.newCondition();
private final Condition noVacio = lock.newCondition();
public void producir(int valor) throws InterruptedException {
lock.lock();
try {
while (buffer.size() == CAPACIDAD) {
// Espera si el buffer está lleno
noLleno.await();
}
buffer.add(valor);
System.out.println("Producido: " + valor);
// Notifica a los consumidores que hay un elemento
noVacio.signal();
} finally {
lock.unlock();
}
}
public int consumir() throws InterruptedException {
lock.lock();
try {
while (buffer.isEmpty()) {
// Espera si el buffer está vacío
noVacio.await();
}
int valor = buffer.poll();
System.out.println("Consumido: " + valor);
// Notifica a los productores que hay espacio
noLleno.signal();
return valor;
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ProductorConsumidor pc = new ProductorConsumidor();
// Hilo productor
Thread productor = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
pc.producir(i);
Thread.sleep(100); // Simula tiempo de producción
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// Hilo consumidor
Thread consumidor = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
pc.consumir();
Thread.sleep(200); // Simula tiempo de consumo
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
productor.start();
consumidor.start();
}
}
Lector-Escritor
Este patrón permite que múltiples lectores accedan simultáneamente a un recurso, pero solo un escritor puede modificarlo a la vez:
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class LectorEscritor {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private int valor = 0;
public int leer() {
rwLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " está leyendo: " + valor);
// Simula lectura lenta
try { Thread.sleep(500); } catch (InterruptedException e) {}
return valor;
} finally {
rwLock.readLock().unlock();
}
}
public void escribir(int nuevoValor) {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " está escribiendo: " + nuevoValor);
// Simula escritura lenta
try { Thread.sleep(1000); } catch (InterruptedException e) {}
this.valor = nuevoValor;
} finally {
rwLock.writeLock().unlock();
}
}
public static void main(String[] args) {
LectorEscritor le = new LectorEscritor();
// Hilos lectores
for (int i = 0; i < 5; i++) {
new Thread(() -> {
while (true) {
le.leer();
try { Thread.sleep(200); } catch (InterruptedException e) {}
}
}, "Lector-" + i).start();
}
// Hilos escritores
for (int i = 0; i < 2; i++) {
new Thread(() -> {
int contador = 0;
while (true) {
le.escribir(contador++);
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}
}, "Escritor-" + i).start();
}
}
}
Buenas prácticas en programación multihilo
- Minimiza el uso de estado compartido: Cada vez que compartes datos entre hilos, introduces complejidad.
- Prefiere inmutabilidad: Los objetos inmutables son thread-safe por naturaleza.
- Utiliza las abstracciones de alto nivel: Usa
ExecutorService
en lugar de manipular hilos directamente. - Mantén las secciones críticas pequeñas: Minimiza el tiempo que los hilos mantienen bloqueos.
- Documenta la sincronización: Deja claro qué métodos son thread-safe y cuáles no.
- Evita la sincronización anidada: Puede llevar a deadlocks difíciles de detectar.
- Considera usar
volatile
para visibilidad: Para variables simples que solo necesitan visibilidad, no atomicidad. - No dependas del timing: Los programas concurrentes no deberían depender del orden de ejecución de los hilos.
Resumen
La programación multihilo es una herramienta poderosa que permite a nuestras aplicaciones Java aprovechar mejor los recursos del sistema ejecutando tareas en paralelo. Hemos aprendido cómo crear y gestionar hilos, cómo sincronizar el acceso a recursos compartidos mediante synchronized
, locks y otras utilidades, y cómo evitar problemas comunes como condiciones de carrera y deadlocks.
Java proporciona un rico conjunto de herramientas en el paquete java.util.concurrent
que facilitan el desarrollo de aplicaciones concurrentes robustas, desde el framework Executor hasta colecciones thread-safe y operaciones atómicas. Dominar estos conceptos te permitirá crear aplicaciones más eficientes y escalables, capaces de responder adecuadamente a las exigencias de los usuarios modernos.