Concurrencia
Hilos y su ciclo de vida
Qué es un hilo, cómo se crea y arranca, qué estados atraviesa y cómo esperar, interrumpir y nombrar hilos sin adivinar.
Desde Java 8
Este capítulo cubre Java 8. Estás leyendo en .
Un proceso, varios hilos
Un programa Java no se ejecuta dentro de un hilo: se ejecuta dentro de un proceso, la JVM, y ese proceso puede tener muchos hilos. Cada hilo tiene su propia pila de llamadas y su propio contador de programa: por ahí va su ejecución, con sus variables locales. Lo que comparten es el heap, donde viven los objetos, y las variables estáticas. Por eso dos hilos de la misma JVM ven el mismo objeto: si uno cambia un campo, el otro puede ver el cambio, aunque no en el momento que esperas.
La pila es privada; el heap, no. Un hilo no tiene una copia del objeto: tiene una referencia al mismo
objeto. Y una línea de Java no es atómica por ocupar una línea: total++ son leer, sumar y escribir, y
el planificador puede cambiar de hilo entre cualquiera de esos pasos. La concurrencia no es solo que las
cosas pasen a la vez; es que pueden entrelazarse.
Crear un hilo: Thread y Runnable
Para ejecutar código en otro hilo necesitas un objeto Thread y algo que ejecutar. Hay dos caminos:
implementar Runnable y pasarlo al constructor, o extender Thread y sobrescribir run(). El primero
separa la tarea del hilo; el segundo ata la tarea a una subclase de Thread.
public class CrearHilos {
static class Heredero extends Thread {
Heredero() {
super("heredero");
}
@Override
public void run() {
System.out.println("run en " + getName());
}
}
public static void main(String[] args) throws InterruptedException {
Runnable tarea = () -> System.out.println(
"Runnable en " + Thread.currentThread().getName());
Thread hilo = new Thread(tarea, "encargo");
hilo.start();
hilo.join();
tarea.run();
new Heredero().start();
}
}start() pide a la JVM que arranque un hilo nuevo y que llame a run() dentro de él. run() es un
método normal: si lo llamas tú, se ejecuta en el hilo que hace la llamada. En el ejemplo, hilo.start()
imprime Runnable en encargo y tarea.run() imprime Runnable en main. El join() de en medio no
forma parte de la creación: está para que la salida sea la misma en cada ejecución.
El nombre se pasa en el constructor Thread(Runnable, String) o se cambia con setName; sin nombre,
la JVM pone uno del estilo de Thread-0. Thread.currentThread() devuelve el hilo que ejecuta la línea.
El nombre del hilo es lo que te permite reconocerlo en un log y en un thread dump. Un dump se pide con
jcmd <pid> Thread.print o jstack <pid> y muestra, para cada hilo, su nombre, su estado y su pila.
Sin nombres, el dump es una lista de Thread-12; con nombres, ves cobros o http-8080.
Un hilo se arranca una vez
Un Thread representa una ejecución, no una tarea reutilizable. start() solo se puede llamar una
vez; el segundo intento lanza IllegalThreadStateException, aunque el hilo ya haya terminado. Para
repetir el trabajo, crea otro Thread con la misma Runnable: la tarea sí se puede reutilizar.
public class UnaVez {
public static void main(String[] args) throws InterruptedException {
Thread hilo = new Thread(() -> System.out.println("trabajo"), "unico");
hilo.start();
hilo.join();
try {
hilo.start();
} catch (IllegalThreadStateException e) {
System.out.println("segundo start: " + e.getClass().getSimpleName());
}
}
}El estado no vuelve a NEW y el hilo no se reinicia. Si lo que quieres es reutilizar hilos en vez de
crear uno por tarea, eso es un pool, y llega en el capítulo 3.
Los seis estados
getState() devuelve un valor de Thread.State, una enumeración con seis constantes: NEW,
RUNNABLE, BLOCKED, WAITING, TIMED_WAITING y TERMINATED. La figura agrupa las tres esperas en
una tarjeta para que el ciclo quepa de un vistazo; la tabla las separa.
El ciclo de vida y sus tres esperas
- 01NEW
Nadie llamó a
start();start()lo pasa a RUNNABLE. - 02RUNNABLE Ejecuta o espera turno; de aquí salen las esperas y el final.
- 03Las tres esperas
BLOCKEDWAITINGTIMED_WAITINGDesde RUNNABLE; monitor, señal o plazo. Vuelven a RUNNABLE. - 04TERMINATED
Solo desde RUNNABLE;
run()vuelve o lanza una excepción.
El recorrido de la figura es el camino principal: NEW, RUNNABLE, TERMINATED. Las esperas son desvíos:
desde RUNNABLE se entra en BLOCKED, WAITING o TIMED_WAITING, y desde cualquiera de las tres se
vuelve a RUNNABLE; el final solo llega desde RUNNABLE. RUNNABLE no significa que el hilo use la
CPU, solo que la JVM no lo tiene esperando; el sistema operativo puede no haberle dado turno. Una
espera con plazo no es lo mismo que una sin plazo: sleep, join con tiempo y wait con tiempo son
TIMED_WAITING; join() sin tiempo, wait() sin tiempo y park() son WAITING. BLOCKED es solo
para el monitor de un synchronized; una lectura de disco no lo pasa a BLOCKED.
| Estado | Entra cuando | Sale cuando |
|---|---|---|
NEW | Creas el Thread y nadie llamó a start(). | start() lo pasa a RUNNABLE. |
RUNNABLE | Arranca o vuelve de una espera. | Espera un monitor, una señal o un plazo, o termina. |
BLOCKED | Otro hilo tiene el monitor de un synchronized. | El monitor queda libre. |
WAITING | wait(), join() o park() sin plazo. | notify(), notifyAll(), unpark() o el hilo esperado termina. |
TIMED_WAITING | sleep(ms), wait(ms), join(ms) o park con plazo. | Vence el plazo o llega la misma señal que en WAITING. |
TERMINATED | run() vuelve o lanza una excepción. | Ya no se sale: el hilo no vuelve a arrancar. |
Esperar a que termine: join
join() deja el hilo que lo llama esperando hasta que el hilo destino termina. Sin plazo espera para
siempre; join(millis) espera como mucho ese tiempo. Si el destino todavía está en NEW, join()
vuelve al instante; si el hilo que espera recibe interrupt(), join() lanza InterruptedException.
En el ejemplo, main arranca dos hilos, espera a los dos y solo entonces lee lo que escribieron. Cada
hilo escribe un campo distinto; join() no solo espera, también hace visibles las escrituras que el
hilo hizo antes de terminar. La suma es 42 en todas las ejecuciones.
public class Estados {
static int parteA;
static int parteB;
public static void main(String[] args) throws InterruptedException {
Thread a = new Thread(() -> {
parteA = 21;
System.out.println("a dentro: " + Thread.currentThread().getState());
}, "a");
Thread b = new Thread(() -> parteB = 21, "b");
System.out.println("antes de start: " + a.getState());
a.start();
b.start();
a.join();
b.join();
System.out.println("suma: " + (parteA + parteB));
System.out.println("despues de join: " + a.getState());
}
}La salida es antes de start: NEW, a dentro: RUNNABLE, suma: 42 y despues de join: TERMINATED.
Las cuatro líneas salen siempre en ese orden: los dos join() fijan los dos puntos que podrían bailar.
El ejemplo pilla tres de los seis estados; los otros tres necesitan una espera que provoque otro hilo.
Dormir e interrumpir
Thread.sleep(millis) deja el hilo en TIMED_WAITING y no suelta los monitores que tenga.
interrupt() no mata el hilo: pone una marca. Si el hilo está en sleep, wait o join, esa espera
lanza InterruptedException y limpia la marca; si ejecuta código normal, la marca queda para que la
consulte. La interrupción es una petición, no una orden.
public class Interrupcion {
public static void main(String[] args) throws InterruptedException {
Thread hilo = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(10);
System.out.println("trabajo");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("salgo con la marca: "
+ Thread.currentThread().isInterrupted());
}, "trabajador");
hilo.start();
Thread.sleep(100);
hilo.interrupt();
hilo.join();
System.out.println("main sigue");
}
}El trabajador puede imprimir más o menos líneas trabajo: el número de vueltas depende del instante
en que llega la interrupción. Lo que no cambia es el final: salgo con la marca: true y luego main sigue, porque join() espera a que termine. La marca restaurada hace falsa la condición del bucle.
Hilos daemon
Un hilo hereda el estado de daemon del hilo que lo crea, y main no es daemon: la JVM no termina
mientras quede un hilo de usuario vivo. Un hilo daemon es de servicio: cuando solo quedan daemons, la
JVM se apaga y se los lleva por delante. setDaemon(true) solo vale antes de start(); sobre un hilo
vivo lanza IllegalThreadStateException. Un daemon no es para trabajo que deba terminar: sus finally
pueden no ejecutarse. Si main crea un daemon que duerme un minuto y termina, el programa acaba en el
acto, no espera ese minuto.
Dos hilos, un contador
Ahora el primer choque con el estado compartido. Dos hilos incrementan el mismo campo estático.
total++ no es atómico: leer, sumar y escribir. Si los dos leen el mismo valor, los dos escriben el
mismo resultado y una actualización se pierde.
public class Contador {
static int total;
public static void main(String[] args) throws InterruptedException {
Runnable sumar = () -> {
for (int i = 0; i < 1_000_000; i++) {
total++;
}
};
Thread a = new Thread(sumar, "a");
Thread b = new Thread(sumar, "b");
a.start();
b.start();
a.join();
b.join();
System.out.println("total: " + total); // puede ser menos de 2000000
}
}Este ejemplo no es determinista. Puede imprimir 2000000 si el planificador no cruza ninguna actualización, y menos si las cruza; nunca más, porque las actualizaciones se pierden, no se inventan. En cincuenta ejecuciones de prueba el número siempre quedó por debajo del total; el tuyo puede ser otro.
La causa no es que int sea lento: es que la operación no es indivisible. El capítulo 2 explica la
memoria compartida y la sincronización; aquí basta con ver el síntoma.
Lo que cuesta un hilo
Un hilo de plataforma es un hilo del sistema operativo: la JVM pide uno al sistema operativo y cada uno tiene su pila. Crear miles es caro en memoria y en cambios de contexto. El capítulo 3 trae los executors y los pools, que reutilizan hilos; el capítulo 6 trae los hilos virtuales, que gestiona la JVM y son mucho más baratos. En este capítulo trabajas con hilos de plataforma.
Lo que viene
El capítulo 2 entra en la memoria compartida: por qué el contador pierde actualizaciones, qué garantiza
synchronized, para qué sirve volatile y cómo esperar y despertar hilos sin perder señales.
Qué te llevas
- Un hilo tiene pila y contador de programa propios y comparte el heap; dos hilos ven los mismos objetos.
ThreadyRunnableson las dos formas de crear un hilo;start()arranca un hilo nuevo yrun()es una llamada normal en el hilo actual.- Un
Threadse arranca una sola vez: el segundostart()lanzaIllegalThreadStateException, aunque el hilo ya haya terminado. Thread.Statetiene seis valores:NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITINGyTERMINATED;getState()los lee yjoin()espera a que el hilo termine.sleepdeja el hilo enTIMED_WAITING;interrupt()pide parar, no mata, yInterruptedExceptionno se traga: se propaga o se repone la marca conThread.currentThread().interrupt().- Un daemon no impide que la JVM termine;
setDaemon(true)va antes destart(). Nombra los hilos para que los logs y los thread dumps se lean mejor. - Dos hilos que incrementan el mismo campo pierden actualizaciones; el resultado puede ser menor que el total esperado. Los hilos de plataforma son caros: los pools llegan en el capítulo 3 y los virtuales en el 6.