Programación Multihilo en Java: Conceptos y Ejemplos Prácticos

  1. Hilos y Concurrencia en Java

La programación multihilo permite que una aplicación ejecute varias partes de su código de forma concurrente. En el contexto de Java, un hilo representa una ruta de ejecución independiente dentro de un proceso. Aunque un programa no cree hilos explícitamente, la Máquina Virtual de Java (JVM) y el sistema operativo siempre están ejecutando múltiples hilos en segundo plano.

  • El método main() inicia el hilo principal, que sirve como punto de entrada del programa.
  • Dentro de un proceso, la ejecución de múltiples hilos es gestionada por un planificador de hilos del sistema operativo, lo que significa que el orden de ejecución no puede ser intervenido directamente por el programador.
  • La operación simultánea sobre recursos compartidos por múltiples hilos puede generar condiciones de carrera y resultados inconsistentes, requiriendo mecanismos de control de concurrencia.
  • La gestión de hilos introduce una sobrecarga adicional, como el tiempo de cambio de contexto de la CPU y los gastos generales de sincronización.
  • Cada hilo interactúa con su propia memoria de trabajo (caché local), lo que, si no se gestiona adecuadamente, puede llevar a inconsistencias de datos entre la memoria principal y las cachés locales.
  1. Implementación de Hilos en Java

Java ofrece principalmente tres maneras de crear y gestionar hilos de ejecución.

2.1 Tres Formas de Creación

  1. Extendiendo la clase java.lang.Thread.
  2. Implementando la interfaz java.lang.Runnable.
  3. Implementando la interfaz java.util.concurrent.Callable, generalmente utilizada con un ExecutorService para tareas que devuelven un resultado y pueden lanzar excepciones.

2.2 Creación de Hilos con la Clase Thread

La forma más sencilla de crear un hilo es extendiendo la clase Thread y sobrescribiendo su método run(). El código a ejecutar en el nuevo hilo se coloca dentro de este método. Para iniciar el hilo, se llama al método start(), que a su vez invoca a run() en un hilo de ejecución separado.

import java.io.IOException;
import java.net.URL;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;

// Clase para simular una descarga de contenido
class GestorDescargas {
    public void descargarContenido(String direccion, String nombreLocal) {
        try {
            URL url = new URL(direccion);
            File archivoDestino = new File(nombreLocal);
            // Simulación: en una aplicación real se usaría Files.copy o Apache Commons IO
            System.out.println("Iniciando descarga simulada de: " + direccion + " a " + nombreLocal);
            Thread.sleep(100 + (long)(Math.random() * 500)); // Simula tiempo de descarga
            System.out.println("Descarga simulada completada: " + nombreLocal);
        } catch (IOException e) {
            System.err.println("Error de E/S al descargar " + nombreLocal + ": " + e.getMessage());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("La descarga de " + nombreLocal + " fue interrumpida.");
        }
    }
}

// Clase de hilo que extiende Thread
public class HiloDescargador extends Thread {
    private String urlRemota; // Dirección del contenido remoto
    private String nombreArchivoLocal; // Nombre para guardar el archivo localmente

    public HiloDescargador(String urlRemota, String nombreArchivoLocal) {
        this.urlRemota = urlRemota;
        this.nombreArchivoLocal = nombreArchivoLocal;
    }

    @Override
    public void run() {
        GestorDescargas gestor = new GestorDescargas();
        gestor.descargarContenido(urlRemota, nombreArchivoLocal);
    }

    public static void main(String[] args) {
        System.out.println("Iniciando descargas con HiloDescargador...");
        HiloDescargador descarga1 = new HiloDescargador("http://ejemplo.com/imagen1.jpg", "img_01.jpg");
        HiloDescargador descarga2 = new HiloDescargador("http://ejemplo.com/documento.pdf", "doc_reporte.pdf");
        HiloDescargador descarga3 = new HiloDescargador("http://ejemplo.com/video.mp4", "vid_promo.mp4");

        descarga1.start();
        descarga2.start();
        descarga3.start();
    }
}

2.3 Creación de Hilos Implementando Runnable

Una alternativa más flexible es implementar la interfaz Runnable. Una clase que implementa Runnable debe proporcionar una implementación para el método run(). Una instancia de esta clase se puede pasar al constructor de un objeto Thread, que luego se inicia.

import java.io.IOException;
import java.net.URL;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;

// Reutilizamos GestorDescargas de la sección anterior

// Clase de hilo que implementa Runnable
public class TareaDescarga implements Runnable {
    private String urlRemota;
    private String nombreArchivoLocal;

    public TareaDescarga(String urlRemota, String nombreArchivoLocal) {
        this.urlRemota = urlRemota;
        this.nombreArchivoLocal = nombreArchivoLocal;
    }

    @Override
    public void run() {
        GestorDescargas gestor = new GestorDescargas();
        gestor.descargarContenido(urlRemota, nombreArchivoLocal);
    }

    public static void main(String[] args) {
        System.out.println("Iniciando descargas con TareaDescarga (Runnable)...");
        TareaDescarga tarea1 = new TareaDescarga("http://ejemplo.com/musica.mp3", "cancion.mp3");
        TareaDescarga tarea2 = new TareaDescarga("http://ejemplo.com/hoja.xls", "datos.xls");
        TareaDescarga tarea3 = new TareaDescarga("http://ejemplo.com/presentacion.ppt", "presentacion.ppt");

        new Thread(tarea1).start();
        new Thread(tarea2).start();
        new Thread(tarea3).start();
    }
}

2.4 Múltiples Hilos Operando sobre el Mismo Objeto

Cuando varios hilos acceden y modifican el mismo recurso compartido, pueden surgir problemas de concurrencia, como inconsistencias de datos o resultados inesperados. El siguiente ejemplo ilustra una situación donde múltiples vendedores intentan vender boletos de un inventario compartido sin sincronización.

