Introducción al multithreading de Java
Qué son los hilos, por qué usarlos en Java y los compromisos de la programación concurrente.
Introducción al multithreading de Java
Cada programa Java que ha escrito hasta ahora ha tenido un único hilo de ejecución — un cursor recorriendo el bytecode, una variable en la pila, una llamada a un método a la vez. Ese es el hilo «main» que la JVM arranca por usted. El multithreading es la JVM ejecutando varios de esos cursores a la vez, compartiendo el mismo montículo. Dos hilos pueden estar dentro de dos métodos distintos sobre dos objetos distintos en el mismo instante — y eso es a la vez el poder y el peligro.
Las CPU modernas tienen muchos núcleos. Un programa de un solo hilo deja todos menos uno ociosos. Un servidor web que atiende una solicitud a la vez no puede aprovechar una máquina de 16 núcleos mejor de lo que aprovecharía una de 1 núcleo. La razón entera por la que existe el multithreading es poner esos núcleos a trabajar y mantener el programa receptivo cuando una parte de él está esperando (al disco, a la red, al usuario).
Qué es realmente un hilo
Un hilo de Java son dos cosas pegadas:
- Un hilo a nivel del sistema operativo que el sistema operativo planifica sobre un núcleo de CPU. Tiene un contador de programa, un conjunto de registros y una pila nativa. El sistema operativo le asigna porciones de tiempo en alternancia con todos los demás hilos ejecutables de la máquina.
- Un objeto Java de tipo
java.lang.Thread. Lleva un nombre, una prioridad, un indicador de daemon y — lo más importante — una referencia alRunnablecuyo métodorun()ejecutará.
Cuando llama a thread.start(), la JVM le pide al sistema operativo que cree un nuevo hilo nativo que, en su primera planificación, llamará a su método run(). El hilo original continúa de inmediato; los dos ahora se ejecutan de forma concurrente.
public static void main(String[] args) {
System.out.println("main: hello from " + Thread.currentThread().getName());
Thread t = new Thread(() -> {
System.out.println("worker: hello from " + Thread.currentThread().getName());
}, "worker-1");
t.start(); // worker se ejecuta en concurrencia con main
System.out.println("main: continuing");
}El entrelazado de la salida no es determinista — el sistema operativo decide qué hilo se ejecuta primero, y esa decisión cambia entre ejecuciones. Ese no-determinismo es el hecho central de la programación concurrente.
Por qué usar hilos
Dos motivaciones distintas, a menudo confundidas:
- Rendimiento (throughput). Tiene trabajo limitado por CPU — redimensionar imágenes, analizar, comprimir. Un hilo usa un núcleo; ocho hilos usan ocho núcleos y terminan aproximadamente ocho veces más rápido. Esto es paralelismo.
- Capacidad de respuesta. Tiene un hilo que de otro modo se bloquearía — esperando una respuesta de red, una respuesta de base de datos, un clic del usuario. Poner ese trabajo en un hilo separado permite que el resto del programa siga haciendo cosas útiles mientras espera. Esto es concurrencia.
La mayoría de los programas reales necesitan ambas. Un servidor web usa muchos hilos para que una solicitud lenta no atasque a las demás (concurrencia) y para que muchas solicitudes rápidas puedan atenderse en paralelo a través de los núcleos (paralelismo).
Por qué los hilos son difíciles
Los hilos comparten memoria. La misma HashMap, ArrayList o int counter++ puede ser tocada por dos hilos en el mismo instante — y a la JVM, las cachés de la CPU y el compilador se les permite a todos reordenar operaciones de maneras que le sorprenden. Los tres problemas con los que el código multihilo choca una y otra vez:
- Condiciones de carrera. Dos hilos leen-modifican-escriben la misma variable; una de las actualizaciones se pierde.
counter++no es atómico — es leer counter, sumar uno, escribir counter. Dos hilos pueden ambos leer el mismo valor y ambos volver a escribirvalue + 1, y usted pierde un incremento. - Visibilidad. Un hilo escribe un campo; otro hilo lo lee y ve el valor antiguo, porque cada hilo tiene su propia caché de CPU y no hay regla que fuerce la propagación de la escritura sin una barrera de memoria. Por eso existen
volatile,synchronizedyjava.util.concurrent. - Interbloqueo (deadlock). El hilo A tiene el cerrojo X y espera el cerrojo Y; el hilo B tiene el cerrojo Y y espera el cerrojo X. Ninguno avanza nunca. El programa se cuelga sin excepción ni línea de registro.
El resto de esta parte del libro trata en gran medida de prevenir estos tres fallos a la vez que se conserva la ganancia de rendimiento.
El vocabulario que verá
Unos cuantos términos que aparecen por todas partes y que el resto de los capítulos da por supuestos:
| Término | Qué significa |
|---|---|
| Concurrencia | Múltiples tareas avanzando durante el mismo período de tiempo. Puede que se ejecuten o no literalmente en el mismo instante. |
| Paralelismo | Múltiples tareas ejecutándose literalmente en el mismo instante en núcleos distintos. Un subconjunto de la concurrencia. |
| Exclusión mutua | Solo se permite un hilo dentro de una sección crítica a la vez. Los cerrojos y synchronized la proporcionan. |
| Modelo de memoria | Las reglas que dicen cuándo se garantiza que un hilo verá la escritura de otro hilo. Definido por la JLS, refinado por JSR-133. |
| Atómica | Una operación que no puede observarse a medio hacer. O sucedió o no — ningún estado intermedio visible para otros hilos. |
| Thread-safe | Una clase cuya API pública puede llamarse desde múltiples hilos sin sincronización externa y aun así comportarse correctamente. |
Hilos daemon y la regla de salida de la JVM
Una cosa que sorprende a los principiantes: la JVM se termina cuando finaliza el último hilo no-daemon. El hilo main es no-daemon. Los hilos que crea con new Thread(...) son no-daemon por defecto — así que generar un hilo worker mantiene viva la JVM hasta que ese worker retorna.
Puede marcar un hilo como daemon con t.setDaemon(true) antes de start(). Los hilos daemon no mantienen viva la JVM; cuando todos los hilos no-daemon terminan, la JVM los arranca de cuajo. Use daemons para trabajo en segundo plano que deba morir con el programa (un temporizador que sondea, un volcador de métricas) — nunca para trabajo cuya finalización realmente necesita (escrituras de archivos, confirmaciones de transacciones).
Hilos vs. hilos virtuales
Java 21 introdujo los hilos virtuales, que parecen idénticos a nivel de API pero son planificados por la JVM sobre un pequeño grupo de hilos del sistema operativo. El modelo mental de este capítulo — un Thread de Java equivale a un hilo del sistema operativo — describe los «hilos de plataforma», que es lo que obtiene con el constructor new Thread(...) sin adornos. Los hilos de plataforma son caros: cada uno ocupa cerca de 1 MB de pila nativa y el sistema operativo limita cuántos puede tener un proceso, así que los crea con cuidado. Los hilos virtuales son baratos — millones está bien — y vuelven a hacer gratuita la E/S bloqueante. Los cubriremos al final de esta parte del libro; hasta entonces, «hilo» significa «hilo de plataforma».
Un ejemplo trabajado: serie vs. paralelo
El programa siguiente suma un fragmento de trabajo de CPU de dos maneras — una vez secuencialmente en el hilo main, una vez repartido entre cuatro hilos — e imprime el tiempo de reloj de cada una. Los números varían según la máquina, pero la forma es la misma en todas partes: más hilos, menos tiempo de reloj, hasta que se quede sin núcleos.
Qué llevarse de la ejecución:
- La ejecución en serie usó un núcleo; la ejecución en paralelo usó cuatro. La aceleración es sublineal (más cerca de 3x que de 4x) porque el sistema operativo, el GC y otros hilos de la JVM también quieren tiempo de CPU. La ley de Amdahl en acción — una pequeña fracción en serie (el bucle final de suma de parciales, el arranque del bucle) limita la aceleración.
- Cada worker escribió en su propio espacio de
partials[]. Nunca dos hilos tocaron el mismo índice, así que no se necesitó sincronización. Esta es la forma más fácil de paralelismo — particionar los datos y dejar que cada hilo sea dueño de su partición. t.join()es cómomainespera a queworker-3termine. Sin los join, el bucle leeríapartialsantes de que los workers hubieran escrito, yparallelSumsería incorrecto.joines la única pieza de coordinación de hilos que usa este programa; los siguientes capítulos introducirán muchas más.- El hilo daemon de abajo no mantuvo viva la JVM. Estaba a punto de dormir 60 segundos, pero
mainretornó y la JVM se terminó, matando al daemon a mitad del sueño sin ejecutar su instrucción de impresión. Ese es el contrato del daemon. Thread.currentThread().getName()y el nombre explícito pasado al constructorThreadson cómo distingue los hilos en los registros, en los perfiladores y en los volcados de hilos. Nombre siempre sus hilos —Thread-3es inútil cuando intenta averiguar cuál está atascado.
Qué sigue
El siguiente capítulo, Clase Thread de Java, se acerca al propio objeto Thread — sus constructores, la diferencia entre extender Thread y pasarle un Runnable, y la API para el nombrado, la prioridad, el estado daemon y la interrupción.
Práctica
Dos hilos ejecutan cada uno `counter++` 100 000 veces sobre un `int counter` compartido que empieza en 0. Tras el `join()` de ambos, ¿cuál es el valor más probable de `counter`?