Hilos
Hasta ahora, nuestros programas han ejecutado las instrucciones de forma secuencial.
Esto significa que normalmente una instrucción se ejecuta después de otra:
Instrucción 1
↓
Instrucción 2
↓
Instrucción 3
↓
Instrucción 4
Por ejemplo:
System.out.println("Primero");
System.out.println("Segundo");
System.out.println("Tercero");
La salida será:
Primero
Segundo
Tercero
Cada instrucción se ejecuta antes de pasar a la siguiente.
Pero en algunos programas necesitamos realizar varias tareas de forma independiente.
Por ejemplo, imaginemos un programa que:
Descarga un archivo
+
Reproduce música
+
Muestra una animación
Si todas estas tareas se ejecutasen de forma completamente secuencial, mientras reproducimos la música no podríamos hacer las demás tareas.
Los hilos, también llamados threads, permiten que un programa pueda ejecutar diferentes tareas de forma concurrente, es decir, al mismo tiempo.
Podemos imaginarlo así:
Programa
├── Hilo 1 → Descargar archivo
│
├── Hilo 2 → Reproducir música
│
└── Hilo 3 → Mostrar animación
Cada hilo representa un flujo de ejecución independiente dentro del programa, de manera que podrán ejecutarse simultaneamente.
Crear un hilo con Thread
Java proporciona una clase llamada:
Thread
que representa un hilo de ejecución. Podemos crear un hilo de esta forma:
Thread hilo = new Thread();
Pero crear el objeto no significa que el hilo haya comenzado a ejecutarse, solamente crea el objeto.
Para iniciar el hilo utilizamos:
start()
Por ejemplo:
Thread hilo = new Thread();
hilo.start();
Sin embargo, este hilo todavía no tiene ninguna tarea que realizar.
Para indicar qué debe hacer el hilo podemos crear una clase que herede de Thread.
Heredar de Thread
Una forma de crear un hilo es crear una clase que herede de Thread.
Por ejemplo:
public class MiHilo extends Thread {
}
Ahora MiHilo es un tipo de hilo.
Podemos imaginarlo:
Thread
│
│ extends
↓
MiHilo
La clase MiHilo heredará las características de Thread.
Pero todavía tenemos que indicar qué código queremos ejecutar. Para ello podemos sobrescribir el método:
run()
El método run()
El método:
run()
contiene las instrucciones que queremos que ejecute el hilo.
Por ejemplo:
public class MiHilo extends Thread {
@Override
public void run() {
System.out.println("Hola desde el hilo");
}
}
Ahora podemos crear el hilo:
MiHilo hilo = new MiHilo();
Y ponerlo en funcionamiento:
hilo.start();
El programa completo sería MiHilo.java:
public class MiHilo extends Thread {
@Override
public void run() {
System.out.println("Hola desde el hilo");
}
}
Y Main:
public class Main {
public static void main(String[] args) {
MiHilo hilo = new MiHilo();
hilo.start();
}
}
La salida será:
Hola desde el hilo
run() frente a start()
Es muy importante diferenciar:
run()
y:
start()
run() contiene el código que queremos ejecutar.
@Override
public void run() {
System.out.println("Hola");
}
Mientras que start() inicia un hilo:
hilo.start();
Por eso, normalmente no debemos llamar directamente a run() cuando queremos crear un nuevo hilo, en su lugar siempre llamaremos a start()
Ejemplo con varios hilos
Podemos crear dos hilos diferentes.
Por ejemplo, el primer hilo Hilo1, que muestra número del 1 al 5:
public class Hilo1 extends Thread {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println("Hilo 1: " + i);
}
}
}
Y el segundo hilo Hilo2:
public class Hilo2 extends Thread {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println("Hilo 2: " + i);
}
}
}
Una vez creados por separado los dos hilos y poniendo su respectivo código en el run() ya podemos iniciarlos:
public class Main {
public static void main(String[] args) {
Hilo1 hilo1 = new Hilo1();
Hilo2 hilo2 = new Hilo2();
hilo1.start();
hilo2.start();
}
}
La salida podría ser:
Hilo 1: 1
Hilo 1: 2
Hilo 2: 1
Hilo 1: 3
Hilo 2: 2
Hilo 2: 3
Hilo 1: 4
Hilo 2: 4
Hilo 1: 5
Hilo 2: 5
Pero también podría ser:
Hilo 2: 1
Hilo 1: 1
Hilo 2: 2
Hilo 2: 3
Hilo 1: 2
Hilo 1: 3
Hilo 2: 4
Hilo 1: 4
Hilo 2: 5
Hilo 1: 5
No podemos asumir un orden concreto, ya que los hilos pueden ejecutarse de forma concurrente y es el sistema el que decide cuándo puede ejecutarse cada uno.
Runnable
Aunque podemos crear hilos heredando de Thread, Java proporciona otra forma muy habitual de hacerlo.
Podemos utilizar:
Runnable
Runnable es una interfaz que representa una tarea que queremos ejecutar.
Podemos imaginar la diferencia:
Thread
↓
Representa el hilo
Runnable
↓
Representa la tarea que queremos ejecutar
Crear un Runnable
Como Runnable es una interfaz, podemos implementar la interfaz:
public class MiTarea implements Runnable {
@Override
public void run() {
System.out.println("Hola desde la tarea");
}
}
Ahora tenemos una tarea que sabe qué hacer. Podemos crearla:
MiTarea tarea = new MiTarea();
Pero todavía no tenemos un hilo ejecutándola, simplemente tenemos una tarea que muestra el mensaje "Hola desde la tarea".
Ahora podemos crear un Thread y pasarle la tarea como argumento:
Thread hilo = new Thread(tarea);
hilo.start();
El programa completo:
public class MiTarea implements Runnable {
@Override
public void run() {
System.out.println("Hola desde la tarea");
}
}
public class Main {
public static void main(String[] args) {
MiTarea tarea = new MiTarea();
Thread hilo = new Thread(tarea);
hilo.start();
}
}
Podemos visualizarlo:
MiTarea
↓
implements Runnable
↓
tarea
↓
new Thread(tarea)
↓
hilo
↓
start()
↓
run()
¿Por qué utilizar Runnable?
Una de las ventajas de Runnable es que Java solamente permite heredar de una clase.
Por ejemplo, una clase no puede hacer:
public class MiClase extends Persona, Thread {
}
Esto no es válido en Java. Sin embargo, sí podemos hacer:
public class MiClase extends Persona implements Runnable {
}
De esta forma podemos seguir heredando de nuestra clase y, además, indicar que nuestra clase representa una tarea ejecutable.
Por ejemplo:
public class Alumno extends Persona implements Runnable {
@Override
public void run() {
System.out.println("El alumno está trabajando");
}
}
Por este motivo, Runnable es una opción muy utilizada para trabajar con hilos.
sleep()
En ocasiones queremos que un hilo espere durante un determinado tiempo.
Por ejemplo, podemos querer que un hilo:
Hace algo
↓
Espera 1 segundo
↓
Hace algo
↓
Espera 1 segundo
↓
Hace algo
Para ello podemos utilizar:
Thread.sleep()
Por ejemplo:
Thread.sleep(1000);
El valor:
1000
representa milisegundos.
Recordemos:
1000 milisegundos = 1 segundo
Por lo tanto:
Thread.sleep(1000);
hace que el hilo actual espere aproximadamente un segundo.
Ejemplo con sleep()
Podemos crear un hilo que cuente hasta cinco cada segundo:
public class MiHilo extends Thread {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("El hilo ha sido interrumpido");
}
}
}
}
Y:
public class Main {
public static void main(String[] args) {
MiHilo hilo = new MiHilo();
hilo.start();
}
}
El resultado será aproximadamente:
1
[espera 1 segundo]
2
[espera 1 segundo]
3
[espera 1 segundo]
4
[espera 1 segundo]
5
¿Por qué sleep() necesita try-catch?
El método:
Thread.sleep()
puede producir una excepción:
InterruptedException
Por eso Java nos obliga a controlar esta excepción.
De manera que siempre que vayamos a utilizar sleep() es altamente recomendable escribirlo así:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("El hilo ha sido interrumpido");
}
Esto conecta con lo que hemos aprendido anteriormente sobre excepciones.
sleep() no detiene todo el programa
Es importante entender que:
Thread.sleep(1000);
no significa que todo el programa se detenga.
Significa que el hilo que está ejecutando esa instrucción queda en espera.
Por ejemplo, podemos tener dos hilos, donde solo el Hilo 1 duerma:
Hilo 1
↓
sleep(3000)
Hilo 2
↓
Continúa ejecutándose
Por ejemplo:
public class Hilo1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
System.out.println("Interrumpido");
}
System.out.println("Hilo 1 terminado");
}
}
Y:
public class Hilo2 extends Thread {
@Override
public void run() {
System.out.println("Hilo 2 ejecutándose");
}
}
Si iniciamos:
Hilo1 hilo1 = new Hilo1();
Hilo2 hilo2 = new Hilo2();
hilo1.start();
hilo2.start();
el segundo hilo puede ejecutarse mientras el primero está esperando.
join()
En ocasiones queremos que un hilo espere a que otro hilo termine.
Para ello podemos utilizar:
join()
Por ejemplo:
hilo.join();
Esto significa:
Espera hasta que
hilotermine su ejecución.
Ejemplo sencillo de join()
Imaginemos que tenemos:
public class MiHilo extends Thread {
@Override
public void run() {
System.out.println("Hilo trabajando...");
}
}
En main:
public class Main {
public static void main(String[] args) {
MiHilo hilo = new MiHilo();
hilo.start();
try {
hilo.join();
} catch (InterruptedException e) {
System.out.println("Interrumpido");
}
System.out.println("Programa terminado");
}
}
Podemos visualizarlo:
main
↓
start()
↓
Hilo comienza
↓
join()
↓
main espera
↓
Hilo termina
↓
main continúa
↓
"Programa terminado"
Diferencia entre sleep() y join()
Aunque ambos hacen que un hilo espere, no significan lo mismo.
Thread.sleep() hace que el hilo actual espere un tiempo determinado.
Por ejemplo:
Thread.sleep(2000);
significa:
Esperar aproximadamente 2 segundos
Mientras que:
hilo.join();
significa:
Esperar hasta que ese hilo termine
Por ejemplo:
Thread.sleep(2000);
no sabe si el otro hilo ha terminado.
Simplemente espera dos segundos.
En cambio:
hilo.join();
espera exactamente hasta que hilo termine.
Ejemplo combinando sleep() y join()
Podemos crear un hilo que tarde unos segundos en terminar:
public class MiHilo extends Thread {
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
System.out.println("Trabajando: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Interrumpido");
}
}
}
}
En main:
public class Main {
public static void main(String[] args) {
MiHilo hilo = new MiHilo();
hilo.start();
try {
hilo.join();
} catch (InterruptedException e) {
System.out.println("Interrumpido");
}
System.out.println("El hilo ha terminado");
}
}
La ejecución será aproximadamente:
Trabajando: 1
[espera 1 segundo]
Trabajando: 2
[espera 1 segundo]
Trabajando: 3
[espera 1 segundo]
El hilo ha terminado
El main no muestra:
El hilo ha terminado
hasta que MiHilo ha terminado.
Varios hilos y join()
También podemos utilizar varios hilos.
Por ejemplo:
MiHilo hilo1 = new MiHilo();
MiHilo hilo2 = new MiHilo();
hilo1.start();
hilo2.start();
Ahora tenemos:
Programa
/ \
↓ ↓
Hilo 1 Hilo 2
Podemos esperar a los dos:
try {
hilo1.join();
hilo2.join();
} catch (InterruptedException e) {
System.out.println("Interrumpido");
}
De esta forma podemos asegurarnos de que los dos hilos han terminado antes de continuar con el programa principal.
Los hilos no garantizan un orden
Es importante recordar que cuando utilizamos varios hilos no podemos asumir el orden en el que se ejecutarán.
Por ejemplo:
hilo1.start();
hilo2.start();
no significa necesariamente:
Hilo 1
Hilo 2
Podría ocurrir:
Hilo 2
Hilo 1
o:
Hilo 1
Hilo 1
Hilo 2
Hilo 1
Hilo 2
El sistema operativo y la máquina virtual de Java gestionan cuándo puede ejecutarse cada hilo.
Por eso, cuando nuestro programa necesita que una tarea termine antes de continuar, podemos utilizar mecanismos como:
join()