public class VentaBoletosConcurrente implements Runnable {
    private int boletosDisponibles = 10; // Recurso compartido

    @Override
    public void run() {
        while (true) {
            if (boletosDisponibles <= 0) {
                System.out.println(Thread.currentThread().getName() + ": No quedan boletos.");
                break;
            }
            try {
                Thread.sleep(150); // Simula el tiempo de procesamiento de la venta
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.err.println("El hilo de venta fue interrumpido.");
            }
            // Esto es una sección crítica sin sincronización
            System.out.println(Thread.currentThread().getName() + " vendió el boleto #" + boletosDisponibles);
            boletosDisponibles--;
        }
    }

    public static void main(String[] args) {
        VentaBoletosConcurrente sistemaVenta = new VentaBoletosConcurrente();

        new Thread(sistemaVenta, "Vendedor-A").start();
        new Thread(sistemaVenta, "Vendedor-B").start();
        new Thread(sistemaVenta, "Vendedor-C").start();
    }
}

Este código demostrará que, sin sincronización, múltiples hilos pueden "vender" el mismo boleto o intentar vender boletos después de que el inventario se haya agotado, resultando en números de boleto negativos o duplicados.

2.5 Simulación de Carrera de Tortugas y Liebres

Un clásico ejemplo para ilustrar la concurrencia y la detección de un ganador en una carrera de hilos.

public class SimulacionCarrera implements Runnable {
    private static String ganadorCarrera = null; // Volatile para visibilidad entre hilos

    @Override
    public void run() {
        String nombreParticipante = Thread.currentThread().getName();
        for (int paso = 1; paso <= 100; paso++) {
            // La liebre "descansa" periódicamente para simular su comportamiento
            if (nombreParticipante.equals("Liebre") && paso % 15 == 0) {
                try {
                    System.out.println(nombreParticipante + " hace una pausa en el paso " + paso);
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }

            // Comprobar si ya hay un ganador
            if (verificarFinDeCarrera(paso)) {
                break;
            }

            System.out.println(nombreParticipante + " ha avanzado " + paso + " pasos.");
        }
    }

    private boolean verificarFinDeCarrera(int pasosActuales) {
        if (ganadorCarrera != null) { // Alguien ya ganó
            return true;
        }
        if (pasosActuales >= 100) { // Si un participante llegó a la meta
            ganadorCarrera = Thread.currentThread().getName();
            System.out.println("¡El ganador es " + ganadorCarrera + "!");
            return true;
        }
        return false;
    }

    public static void main(String[] args) {
        SimulacionCarrera carrera = new SimulacionCarrera();

        new Thread(carrera, "Liebre").start();
        new Thread(carrera, "Tortuga").start();
    }
}

2.6 Creación de Hilos con la Interfaz Callable

La interfaz Callable es similar a Runnable, pero su método call() puede devolver un resultado y lanzar excepciones. Se utiliza junto con un ExecutorService y objetos Future para obtener los resultados de las tareas asíncronas.

import java.util.concurrent.*;

// Clase que implementa Callable para una tarea con resultado
public class TareaAsincrona implements Callable<String> {
    private String nombreTarea;
    private int duracionMs;

    public TareaAsincrona(String nombreTarea, int duracionMs) {
        this.nombreTarea = nombreTarea;
        this.duracionMs = duracionMs;
    }

    @Override
    public String call() throws Exception {
        System.out.println("Iniciando tarea: " + nombreTarea);
        Thread.sleep(duracionMs); // Simula el trabajo
        String resultado = "Tarea '" + nombreTarea + "' completada en " + duracionMs + "ms.";
        System.out.println(resultado);
        return resultado;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 1. Crear un servicio de ejecución (pool de hilos)
        ExecutorService servicioEjecutor = Executors.newFixedThreadPool(2);

        // 2. Crear instancias de Callable
        TareaAsincrona tareaA = new TareaAsincrona("Procesar Datos", 1200);
        TareaAsincrona tareaB = new TareaAsincrona("Generar Informe", 800);
        TareaAsincrona tareaC = new TareaAsincrona("Enviar Notificaciones", 1500);

        // 3. Enviar las tareas al servicio de ejecución y obtener Future
        Future<String> resultadoA = servicioEjecutor.submit(tareaA);
        Future<String> resultadoB = servicioEjecutor.submit(tareaB);
        Future<String> resultadoC = servicioEjecutor.submit(tareaC);

        // 4. Obtener los resultados (bloquea hasta que la tarea termina)
        System.out.println("\nEsperando resultados...");
        System.out.println("Resultado de Tarea A: " + resultadoA.get());
        System.out.println("Resultado de Tarea B: " + resultadoB.get());
        System.out.println("Resultado de Tarea C: " + resultadoC.get());

        // 5. Cerrar el servicio de ejecución
        servicioEjecutor.shutdown();
        System.out.println("Servicio de ejecución cerrado.");
    }
}

2.7 Patrón de Proxy Estático

El patrón Proxy Estático es un patrón de diseño estructural que permite proporcionar un sustituto o marcador de posición para otro objeto. Un proxy controla el acceso al objeto original, lo que puede ser útil para añadir funcionalidades adicionales antes o después de la ejecución del método del objeto real, como la gestión de hilos, la seguridad o el registro. Aunque no es exclusivo de la programación multihilo, a menudo se usa para envolver la creación de hilos o la ejecución de tareas.

interface ServicioPrincipal {
    void ejecutarAccion();
}

class ImplementacionReal implements ServicioPrincipal {
    @Override
    public void ejecutarAccion() {
        System.out.println("Realizando la acción principal...");
    }
}

class ProxyServicio implements ServicioPrincipal {
    private ServicioPrincipal objetoReal;

    public ProxyServicio(ServicioPrincipal objetoReal) {
        this.objetoReal = objetoReal;
    }

