W3docs

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 al Runnable cuyo método run() 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 escribir value + 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, synchronized y java.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érminoQué significa
ConcurrenciaMúltiples tareas avanzando durante el mismo período de tiempo. Puede que se ejecuten o no literalmente en el mismo instante.
ParalelismoMúltiples tareas ejecutándose literalmente en el mismo instante en núcleos distintos. Un subconjunto de la concurrencia.
Exclusión mutuaSolo se permite un hilo dentro de una sección crítica a la vez. Los cerrojos y synchronized la proporcionan.
Modelo de memoriaLas 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ómicaUna operación que no puede observarse a medio hacer. O sucedió o no — ningún estado intermedio visible para otros hilos.
Thread-safeUna 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.

java— editable, runs on the server

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ómo main espera a que worker-3 termine. Sin los join, el bucle leería partials antes de que los workers hubieran escrito, y parallelSum sería incorrecto. join es 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 main retornó 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 constructor Thread son cómo distingue los hilos en los registros, en los perfiladores y en los volcados de hilos. Nombre siempre sus hilos — Thread-3 es 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

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`?