Ir al contenido principal

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:

  1. Java no permite herencia múltiple, por lo que si extiendes Thread, no podrás extender ninguna otra clase.
  2. Implementar Runnable separa mejor la tarea (qué hacer) del mecanismo de ejecución (cómo hacerlo).
  3. 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:

  1. Nuevo (New): El hilo ha sido creado pero aún no ha sido iniciado.
  2. Ejecutable (Runnable): El hilo está listo para ejecutarse y compite por tiempo de CPU.
  3. Bloqueado (Blocked): El hilo está esperando por un monitor (lock).
  4. En espera (Waiting): El hilo está esperando indefinidamente a que otro hilo realice una acción.
  5. Esperando por tiempo (Timed Waiting): Similar al estado anterior pero con un tiempo máximo de espera.
  6. 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:

  1. Adquirir los recursos siempre en el mismo orden
  2. Limitar el tiempo de bloqueo (usar tryLock con un tiempo máximo)
  3. 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

  1. Minimiza el uso de estado compartido: Cada vez que compartes datos entre hilos, introduces complejidad.
  2. Prefiere inmutabilidad: Los objetos inmutables son thread-safe por naturaleza.
  3. Utiliza las abstracciones de alto nivel: Usa ExecutorService en lugar de manipular hilos directamente.
  4. Mantén las secciones críticas pequeñas: Minimiza el tiempo que los hilos mantienen bloqueos.
  5. Documenta la sincronización: Deja claro qué métodos son thread-safe y cuáles no.
  6. Evita la sincronización anidada: Puede llevar a deadlocks difíciles de detectar.
  7. Considera usar volatile para visibilidad: Para variables simples que solo necesitan visibilidad, no atomicidad.
  8. 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.