    private void antesDeEjecutar() {
        System.out.println("Proxy: Preparando antes de la acción.");
    }

    private void despuesDeEjecutar() {
        System.out.println("Proxy: Finalizando después de la acción.");
    }

    @Override
    public void ejecutarAccion() {
        antesDeEjecutar();
        this.objetoReal.ejecutarAccion();
        despuesDeEjecutar();
    }
}

public class EjemploProxyEstatico {
    public static void main(String[] args) {
        // Ejecución directa del servicio real
        System.out.println("--- Ejecución directa ---");
        ServicioPrincipal servicioDirecto = new ImplementacionReal();
        servicioDirecto.ejecutarAccion();

        // Ejecución a través del proxy
        System.out.println("\n--- Ejecución a través del proxy ---");
        ServicioPrincipal servicioConProxy = new ProxyServicio(new ImplementacionReal());
        servicioConProxy.ejecutarAccion();

        // El proxy puede envolver también la ejecución de un hilo (aunque no lo muestra aquí)
        System.out.println("\n--- Ejecución con Lambda (demostración Thread) ---");
        new Thread(() -> System.out.println("Acción desde un hilo Lambda")).start();
    }
}

  1. Expresiones Lambda en Java

Una expresión lambda en Java (introducida en Java 8) es una forma concisa de representar una instancia de una interfaz funcional (una interfaz con un único método abstracto). Simplifica la escritura de código, especialmente en el contexto de las APIs de concurrencia y colecciones.

interface OperacionGenerica {
    void realizar();
}

public class EjemploLambda {

    // 1. Clase estática interna que implementa la interfaz funcional
    static class ImplementacionEstatica implements OperacionGenerica {
        @Override
        public void realizar() {
            System.out.println("Implementación 1: Clase estática interna.");
        }
    }

    public static void main(String[] args) {
        // Usando la implementación estática
        OperacionGenerica op1 = new ImplementacionEstatica();
        op1.realizar();

        // 2. Clase local dentro de un método
        class ImplementacionLocal implements OperacionGenerica {
            @Override
            public void realizar() {
                System.out.println("Implementación 2: Clase local.");
            }
        }
        OperacionGenerica op2 = new ImplementacionLocal();
        op2.realizar();

        // 3. Clase anónima
        OperacionGenerica op3 = new OperacionGenerica() {
            @Override
            public void realizar() {
                System.out.println("Implementación 3: Clase anónima.");
            }
        };
        op3.realizar();

        // 4. Expresión Lambda (la forma más concisa)
        OperacionGenerica op4 = () -> {
            System.out.println("Implementación 4: Expresión Lambda.");
        };
        op4.realizar();

        // Lambda con una sola sentencia no requiere llaves
        OperacionGenerica op5 = () -> System.out.println("Implementación 5: Lambda simplificada.");
        op5.realizar();
    }
}

  1. Estados de los Hilos

Los hilos en Java pasan por varios estados durante su ciclo de vida. La clase Thread.State define estos estados:

  • NEW: Un hilo que aún no ha comenzado.
  • RUNNABLE: Un hilo que se está ejecutando o está listo para ejecutar.
  • BLOCKED: Un hilo bloqueado esperando un monitor de bloqueo.
  • WAITING: Un hilo esperando indefinidamente a otro hilo.
  • TIMED_WAITING: Un hilo esperando por un tiempo específico.
  • TERMINATED: Un hilo que ha terminado su ejecución.

4.1 Detener Hilos de Forma Segura

Se desaconseja el uso de métodos como stop() o destroy() de la clase Thread (están obsoletos) porque pueden dejar los objetos en un estado inconsistente. La forma recomendada es que el hilo se detenga a sí mismo utilizando una bandera booleana.

public class HiloControlable implements Runnable {
    private volatile boolean ejecutar = true; // 'volatile' asegura visibilidad entre hilos

    @Override
    public void run() {
        int contador = 0;
        while (ejecutar) {
            System.out.println(Thread.currentThread().getName() + " ejecutándose... " + contador++);
            try {
                Thread.sleep(50); // Simula el trabajo
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt(); // Restablece el estado de interrupción
                System.out.println("Hilo " + Thread.currentThread().getName() + " interrumpido, terminando.");
                ejecutar = false; // Detener el bucle en caso de interrupción
            }
        }
        System.out.println("Hilo " + Thread.currentThread().getName() + " ha terminado su ejecución.");
    }

    public void solicitarParada() {
        this.ejecutar = false;
    }

