W3docs

Introducción al Multithreading en Java

Qué son los hilos, por qué usarlos en Java y las ventajas y desventajas de la programación concurrente.

Cada programa Java que has escrito hasta ahora ha tenido un solo hilo de ejecución — un cursor recorriendo el bytecode, una variable en la pila, una llamada a método a la vez. Ese es el hilo "main" que la JVM inicia por ti. El multithreading consiste en que la JVM ejecute varios cursores a la vez, compartiendo el mismo heap. Dos hilos pueden estar dentro de dos métodos distintos en dos objetos distintos al mismo tiempo — y eso es tanto el poder como el peligro.

Las CPU modernas tienen muchos núcleos. Un programa de un solo hilo deja todos menos uno inactivos. Un servidor web que gestiona una petición a la vez no puede aprovechar una máquina de 16 núcleos más de lo que podría aprovechar una de 1 núcleo. La razón de ser del multithreading es poner esos núcleos a trabajar y mantener el programa respondiendo cuando alguna parte de él está esperando (al disco, a la red, al usuario).

Qué es realmente un hilo

Un hilo Java son dos cosas unidas:

  • Un hilo a nivel de SO que el sistema operativo planifica en un núcleo de CPU. Tiene un contador de programa, un conjunto de registros y una pila nativa. El SO lo reparte en el tiempo 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 llamas a thread.start(), la JVM pide al SO que cree un nuevo hilo nativo que, en su primera planificación, llamará a tu método run(). El hilo original continúa inmediatamente; 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 runs concurrently with main
  System.out.println("main: continuing");
}

La intercalación de la salida no es determinista — el SO 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. Tienes trabajo ligado a la CPU — redimensionamiento de imágenes, análisis, compresión. 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. Tienes un hilo que de otro modo se bloquearía — esperando la respuesta de una red, de una 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 petición lenta no detenga a las demás (concurrencia) y para que muchas peticiones rápidas se puedan manejar en paralelo entre los núcleos (paralelismo).

Por qué los hilos son difíciles

Los hilos comparten memoria. El mismo HashMap, ArrayList o int counter++ pueden ser tocados por dos hilos al mismo instante — y la JVM, las cachés de la CPU y el compilador pueden reordenar operaciones de formas que te sorprenden. Los tres problemas que el código multihilo sigue encontrando:

  • 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 leer el mismo valor y ambos escribir value + 1, y pierdes un incremento. La solución es la sincronización — forzar que la operación leer-modificar-escribir ocurra como un paso indivisible.
  • 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 ninguna regla que fuerce la propagación de la escritura sin una barrera de memoria. Por eso existen volatile, synchronized y java.util.concurrent.
  • Deadlock. El hilo A mantiene el bloqueo X y espera el bloqueo Y; el hilo B mantiene el bloqueo Y y espera el bloqueo X. Ninguno avanza nunca. El programa se cuelga sin ninguna excepción ni línea de registro. El capítulo sobre deadlock muestra cómo detectarlo y evitarlo.

El resto de esta parte del libro trata principalmente de prevenir estos tres fallos mientras se mantiene la ganancia de rendimiento.

El vocabulario que verás

Algunos términos que aparecen en todas partes y que el resto de los capítulos asume:

TérminoQué significa
ConcurrenciaVarias tareas progresando durante el mismo período de tiempo. Pueden o no ejecutarse literalmente al mismo instante.
ParalelismoVarias tareas ejecutándose literalmente al mismo instante en diferentes núcleos. Un subconjunto de la concurrencia.
Exclusión mutuaSolo un hilo puede estar dentro de una sección crítica a la vez. Los bloqueos y synchronized la proporcionan.
Modelo de memoriaLas reglas que dicen cuándo un hilo tiene garantizado ver la escritura de otro hilo. Definido por el JLS, refinado por JSR-133.
AtómicoUna operación que no puede observarse a medias. O ocurrió o no ocurrió — ningún estado intermedio visible para otros hilos.
Thread-safeUna clase cuya API pública puede ser llamada desde múltiples hilos sin sincronización externa y aun así comportarse correctamente.

Hilos daemon y la regla de salida de la JVM

Algo que sorprende a los principiantes: la JVM termina cuando el último hilo no-daemon finaliza. El hilo main es no-daemon. Los hilos que creas con new Thread(...) son no-daemon por defecto — por lo que lanzar un hilo trabajador mantiene la JVM viva hasta que ese trabajador retorne.

Puedes marcar un hilo como daemon con t.setDaemon(true) antes de start(). Los hilos daemon no mantienen la JVM viva; cuando todos los hilos no-daemon terminan, la JVM los elimina abruptamente. Usa daemons para trabajo en segundo plano que debería morir con el programa (un temporizador que sondea, un emisor de métricas) — nunca para trabajo cuya finalización realmente necesitas (escrituras en 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 SO. El modelo mental de este capítulo — un Thread Java igual a un hilo del SO — describe los "hilos de plataforma", que es lo que obtienes con el constructor new Thread(...) sin adornos. Los hilos de plataforma son costosos: cada uno ocupa aproximadamente 1 MB de pila nativa y el SO limita cuántos puede tener un proceso, por lo que se crean con cuidado. Los hilos virtuales son baratos — millones están bien — y hacen que el I/O bloqueante sea gratuito de nuevo. Los cubrimos en Java Virtual Threads; hasta entonces, "hilo" significa "hilo de plataforma".

Un ejemplo trabajado: serial vs. paralelo

El programa a continuación suma una porción de trabajo de CPU de dos formas — una vez secuencialmente en el hilo main, otra vez dividido entre cuatro hilos — e imprime el tiempo de pared para cada uno. 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 pared, hasta que se acaban los núcleos.

java— editable, runs on the server

Qué extraer de la ejecución:

  • La ejecución serial usó un núcleo; la ejecución paralela usó cuatro. La aceleración es sublineal (más cerca de 3x que de 4x) porque el SO, 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 serial (el bucle final de suma de parciales, el arranque del bucle) limita la aceleración.
  • Cada trabajador escribió en su propio slot en partials[]. Ningún par de hilos tocó el mismo índice, así que no se necesitó sincronización. Esta es la forma más sencilla de paralelismo — particionar los datos y dejar que cada hilo sea dueño de su partición.
  • t.join() es la forma en que main espera a que worker-3 termine. Sin los joins, el bucle leería partials antes de que los trabajadores 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 del final no mantuvo la JVM viva. Estaba a punto de dormir durante 60 segundos, pero main retornó y la JVM terminó, matando al daemon en pleno sueño sin ejecutar su sentencia de impresión. Ese es el contrato del daemon.
  • Thread.currentThread().getName() y el nombre explícito pasado al constructor Thread son la forma de distinguir los hilos en los registros, en los perfiladores y en los volcados de hilos. Siempre nombra tus hilos — Thread-3 no sirve de nada cuando intentas averiguar cuál está bloqueado.

Qué sigue

El siguiente capítulo, Java Thread Class, se centra en el objeto Thread en sí — sus constructores, la diferencia entre extender Thread y pasarle un Runnable, y la API para nombres, prioridad, estado daemon e interrupción.

Práctica

Práctica
Dos hilos cada uno ejecutan `counter++` 100,000 veces sobre un `int counter` compartido que comienza en 0. Después de que ambos hacen `join()`, ¿cuál es el valor más probable de `counter`?
Dos hilos cada uno ejecutan `counter++` 100,000 veces sobre un `int counter` compartido que comienza en 0. Después de que ambos hacen `join()`, ¿cuál es el valor más probable de `counter`?
Was this page helpful?