- 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.
- 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
- Extendiendo la clase
java.lang.Thread. - Implementando la interfaz
java.lang.Runnable. - Implementando la interfaz
java.util.concurrent.Callable, generalmente utilizada con unExecutorServicepara 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();
}
}
- 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();
}
}
- 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.
- 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
Classde 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:
- Exclusión Mutua: Al menos un recurso debe ser no compartible.
- Retención y Espera: Un hilo retiene al menos un recurso mientras espera adquirir recursos adicionales que están siendo retenidos por otros hilos.
- No Apropiación: Los recursos no pueden ser liberados forzosamente; solo el hilo que los retiene puede liberarlos voluntariamente.
- 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:
synchronizedes un bloqueo implícito (gestionado por la JVM), mientras queLockes un bloqueo explícito (el programador debe llamar alock()yunlock()). - Flexibilidad:
Lockofrece 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()).synchronizedes más limitado. - Manejo de Errores: Con
Lock, es esencial liberar el bloqueo en un bloquefinallypara evitar interbloqueos en caso de excepciones.synchronizedgarantiza la liberación automática. - Rendimiento: En versiones modernas de la JVM, el rendimiento de
synchronizedyReentrantLockes a menudo comparable. Sin embargo, para escenarios de alta concurrencia con requisitos complejos,Lockpuede ofrecer ventajas de rendimiento y escalabilidad. - Prioridad de Uso: Se recomienda usar
synchronizedpara casos sencillos de sincronización. Para escenarios más complejos o cuando se necesitan características avanzadas, las utilidades del paquetejava.util.concurrent.locksson preferibles.
- 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();
}
}
- 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 tareaRunnable, sin devolver un resultado.submit(Callable<T> task): Ejecuta una tareaCallable, devolviendo un objetoFuturepara 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.");
}
}
- 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.";
}
}