    public static void main(String[] args) {
        HiloControlable miHilo = new HiloControlable();
        Thread t1 = new Thread(miHilo, "Contador-Hilo");
        t1.start();

        // Permitir que el hilo se ejecute por un tiempo
        try {
            Thread.sleep(1000); // Esperar 1 segundo
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println("\nSolicitando la parada del hilo...");
        miHilo.solicitarParada(); // Solicitar que el hilo se detenga
        
        // Esperar a que el hilo termine para ver el mensaje final
        try {
            t1.join(); 
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("Programa principal ha terminado.");
    }
}

4.2 Hilos en Espera (sleep)

El método Thread.sleep() pausa la ejecución del hilo actual por un tiempo especificado, pasándolo del estado RUNNABLE a TIMED_WAITING. Después del tiempo de espera, el hilo vuelve al estado RUNNABLE.

import java.text.SimpleDateFormat;
import java.util.Date;

public class TemporizadorHilo {

    // Método para una cuenta regresiva simple
    public static void cuentaRegresiva(int segundos) throws InterruptedException {
        while (segundos > 0) {
            System.out.println("Quedan " + segundos + " segundos...");
            Thread.sleep(1000);
            segundos--;
        }
        System.out.println("¡Tiempo agotado!");
    }

    public static void main(String[] args) {
        // Ejemplo de cuenta regresiva
        try {
            System.out.println("Iniciando cuenta regresiva de 3 segundos...");
            cuentaRegresiva(3);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("La cuenta regresiva fue interrumpida.");
        }

        System.out.println("\nMostrando la hora actual cada segundo:");
        SimpleDateFormat formatoHora = new SimpleDateFormat("HH:mm:ss");
        long inicio = System.currentTimeMillis();

        for (int i = 0; i < 5; i++) { // Mostrar la hora 5 veces
            try {
                Thread.sleep(1000); // Esperar 1 segundo
                System.out.println("Hora actual: " + formatoHora.format(new Date(System.currentTimeMillis())));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.err.println("El temporizador fue interrumpido.");
                break;
            }
        }
    }
}

4.3 Ceder el Control (yield)

El método Thread.yield() sugiere al planificador de hilos que el hilo actual puede ceder voluntariamente la CPU para que otros hilos de igual o mayor prioridad puedan ejecutarse. No garantiza que el hilo ceda el control o que otro hilo se ejecute, simplemente lo pasa de RUNNABLE a RUNNABLE (listo para ejecutar de nuevo).

public class EjemploThreadYield implements Runnable {
    @Override
    public void run() {
        String nombreHilo = Thread.currentThread().getName();
        System.out.println(nombreHilo + " - Iniciando ejecución.");
        
        // Simular un punto donde el hilo puede ceder el control
        if (nombreHilo.equals("Hilo-A")) {
            System.out.println(nombreHilo + " - Cediendo CPU...");
            Thread.yield(); // Sugerir al planificador que ceda la CPU
            System.out.println(nombreHilo + " - Retomando ejecución después de yield.");
        } else {
             System.out.println(nombreHilo + " - Continuando ejecución normal.");
        }

        System.out.println(nombreHilo + " - Finalizando ejecución.");
    }

    public static void main(String[] args) {
        EjemploThreadYield tarea = new EjemploThreadYield();

        new Thread(tarea, "Hilo-A").start();
        new Thread(tarea, "Hilo-B").start();
    }
}

4.4 Unir Hilos (join)

El método join() permite que un hilo espere a que otro hilo termine su ejecución. Cuando un hilo llama a hilo.join(), el hilo actual (el que llama) se bloquea hasta que hilo completa su ejecución, pasando de RUNNABLE a WAITING o TIMED_WAITING.

public class HiloEsperador implements Runnable {
    private String nombreTarea;
    private int limite;

    public HiloEsperador(String nombreTarea, int limite) {
        this.nombreTarea = nombreTarea;
        this.limite = limite;
    }

    @Override
    public void run() {
        for (int i = 0; i < limite; i++) {
            System.out.println(nombreTarea + " - Progreso: " + i);
            try {
                Thread.sleep(10); // Simular trabajo
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.err.println(nombreTarea + " interrumpido.");
                break;
            }
        }
        System.out.println(nombreTarea + " - Tarea completada.");
    }

    public static void main(String[] args) throws InterruptedException {
        HiloEsperador tareaImportante = new HiloEsperador("Tarea Importante", 200);
        Thread hiloSecundario = new Thread(tareaImportante, "Hilo Secundario");
        hiloSecundario.start();

        for (int i = 0; i < 500; i++) {
            System.out.println("Hilo Principal - Iteración: " + i);
            if (i == 100) {
                System.out.println("Hilo Principal espera a que Hilo Secundario termine.");
                hiloSecundario.join(); // El hilo principal se bloquea aquí hasta que hiloSecundario termine
                System.out.println("Hilo Principal ha reanudado su ejecución.");
            }
            try {
                Thread.sleep(5); // Simular trabajo del hilo principal
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.err.println("Hilo Principal interrumpido.");
            }
        }
        System.out.println("Hilo Principal - Ejecución finalizada.");
    }
}

4.5 Observación del Estado de los Hilos

Podemos monitorear los cambios de estado de un hilo utilizando el método getState().

public class MonitorDeEstadoHilo {
    public static void main(String[] args) throws InterruptedException {
        Thread miHilo = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println(Thread.currentThread().getName() + " - Trabajando " + (i + 1) + "...");
                try {
                    Thread.sleep(500); // Simular trabajo
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.err.println("Hilo de trabajo interrumpido.");
                    break;
                }
            }
            System.out.println(Thread.currentThread().getName() + " - Finalizado.");
        }, "Hilo-Observado");

        // 1. Estado NEW
        System.out.println("Estado inicial: " + miHilo.getState()); // NEW

        // 2. Estado RUNNABLE
        miHilo.start();
        System.out.println("Estado después de start(): " + miHilo.getState()); // RUNNABLE (o TIMED_WAITING si ya está en sleep)

        // Monitorear el estado mientras el hilo se ejecuta
        while (miHilo.getState() != Thread.State.TERMINATED) {
            Thread.sleep(100); // Pequeña pausa para no saturar la CPU
            Thread.State estadoActual = miHilo.getState();
            System.out.println("Estado en bucle: " + estadoActual);
            // Podemos ver RUNNABLE, TIMED_WAITING, y finalmente TERMINATED
        }

        // 3. Estado TERMINATED
        System.out.println("Estado final: " + miHilo.getState()); // TERMINATED
    }
}

4.6 Prioridad de Hilos

Cada hilo en Java tiene una prioridad, que es un número entero entre 1 (mínima) y 10 (máxima). El planificador de hilos del sistema operativo usa estas prioridades para decidir qué hilo debe ejecutar a continuación. Las constantes Thread.MIN_PRIORITY (1), Thread.NORM_PRIORITY (5) y Thread.MAX_PRIORITY (10) definen los rangos.

class TareaConPrioridad implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " (Prioridad: " + Thread.currentThread().getPriority() + ") ejecutándose.");
        for (int i = 0; i < 5; i++) {
            // Un pequeño trabajo para que el planificador tenga algo que hacer
            // No hacer Thread.sleep() aquí para que la prioridad tenga más efecto en la salida
            System.out.print(Thread.currentThread().getName().charAt(0));
        }
        System.out.println();
    }
}

