Este artículo profundiza en el concepto de hilos (threads) en el contexto de Java EE, contrsatándolos con los procesos y detallando cómo implementarlos en Java. Los hilos son la unidad mínima de ejecución que un sistema operativo puede gestionar de forma independiente.
¿Por Qué Utilizar Hilos en Lugar de Procesos para la Programación Concurrente?
La elección de hilos sobre procesos para la programación concurrente se debe a varias ventajas clave:
- Menor Costo de Creación: Crear un proceso es una operación costosa en términos de tiempo, memoria y CPU. Los hilos son significativamente más ligeros.
- Menor Costo de Planificación y Destrucción: Similar a la creación, la gestión del ciclo de vida de un proceso es más pesada que la de un hilo.
- Compartición de Recursos: Los hilos dentro del mismo proceso comparten el mismo espacio de direcciones y recursos, lo que facilita la comunicación y reduce la duplicación.
- Aprovechamiento de CPUs Multinúcleo: La arquitectura moderna de CPUs con múltiples núcleos se beneficia enormemente de la ejecución paralela que ofrecen los hilos.
Definición de Hilo
Un hilo es la unidad más pequeña de procesamiento que puede ser planificada y ejecutada por un sistema operativo. Es un flujo de ejecución dentro de un proceso. A diferencia de los procesos, los hilos se crean y gestionan dentro de un proceso y comparten su espacio de direcciones y recursos del sistema. Aunque la creación del primer hilo en un proceso puede tener un costo mayor, los hilos subsiguientes son más eficientes. La programación concurrente busca asignar cada hilo a un núcleo de CPU distinto para una ejecución independiente, aunque en sistemas de un solo núcleo, la rápida conmutación de contexto entre hilos simula la ejecución simultánea.
Un hilo es la unidad fundamental de ejecución gestionada por el sistema operativo.
Diferencias Clave entre Procesos y Hilos
Un proceso puede contener uno o varios hilos. Las distinciones principales son:
- Recursos y Aislamiento:
- Procesos: Tienen su propio espacio de memoria independiente, descriptores de archivo, conexiones de red, etc. La comunicación entre procesos requiere mecanismos de Interproceso (IPC) y es más costosa.
- Hilos: Comparten el espacio de memoria y los recursos del mismo proceso. El acceso y la modificación de datos son directos y más rápidos.
- Costos de Creación y Destrucción:
- Procesos: La creación implica asignar memoria, cargar ejecutables y configurar estructuras de datos del sistema, lo que resulta en un costo elevado.
- Hilos: Requieren principalmente la creación de un contexto de ejecución y una pequeña cantidad de memoria, siendo mucho más ligeros.
- Concurrencia y Capacidad de Respuesta:
- Procesos: La comunicación es más compleja y la conmutación entre procesos es más costosa, afectando la capacidad de respuesta.
- Hilos: La compartición de memoria simplifica la comunicación y la conmutación entre hilos es más rápida, lo que resulta en una mayor concurrencia y mejor capacidad de respuesta.
- Gestión y Planificación:
- Procesos: Son gestionados y planificados de forma independiente por el sistema operativo.
- Hilos: Son creados y gestionados dentro de un proceso, y su planificación es realizada por el planificador de hilos del sistema operativo, siendo más eficiente que la planificación de procesos.
- Seguridad y Estabilidad:
- Procesos: El fallo de un proceso generalmente no afecta a otros, ofreciendo mayor aislamiento y estabilidad.
- Hilos: Un error en un hilo puede provocar el colapso de todo el proceso, ya que comparten recursos críticos.
La información de control de procesos (PCB) se aplica a los hilos. Cada hilo tiene su propio PCB con contexto, prioridad e información de contabilidad. Sin embargo, los hilos dentro del mismo proceso comparten el mismo ID de proceso (PID), punteros de memoria y tablas de descriptores de archivo.
Implementación de Hilos en Java
Java ofrece varias formas de crear y gestionar hilos:
1. Creación de Hilos
Existen dos enfoques principales para crear hilos en Java:
a) Extender la Clase Thread
Se crea una nueva clase que hereda de Thread y se sobrescribe el método run(), el cual define la tarea que ejecutará el hilo.
class MiHiloExtendido extends Thread {
@Override
public void run() {
System.out.println("Ejecutando desde MiHiloExtendido.");
}
}
Instanciación:
public class DemoCreacion1 {
public static void main(String[] args) {
Thread hilo = new MiHiloExtendido();
// Para iniciar el hilo, se usa hilo.start()
}
}
b) Implementar la Interfaz Runnable
Se crea una clase que implementa la interfaz Runnable, definiendo la lógica de ejecución en el método run(). Luego, se crea una instancia de Thread pasándole el objeto Runnable en su constructor.
class MiRunnable implements Runnable {
@Override
public void run() {
System.out.println("Ejecutando desde MiRunnable.");
}
}
Instanciación:
public class DemoCreacion2 {
public static void main(String[] args) {
Thread hilo = new Thread(new MiRunnable());
// Para iniciar el hilo, se usa hilo.start()
}
}
Formas más concisas de implementar Runnable:
-
Clases Anónimas: ```java
public class DemoCreacion3 { public static void main(String[] args) { Thread hilo = new Thread(new Runnable() { @Override public void run() { System.out.println("Ejecutando desde Runnable anónimo."); } }); // Para iniciar el hilo, se usa hilo.start() } }
-
Expresiones Lambda (Java 8+): ```java
public class DemoCreacion4 { public static void main(String[] args) { Thread hilo = new Thread(() -> { System.out.println("Ejecutando desde Runnable con Lambda."); }); // Para iniciar el hilo, se usa hilo.start() } }
2. Constructores Comunes de la Clase Thread
| Método | Descripción |
|---|---|
Thread() |
Crea un nuevo hilo. |
Thread(Runnable target) |
Crea un hilo con un objeto Runnable especificado. |
Thread(String name) |
Crea un hilo con un nombre específico. |
Thread(Runnable target, String name) |
Crea un hilo con un objeto Runnable y un nombre. |
Thread(ThreadGroup group, Runnable target) |
Crea un hilo dentro de un grupo de hilos específico. |
3. Atributos Comunes de Thread
| Atributo | Método de Obtención |
|---|---|
| ID del Hilo | getId() |
| Nombre del Hilo | getName() |
| Estado del Hilo | getState() |
| Prioridad | getPriority() |
| ¿Es un hilo demonio? | isDaemon() |
| ¿Está vivo? | isAlive() |
| ¿Ha sido interrumpido? | isInterrupted() |
- ID: Identificador único para cada hilo.
- Nombre: Útil para depuración y herramientas de monitoreo.
- Estado: Indica la situación actual del hilo (ej.
RUNNABLE,BLOCKED,WAITING). - Prioridad: Un valor numérico (1-10) que sugiere la preferencia del hilo para ser planificado. Hilos con mayor prioridad tienen mayor probabilidad de ser ejecutados.
- Hilo Demonios (Daemon Threads): Hilos que se ejecutan en segundo plano. La JVM finaliza su ejecución cuando todos los hilos no demonio han terminado.
- ¿Está vivo?: Devuelve
truesi el métodorun()del hilo ha comenzado y aún no ha terminado. - ¿Ha sido interrrumpido?: Indica si se ha solicitado la interrupción del hilo.
4. Inicio de Hilos
Para que un hilo comience su ejecución, se debe llamar al método start() sobre su objeto. Llamar directamente a run() ejecutará el método en el hilo actual, no iniciará un nuevo hilo.
// Suponiendo que 'hilo' es una instancia de Thread
hilo.start(); // Inicia la ejecución concurrente del hilo
La diferencia entre start() y run() se evidencia en un ejemplo que muestra ejecución concurrente:
class MiRunnable {
public void run() {
while(true) {
System.out.println(Thread.currentThread().getName() + ": ¡Hola!");
try {
Thread.sleep(1000); // Pausa de 1 segundo
} catch (InterruptedException e) {
System.err.println(Thread.currentThread().getName() + " interrumpido.");
Thread.currentThread().interrupt(); // Restablecer el estado de interrupción
break; // Salir del bucle si se interrumpe
}
}
}
}
public class DemoInicio {
public static void main(String[] args) {
Thread hiloSecundario = new Thread(new MiRunnable(), "Hilo-Secundario");
hiloSecundario.start(); // Inicia el hilo secundario
// Bucle principal del hilo main
while(true) {
System.out.println(Thread.currentThread().getName() + ": ¡Hola!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.err.println(Thread.currentThread().getName() + " interrumpido.");
Thread.currentThread().interrupt();
break;
}
}
}
}
En este ejemplo, al usar start(), ambos hilos (main y Hilo-Secundario) se ejecutan concurrentemente, mostrando una salida intercalada. Si se llamara a run() directamente, solo se ejecutaría el método run() del hilo secundario dentro del hilo main, y el bucle principal de main no se ejecutaría hasta que run() terminara.
El método Thread.sleep(long millis) pausa la ejecución del hilo actual durante el tiempo especificado. Es crucial manejar la InterruptedException que puede lanzar.
La ejecución concurrente de hilos se conoce como ejecución preemptiva, donde los hilos compiten por los recursos de la CPU, lo que puede llevar a un orden de ejecución impredecible y, posteriormente, a problemas de seguridad en hilos.
5. Terminación de Hilos
Un hilo termina naturalmente cuando su método run() finaliza. Sin embargo, se puede solicitar su terminación de forma controlada:
a) Mediante una Bandera Personalizada
Se utiliza una variable boolaena (bandera) para indicar al hilo cuándo debe detenerse.
public class DemoTerminacion1 {
private static volatile boolean detenerHilo = false; // volatile asegura visibilidad entre hilos
public static void main(String[] args) throws InterruptedException {
Thread hilo = new Thread(() -> {
while (!detenerHilo) {
System.out.println("Hilo trabajando...");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Manejar interrupción
break;
}
}
System.out.println("Hilo detenido.");
});
hilo.start();
System.out.println("Main: Esperando 2 segundos antes de detener el hilo...");
Thread.sleep(2000);
detenerHilo = true; // Señalar al hilo que se detenga
hilo.join(); // Esperar a que el hilo termine
System.out.println("Main: Hilo detenido exitosamente.");
}
}
b) Utilizando el Método interrupt()
El método interrupt() de la clase Thread solicita la interrupción de un hilo. El hilo debe verificar periódicamente su estado de interrupción (isInterrupted()) o manejar la InterruptedException.
public class DemoTerminacion2 {
public static void main(String[] args) throws InterruptedException {
Thread hilo = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("Hilo trabajando (interruptible)...");
try {
// Si el hilo está en sleep/wait, interrupt() lanzará InterruptedException
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Hilo interrumpido durante sleep/wait. Limpiando y saliendo.");
// Cuando sleep lanza InterruptedException, el flag de interrupción se resetea.
// Es buena práctica volver a lanzarlo si queremos que el bucle se detenga.
Thread.currentThread().interrupt();
break; // Salir del bucle
}
}
System.out.println("Hilo terminado limpiamente.");
});
hilo.start();
System.out.println("Main: Esperando 2 segundos antes de interrumpir el hilo...");
Thread.sleep(2000);
System.out.println("Main: Solicitando interrupción del hilo...");
hilo.interrupt(); // Solicitar interrupción
hilo.join(); // Esperar a que el hilo termine
System.out.println("Main: Hilo finalizado.");
}
}
Nota Importante sobre interrupt() y sleep(): Si un hilo está en estado de espera (sleep(), wait(), join()) y se llama a interrupt() sobre él, se lanzará una InterruptedException. Dentro del bloque catch, el indicador de interrupción del hilo se limpia (se pone a false). Para asegurar que el bucle de terminación se detenga, es común volver a llamar a Thread.currentThread().interrupt(); dentro del catch.
6. Espera de Hilos (Join)
El método join() permite que un hilo espere a que otro hilo termine su ejecución. Esto es útil para sincronizar el flujo de ejecución.
public void join(): Espera indefinidamente hasta que el hilo termine.public void join(long millis): Espera hasta que el hilo termine o hasta que expire el tiempo especificado en milisegundos.
public class DemoJoin {
public static void main(String[] args) {
Thread hiloTrabajador = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("Hilo Trabajador: Paso " + (i + 1));
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
System.out.println("Hilo Trabajador: Terminado.");
});
hiloTrabajador.start();
try {
System.out.println("Main: Esperando a que el Hilo Trabajador termine...");
hiloTrabajador.join(); // El hilo Main esperará aquí
System.out.println("Hilo Trabajador ha terminado.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Main fue interrumpido mientras esperaba.");
}
System.out.println("Main: Continuando ejecución.");
for (int i = 0; i < 3; i++) {
System.out.println("Main: Paso " + (i + 1));
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
System.out.println("Main: Terminado.");
}
}
En este ejemplo, el hilo main se "bloquea" en la llamada a hiloTrabajador.join() hasta que hiloTrabajador complete su ejecución. Si el hilo sobre el que se llama join() ya ha terminado, la llamada no produce ningún efecto de espera.