05 - Hilos: de la CPU a Java
El servidor del tema anterior atiende una petición a la vez, y la solución son los hilos. Este tema los recorre de abajo arriba: qué es un hilo para el sistema operativo y la CPU, cómo se usan en Java y el servidor HTTP concurrente que sale de combinar hilos y sockets.
1. Proceso e hilo
Un proceso es un programa en ejecución. El sistema operativo le asigna memoria propia: el código, la memoria dinámica (heap) y sus recursos (ficheros y sockets abiertos).
Un hilo es una secuencia de ejecución dentro de ese proceso. Todos los hilos del proceso trabajan sobre la misma memoria, pero cada uno ejecuta por su lado:
| Compartido por todos los hilos | Propio de cada hilo |
|---|---|
| Código y memoria dinámica (heap) | Pila: variables locales y llamadas en curso |
| Ficheros y sockets abiertos | Registros y contador de programa |
Valores static de las clases | Estado: listo, ejecutando, bloqueado |
Que compartan la memoria es lo que hace barato comunicar hilos entre sí, y también lo que crea el peligro: dos hilos pueden tocar el mismo objeto a la vez.
2. De los núcleos a los hilos
La CPU ejecuta instrucciones con sus núcleos, pero un servidor tiene muchos más hilos que núcleos. Se distinguen varios niveles:
| Nivel | Qué es |
|---|---|
| Núcleo | Unidad física de ejecución de la CPU |
| Hilo de hardware | Cola de instrucciones que un núcleo puede atender. Con SMT (p. ej. Hyper-Threading de Intel) un núcleo atiende dos: es la CPU de “8 núcleos y 16 hilos” |
| Hilo del sistema operativo | La unidad que el SO crea y planifica: cientos en un servidor normal |
| Hilo de plataforma de Java | Un hilo del SO creado por la JVM |
Se puede ver la distinción en el propio sistema: top -1 (o pulsar 1 dentro de top) muestra una línea por cada CPU lógica —con SMT, cada hilo de hardware—:
$ top -1 %Cpu0 : 2.0 us, 0.0 sy, 97.9 id, ... %Cpu1 : 0.0 us, 0.3 sy, 99.7 id, ... ... %Cpu15 : 0.0 us, 0.0 sy, 100.0 id, ...
Esas 16 líneas son los 16 hilos de hardware de una CPU de 8 núcleos; los hilos del SO de la tabla son los que el planificador reparte entre ellas.
3. El planificador
El planificador reparte los núcleos entre los hilos que hay listos para ejecutar:
- A cada hilo le toca una rodaja de tiempo en un núcleo. Al agotarla, el SO guarda su contexto (registros, contador de programa) y da el núcleo a otro hilo: es el cambio de contexto.
- El cambio de contexto no es gratis (guardar y restaurar estado, cachés que se enfrían), pero es mucho más barato que dejar el núcleo sin trabajo.
Con 200 hilos en 8 núcleos, casi todo es concurrencia —hilos que progresan intercalándose en el mismo núcleo— y poco paralelismo —hilos ejecutándose a la vez, uno por núcleo—:
Núcleo 1: [ hilo A ][ hilo B ][ hilo A ][ hilo C ] ...
4. Bloqueo e I/O
Cuando un hilo pide algo que no está listo —leer de un socket, esperar la respuesta de la base de datos— pasa a bloqueado: el SO lo aparca y el núcleo pasa a otro hilo. Cuando llega el dato, el hilo vuelve a estar listo.
Un hilo bloqueado no consume CPU, pero sigue ocupando su pila en memoria y, en un servidor, su sitio en el pool. Por eso unas pocas peticiones lentas copan el pool de un servidor aunque apenas gasten procesador: sus hilos están aparcados, no libres.
5. El coste de un hilo
- Cada hilo de plataforma es una estructura en el kernel y una pila reservada (en torno a 1 MB por defecto): miles de hilos son miles de megas solo de pilas.
- Crear y destruir hilos en cada petición añade ese coste a todas.
- El cambio de contexto se paga en cada intercalado.
Por eso los servidores crean un conjunto de hilos una vez y los reutilizan: es el pool que se usa en la sección 9 y que Tomcat trae de serie.
6. Hilos en Java
Java crea hilos de plataforma (hilos del SO) con Thread. start() lanza el hilo; join() espera su finalización:
// Con una lambda (implementa Runnable)
Thread worker = new Thread(() -> System.out.println("working"));
worker.start();
// Esperar a que termine
worker.join();
Crear un hilo por tarea hereda el coste de la sección 5. Un ExecutorService mantiene un pool de hilos reutilizables:
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.execute(() -> System.out.println("tarea 1"));
pool.execute(() -> System.out.println("tarea 2"));
pool.shutdown();
Las tareas se encolan y los hilos del pool las ejecutan; al terminar una tarea, el hilo queda disponible para la siguiente. Los hilos se reutilizan entre tareas distintas.
7. Estado compartido: condición de carrera
Un campo static pertenece a la clase: todos los hilos ven y modifican el mismo valor.
public class SharedValue {
private static volatile String value;
public static void setValue(String newValue) {
value = newValue;
}
public static String getValue() {
return value;
}
}
Si dos tareas fijan valores distintos a la vez, la segunda puede sobrescribir el valor antes de que la primera lo lea. Es una condición de carrera: el resultado depende del orden temporal en que se ejecutan los hilos.
volatile no crea un valor distinto por hilo: el campo sigue siendo único y compartido. Garantiza visibilidad (los hilos ven la última escritura), pero las operaciones siguen siendo sueltas y pueden solaparse.
Campo static | Variable local |
|---|---|
| Un único valor visible para todos los hilos | Cada hilo tiene la suya en su propia pila |
| Requiere cuidado ante accesos concurrentes | No se comparte entre tareas |
8. ThreadLocal: un valor por hilo
ThreadLocal guarda un valor distinto para cada hilo:
public class ThreadData {
private static final ThreadLocal<String> DATA_BY_THREAD = new ThreadLocal<>();
public static void setData(String data) {
DATA_BY_THREAD.set(data);
}
public static String getData() {
return DATA_BY_THREAD.get();
}
public static void clearData() {
DATA_BY_THREAD.remove();
}
}
Pero el valor es por hilo, no por tarea ni por usuario. Como el pool reutiliza los hilos, el valor fijado en una tarea sigue ahí cuando el mismo hilo ejecuta la siguiente: es una fuga de datos. La solución es eliminar el valor al terminar, con remove() en un bloque finally:
try {
// procesar la tarea usando ThreadData.getData()
} finally {
ThreadData.clearData();
}
ThreadLocal debe eliminarse al terminar la tarea. Si el hilo se reutiliza, el valor se filtra a la siguiente.
9. El servidor HTTP concurrente
El servidor del tema 04 atiende una petición a la vez. La solución es combinar ambas piezas: un pool de hilos atendiendo las conexiones del servidor de sockets.
public class ConcurrentHttpServer {
private static final ExecutorService POOL = Executors.newFixedThreadPool(10);
public static void main(String[] args) throws IOException {
try (ServerSocket serverSocket = new ServerSocket(8080)) {
while (true) {
Socket socket = serverSocket.accept();
POOL.execute(() -> handle(socket));
}
}
}
private static void handle(Socket socket) {
try (socket) {
// leer petición y responder como en el tema 04
} catch (IOException exception) {
// registrar el error
}
}
}
accept()devuelve elSockety el bucle principal queda libre para aceptar la siguiente conexión.- El pool ejecuta cada petición en un hilo worker.
- Los hilos del pool se reutilizan entre peticiones.
Las reglas de los hilos siguen valiendo en el servidor:
- Estado
staticcompartido → condición de carrera entre peticiones. ThreadLocalsin limpiar → fuga de datos a la siguiente petición del mismo hilo.- Variables locales → seguras: cada hilo tiene su propia pila.
10. HttpServer del JDK
Java incluye un servidor HTTP con pool de hilos integrado, útil como alternativa intermedia entre el servidor propio y Tomcat:
public class JdkHttpServer {
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/books", exchange -> {
String body = "{\"message\":\"books endpoint\"}";
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.getBytes().length);
try (OutputStream output = exchange.getResponseBody()) {
output.write(body.getBytes());
}
});
server.setExecutor(Executors.newFixedThreadPool(10));
server.start();
}
}
HttpServer resuelve el parseo del protocolo; el Executor gestiona el pool. Lo que sigue sin resolver: enrutado por rutas, archivos estáticos, ciclo de vida de componentes… Eso es exactamente lo que aporta un contenedor de servlets.
11. Hacia Tomcat
| Capa | Problema que resuelve |
|---|---|
| Sockets | Comunicación TCP |
| Pool de hilos | Concurrencia entre peticiones |
| HttpServer / contenedor | Protocolo HTTP, enrutado, recursos |
| Tomcat | Todo lo anterior + ciclo de vida de servlets y filtros |
En los hilos del pool de Tomcat se reconocen las piezas de este tema. Con top -H el sistema muestra cada hilo como una línea, con su estado en la columna S (R ejecutando, S durmiendo o bloqueado); los del pool de Tomcat se llaman http-nio-8080-exec-N:
$ top -H -p 1234 PID USER PR NI VIRT RES S COMMAND 1251 cesar 20 0 3560224 42100 R http-nio-8080-exec-1 1252 cesar 20 0 3560224 42100 S http-nio-8080-exec-2 1230 cesar 20 0 3560224 42100 S main
Una petición que espera a la base de datos tiene su hilo en estado S hasta que llega la respuesta. Un hilo de plataforma de Java se apoya uno a uno en un hilo del SO: la JVM no planifica, el planificador del SO decide cuándo se ejecuta. Desde Java 21 existen además hilos virtuales (miles de hilos de la JVM sobre pocos hilos del SO); no se usan en este curso.