public class EjemploPrioridadHilos {
    public static void main(String[] args) {
        System.out.println("Prioridad del hilo principal: " + Thread.currentThread().getPriority());

        TareaConPrioridad tarea = new TareaConPrioridad();

        Thread hiloBaja = new Thread(tarea, "Hilo-Baja");
        Thread hiloNormal = new Thread(tarea, "Hilo-Normal");
        Thread hiloAlta = new Thread(tarea, "Hilo-Alta");
        Thread hiloMaxima = new Thread(tarea, "Hilo-Maxima");

        hiloBaja.setPriority(Thread.MIN_PRIORITY); // Prioridad 1
        hiloNormal.setPriority(Thread.NORM_PRIORITY); // Prioridad 5 (por defecto)
        hiloAlta.setPriority(7);
        hiloMaxima.setPriority(Thread.MAX_PRIORITY); // Prioridad 10

        // Iniciar los hilos. El orden de inicio no garantiza el orden de ejecución.
        hiloBaja.start();
        hiloNormal.start();
        hiloAlta.start();
        hiloMaxima.start();

        // Las prioridades son una sugerencia al SO, no una garantía estricta.
        // La salida puede variar según el sistema operativo y la implementación de la JVM.
    }
}

4.8 Hilos Demonio (Daemon Threads)

Los hilos en Java se clasifican en dos tipos: hilos de usuario (user threads) y hilos demonio (daemon threads). La JVM espera que todos los hilos de usuario terminen antes de finalizar el programa. Sin embargo, no espera a que los hilos demonio terminen; si los únicos hilos restantes son demonio, la JVM se apagará.

Los hilos demonio se utilizan para tareas de soporte en segundo plano, como la recolección de basura, la monitorización de recursos o el registro de actividad. Para designar un hilo como demonio, se usa thread.setDaemon(true) antes de iniciarlo.

  1. Sincronización de Hilos

La sincronización es crucial para gestionar el acceso a recursos compartidos entre múltiples hilos y prevenir inconsistencias de datos.

5.1 Problemas de Concurrencia: Listas No Seguras

Muchas estructuras de datos de la biblioteca estándar de Java, como ArrayList y HashMap, no son intrínsecamente seguras para hilos. Cuando múltiples hilos modifican una de estas colecciones sin sincronización, pueden ocurrir problemas como la pérdida de datos o excepciones IndexOutOfBoundsException.

import java.util.ArrayList;
import java.util.List;

public class EjemploListaNoSincronizada {
    public static void main(String[] args) {
        List<String> listaCompartida = new ArrayList<>();

        // Crear y lanzar múltiples hilos que añaden elementos a la lista
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                // Esta operación no es atómica ni sincronizada
                listaCompartida.add(Thread.currentThread().getName());
            }, "Hilo-" + i).start();
        }

        // Esperar un poco para que la mayoría de los hilos terminen
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        // La cantidad de elementos podría no ser la esperada (10000)
        // Y es posible que el programa haya lanzado excepciones antes de llegar aquí.
        System.out.println("Tamaño final de la lista: " + listaCompartida.size());
    }
}

5.2 Métodos Sincronizados (synchronized)

La palabra clave synchronized en Java proporciona un mecanismo intrínseco de bloqueo. Puede aplicarse a métodos o bloques de código para asegurar que solo un hilo a la vez pueda ejecutar esa sección crítica.

  • Cuando se aplica a un método de instancia, el bloqueo es sobre el objeto que invoca el método.
  • Cuando se aplica a un método estático, el bloqueo es sobre el objeto Class de la clase.
  • Para un bloque de código, se especifica un objeto monitor sobre el cual se adquiere el bloqueo.
import java.util.ArrayList;
import java.util.List;

public class EjemploListaSincronizada {
    public static void main(String[] args) {
        List<String> listaCompartida = new ArrayList<>();
        Object bloqueo = new Object(); // Objeto monitor para el bloque synchronized

        for (int i = 0; i < 10000; i++) {
            new Thread(() -> {
                // Sincronizar el acceso a la lista usando un bloque synchronized
                synchronized (bloqueo) { // El bloqueo es sobre el objeto 'bloqueo'
                    listaCompartida.add(Thread.currentThread().getName());
                }
            }, "Hilo-" + i).start();
        }

        try {
            Thread.sleep(3000); // Dar tiempo a los hilos para terminar
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        // El tamaño de la lista debería ser 10000, ya que el acceso está sincronizado.
        System.out.println("Tamaño final de la lista: " + listaCompartida.size());
    }
}

5.4 Clases JUC (Java Util Concurrent)

El paquete java.util.concurrent (JUC) proporciona una rica colección de herramientas para la concurrencia, incluyendo estructuras de datos seguras para hilos, ejecutores de tareas, bloqueos avanzados y utilidades de sincronización. Un ejemplo es CopyOnWriteArrayList, una lista que es segura para hilos al realizar copias del subyacente array cada vez que se produce una modificación. Es ideal para situaciones con muchas lecturas y pocas escrituras.

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class EjemploJUCLista {
    public static void main(String[] args) {
        // CopyOnWriteArrayList es intrínsecamente thread-safe
        List<String> listaConcurrente = new CopyOnWriteArrayList<>();

        for (int i = 0; i < 10000; i++) {
            new Thread(() -> {
                listaConcurrente.add(Thread.currentThread().getName());
            }, "Hilo-JUC-" + i).start();
        }

        try {
            Thread.sleep(3000); // Esperar que los hilos terminen
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println("Tamaño final de la lista (CopyOnWriteArrayList): " + listaConcurrente.size());
    }
}

5.5 Interbloqueo (Deadlock)

Un interbloqueo ocurre cuando dos o más hilos están esperando indefinidamente a que los otros liberen un recurso que necesitan, resultando en un estancamiento. Esto suele suceder cuando se requieren múltiples bloqueos en un orden inconsistente.

Las condiciones de Coffman para un interbloqueo son:

  1. Exclusión Mutua: Al menos un recurso debe ser no compartible.
  2. Retención y Espera: Un hilo retiene al menos un recurso mientras espera adquirir recursos adicionales que están siendo retenidos por otros hilos.
  3. No Apropiación: Los recursos no pueden ser liberados forzosamente; solo el hilo que los retiene puede liberarlos voluntariamente.
  4. Espera Circular: Existe un conjunto de hilos en espera, donde cada hilo en el conjunto espera un recurso retenido por el siguiente hilo del conjunto.

Para evitar intebrloqueos, se debe intentar romper al menos una de estas condiciones. Una estrategia común es establecer un orden estricto para la adquisición de bloqueos.

class RecursoA {
    private String nombre = "Recurso A";
    public String getNombre() { return nombre; }
    public synchronized void usar() {
        System.out.println(Thread.currentThread().getName() + " usando " + nombre);
    }
}

class RecursoB {
    private String nombre = "Recurso B";
    public String getNombre() { return nombre; }
    public synchronized void usar() {
        System.out.println(Thread.currentThread().getName() + " usando " + nombre);
    }
}

class TareaConcurrente extends Thread {
    private RecursoA recursoA;
    private RecursoB recursoB;
    private int orden; // 0 para A luego B, 1 para B luego A

    public TareaConcurrente(String nombre, RecursoA a, RecursoB b, int orden) {
        super(nombre);
        this.recursoA = a;
        this.recursoB = b;
        this.orden = orden;
    }

    @Override
    public void run() {
        if (orden == 0) {
            synchronized (recursoA) {
                System.out.println(getName() + " ha adquirido " + recursoA.getNombre());
                try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                System.out.println(getName() + " intentando adquirir " + recursoB.getNombre());
                synchronized (recursoB) {
                    System.out.println(getName() + " ha adquirido ambos recursos.");
                }
            }
        } else { // orden == 1
            synchronized (recursoB) {
                System.out.println(getName() + " ha adquirido " + recursoB.getNombre());
                try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                System.out.println(getName() + " intentando adquirir " + recursoA.getNombre());
                synchronized (recursoA) {
                    System.out.println(getName() + " ha adquirido ambos recursos.");
                }
            }
        }
    }
}

public class EjemploDeadlock {
    public static void main(String[] args) {
        RecursoA rA = new RecursoA();
        RecursoB rB = new RecursoB();

        // Este escenario potencialmente causa deadlock si el sleep es suficiente
        // y los hilos adquieren bloqueos en orden inverso.
        TareaConcurrente hilo1 = new TareaConcurrente("Hilo Uno", rA, rB, 0); // Adquiere A, luego B
        TareaConcurrente hilo2 = new TareaConcurrente("Hilo Dos", rA, rB, 1); // Adquiere B, luego A

        hilo1.start();
        hilo2.start();
        System.out.println("Observa si los hilos se bloquean...");
    }
}

5.6 Interfaz Lock (Bloqueo Explícito)

5.6.1 Definición y ReentrantLock

A partir de Java 5, la biblioteca java.util.concurrent.locks ofrece mecanismos de bloqueo explícitos a través de la interfaz Lock. La implementación más común es ReentrantLock, que ofrece una funcionalidad similar a synchronized pero con mayor flexibilidad, como la capacidad de intentar adquirir un bloqueo, consultar si un bloqueo está disponible, y adquirir bloqueos en un orden condicional.

import java.util.concurrent.locks.ReentrantLock;

public class ExpendedorDeTickets implements Runnable {
    private int boletosDisponibles = 10;
    private final ReentrantLock cerradura = new ReentrantLock(); // Bloqueo explícito

    @Override
    public void run() {
        while (true) {
            cerradura.lock(); // Adquiere el bloqueo
            try {
                if (boletosDisponibles > 0) {
                    try {
                        Thread.sleep(50); // Simula tiempo de procesamiento
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        System.err.println("Hilo " + Thread.currentThread().getName() + " interrumpido.");
                    }
                    System.out.println(Thread.currentThread().getName() + " vendió el boleto #" + boletosDisponibles);
                    boletosDisponibles--;
                } else {
                    System.out.println(Thread.currentThread().getName() + ": No quedan boletos.");
                    break;
                }
            } finally {
                cerradura.unlock(); // Siempre libera el bloqueo en un bloque finally
            }
        }
    }

    public static void main(String[] args) {
        ExpendedorDeTickets sistemaTickets = new ExpendedorDeTickets();

        new Thread(sistemaTickets, "Agente-1").start();
        new Thread(sistemaTickets, "Agente-2").start();
        new Thread(sistemaTickets, "Agente-3").start();
    }
}

5.6.2 Comparación synchronized vs Lock

  • Gestión: synchronized es un bloqueo implícito (gestionado por la JVM), mientras que Lock es un bloqueo explícito (el programador debe llamar a lock() y unlock()).
  • Flexibilidad: Lock ofrece más flexibilidad, como la posibilidad de implementar semáforos, bloqueos reetnrantes, bloqueos de lectura/escritura (ReadWriteLock), o intentar adquirir el bloqueo sin esperar indefinidamente (tryLock()). synchronized es más limitado.
  • Manejo de Errores: Con Lock, es esencial liberar el bloqueo en un bloque finally para evitar interbloqueos en caso de excepciones. synchronized garantiza la liberación automática.
  • Rendimiento: En versiones modernas de la JVM, el rendimiento de synchronized y ReentrantLock es a menudo comparable. Sin embargo, para escenarios de alta concurrencia con requisitos complejos, Lock puede ofrecer ventajas de rendimiento y escalabilidad.
  • Prioridad de Uso: Se recomienda usar synchronized para casos sencillos de sincronización. Para escenarios más complejos o cuando se necesitan características avanzadas, las utilidades del paquete java.util.concurrent.locks son preferibles.
  1. Colaboración entre Hilos

6.1 Comunicación entre Hilos: Patrón Productor-Consumidor

El patrón Productor-Consumidor es un clásico problema de sincronización donde un conjunto de "productores" genera datos y los coloca en un búfer, mientras que un conjunto de "consumidores" extrae los datos de ese búfer para procesarlos. Ambos operan concurrentemente y deben sincronizarse para evitar condiciones de carrera, como que el productor intente añadir datos a un búfer lleno o que el consumidor intente extraer datos de un búfer vacío.

6.2 Modelo Monitor (Patrón Productor-Consumidor Clásico)

Este modelo utiliza los métodos wait() y notifyAll() (parte del mecanismo synchronized) para la comunicación entre hilos. Los hilos que esperan una condición (ej. búfer no lleno/vacío) llaman a wait(), liberando el bloqueo. Los hilos que cambian la condición llaman a notifyAll() (o notify()) para despertar a los hilos en espera.

class Articulo {
    int id;
    public Articulo(int id) { this.id = id; }
    @Override public String toString() { return "Articulo #" + id; }
}

class Almacen {
    Articulo[] buffer = new Articulo[5];
    int count = 0; // Número de artículos en el buffer

    // Método para que el productor coloque un artículo
    public synchronized void producir(Articulo item) throws InterruptedException {
        while (count == buffer.length) { // Búfer lleno, productor espera
            System.out.println("Almacén lleno. Productor esperando...");
            this.wait();
        }
        buffer[count] = item;
        count++;
        System.out.println("Producido: " + item + ". Total: " + count);
        this.notifyAll(); // Notifica a los consumidores que hay nuevos artículos
    }

    // Método para que el consumidor extraiga un artículo
    public synchronized Articulo consumir() throws InterruptedException {
        while (count == 0) { // Búfer vacío, consumidor espera
            System.out.println("Almacén vacío. Consumidor esperando...");
            this.wait();
        }
        count--;
        Articulo item = buffer[count];
        System.out.println("Consumido: " + item + ". Restantes: " + count);
        this.notifyAll(); // Notifica a los productores que hay espacio
        return item;
    }
}

class HiloProductor extends Thread {
    private Almacen almacen;
    public HiloProductor(Almacen almacen) { this.almacen = almacen; }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            try {
                almacen.producir(new Articulo(i));
                Thread.sleep(50); // Simula el tiempo de producción
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }
}

class HiloConsumidor extends Thread {
    private Almacen almacen;
    public HiloConsumidor(Almacen almacen) { this.almacen = almacen; }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            try {
                almacen.consumir();
                Thread.sleep(150); // Simula el tiempo de consumo
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }
}

public class ProductorConsumidorMonitor {
    public static void main(String[] args) {
        Almacen almacenCompartido = new Almacen();
        new HiloProductor(almacenCompartido).start();
        new HiloConsumidor(almacenCompartido).start();
    }
}

6.4 Semáforos (Comunicación con Banderas)

Aunque el término "semáforos" se refiere más comúnmente a la clase Semaphore de JUC, el ejemplo original usa un enfoque de "banderas" booleanas para coordinar la ejecución. Aquí, un hilo productor y un hilo consumidor alternan el acceso a un recurso compartido basándose en el estado de una bandera.

class CanalComunicacion {
    String mensaje;
    boolean listoParaConsumir = false; // true: listo para consumidor; false: listo para productor

    // El productor escribe un mensaje
    public synchronized void escribirMensaje(String msg) throws InterruptedException {
        while (listoParaConsumir) { // Si hay un mensaje pendiente, productor espera
            System.out.println("Productor esperando. Mensaje pendiente.");
            this.wait();
        }
        this.mensaje = msg;
        System.out.println("Productor escribió: " + msg);
        listoParaConsumir = true; // Indica que hay un mensaje para el consumidor
        this.notifyAll(); // Notifica al consumidor
    }

    // El consumidor lee un mensaje
    public synchronized String leerMensaje() throws InterruptedException {
        while (!listoParaConsumir) { // Si no hay mensaje, consumidor espera
            System.out.println("Consumidor esperando. No hay mensaje.");
            this.wait();
        }
        String msgLeido = this.mensaje;
        System.out.println("Consumidor leyó: " + msgLeido);
        listoParaConsumir = false; // Indica que el canal está listo para un nuevo mensaje
        this.notifyAll(); // Notifica al productor
        return msgLeido;
    }
}

class HiloEmisor extends Thread { // Productor
    private CanalComunicacion canal;
    public HiloEmisor(CanalComunicacion canal) { this.canal = canal; }

    @Override
    public void run() {
        String[] mensajes = {"Hola", "Qué tal", "Todo bien", "Adiós"};
        for (String msg : mensajes) {
            try {
                canal.escribirMensaje(msg);
                Thread.sleep(200);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }
}

class HiloReceptor extends Thread { // Consumidor
    private CanalComunicacion canal;
    public HiloReceptor(CanalComunicacion canal) { this.canal = canal; }

    @Override
    public void run() {
        for (int i = 0; i < 4; i++) { // Esperar 4 mensajes
            try {
                canal.leerMensaje();
                Thread.sleep(500);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }
}

public class ComunicacionConBanderas {
    public static void main(String[] args) {
        CanalComunicacion canal = new CanalComunicacion();
        new HiloEmisor(canal).start();
        new HiloReceptor(canal).start();
    }
}

  1. Pool de Hilos (ThreadPoolExecutor)

Un pool de hilos es una colección de hilos pre-creados que se mantienen listos para ejecutar tareas. En lugar de crear un nuevo hilo para cada tarea, las tareas se envían al pool, que asigna un hilo disponible para ejecutarlas. Esto reduce la sobrecarga de la creación y destrucción de hilos, mejora la capacidad de respuesta y permite una gestión más eficiente de los recursos.

El paquete java.util.concurrent proporciona la interfaz ExecutorService y la clase de utilidad Executors para crear y gestionar pools de hilos.

  • execute(Runnable task): Ejecuta una tarea Runnable, sin devolver un resultado.
  • submit(Callable<T> task): Ejecuta una tarea Callable, devolviendo un objeto Future para obtener el resultado.
  • shutdown(): Inicia el apagado ordenado del pool, permitiendo que las tareas ya enviadas terminen antes de que el pool se cierre completamente.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

// Una tarea simple que implementa Runnable
class TareaSimple implements Runnable {
    private String nombre;
    public TareaSimple(String nombre) {
        this.nombre = nombre;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " ejecutando tarea: " + nombre);
        try {
            Thread.sleep(100 + (long)(Math.random() * 400)); // Simula trabajo
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Tarea " + nombre + " interrumpida.");
        }
        System.out.println(Thread.currentThread().getName() + " finalizó tarea: " + nombre);
    }
}

public class EjemploThreadPool {
    public static void main(String[] args) {
        System.out.println("Iniciando pool de hilos...");

        // 1. Crear un servicio de ejecución (pool de hilos de tamaño fijo 3)
        // Esto creará 3 hilos que reutilizará para todas las tareas.
        ExecutorService poolDeHilos = Executors.newFixedThreadPool(3);

        // 2. Enviar múltiples tareas al pool
        poolDeHilos.execute(new TareaSimple("Procesar Archivo A"));
        poolDeHilos.execute(new TareaSimple("Generar Reporte X"));
        poolDeHilos.execute(new TareaSimple("Actualizar Base de Datos"));
        poolDeHilos.execute(new TareaSimple("Enviar Email"));
        poolDeHilos.execute(new TareaSimple("Procesar Archivo B"));
        poolDeHilos.execute(new TareaSimple("Cargar Configuración"));

        // 3. Apagar el pool de hilos.
        // No acepta nuevas tareas, pero completa las pendientes.
        poolDeHilos.shutdown();

        System.out.println("Todas las tareas han sido enviadas al pool.");
        // Podemos esperar a que el pool termine sus tareas
        while (!poolDeHilos.isTerminated()) {
            // Espera activa o Thread.sleep()
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        System.out.println("Pool de hilos finalizado.");
    }
}

  1. Resumen de Creación de Hilos

Para concluir, recordemos las tres formas fundamentales de crear hilos en Java, ilustradas en un solo ejemplo.

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class MetodosCreacionHilos {
    public static void main(String[] args) {

        // --- Opción 1: Extender la clase Thread ---
        System.out.println("--- Opción 1: Hilo extendiendo Thread ---");
        MiHiloExtendido hilo1 = new MiHiloExtendido("HiloA");
        hilo1.start();

        // --- Opción 2: Implementar la interfaz Runnable ---
        System.out.println("\n--- Opción 2: Hilo implementando Runnable ---");
        MiTareaRunnable tarea2 = new MiTareaRunnable("TareaB");
        new Thread(tarea2).start();

        // También se puede usar una expresión Lambda para Runnable
        new Thread(() -> System.out.println(Thread.currentThread().getName() + " - Tarea Lambda Runnable")).start();

        // --- Opción 3: Implementar la interfaz Callable con FutureTask ---
        System.out.println("\n--- Opción 3: Hilo implementando Callable ---");
        MiTareaCallable tarea3 = new MiTareaCallable("TareaC");
        FutureTask<String> futureTask = new FutureTask<>(tarea3);
        new Thread(futureTask).start();

        try {
            // Obtener el resultado de la tarea Callable (puede bloquear)
            String resultado = futureTask.get();
            System.out.println("Resultado de Callable: " + resultado);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("La obtención del resultado fue interrumpida.");
        } catch (ExecutionException e) {
            System.err.println("Error durante la ejecución de Callable: " + e.getCause());
        }
        System.out.println("\nFin del hilo principal.");
    }
}

// 1. Clase que extiende Thread
class MiHiloExtendido extends Thread {
    private String nombreHilo;

    public MiHiloExtendido(String nombreHilo) {
        this.nombreHilo = nombreHilo;
    }

    @Override
    public void run() {
        System.out.println(nombreHilo + " - Ejecución iniciada desde Thread extendido.");
        // Simular trabajo
        try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        System.out.println(nombreHilo + " - Ejecución finalizada desde Thread extendido.");
    }
}

// 2. Clase que implementa Runnable
class MiTareaRunnable implements Runnable {
    private String nombreTarea;

    public MiTareaRunnable(String nombreTarea) {
        this.nombreTarea = nombreTarea;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " - Ejecución iniciada para tarea Runnable: " + nombreTarea);
        // Simular trabajo
        try { Thread.sleep(150); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        System.out.println(Thread.currentThread().getName() + " - Ejecución finalizada para tarea Runnable: " + nombreTarea);
    }
}

// 3. Clase que implementa Callable (con un tipo de retorno)
class MiTareaCallable implements Callable<String> {
    private String nombreTarea;

    public MiTareaCallable(String nombreTarea) {
        this.nombreTarea = nombreTarea;
    }

    @Override
    public String call() throws Exception {
        System.out.println(Thread.currentThread().getName() + " - Ejecución iniciada para tarea Callable: " + nombreTarea);
        // Simular trabajo que devuelve un resultado
        Thread.sleep(200);
        System.out.println(Thread.currentThread().getName() + " - Ejecución finalizada para tarea Callable: " + nombreTarea);
        return "Procesamiento de '" + nombreTarea + "' completado exitosamente.";
    }
}

Etiquetas: java Multithreading Concurrency Threads Runnable

Publicado el 7-19 08:28