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.

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

Proceso (memoria asignada por el SO)CódigoHeap: objetos y estado compartidoPila del hilo 1Pila del hilo 2

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.

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.

En lo que queda de curso, hilo a secas significa hilo del sistema operativo.

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.

ListoEjecutandoBloqueadoel planificador le asigna un núcleoagota su rodaja de tiempollamada bloqueante (I/O, BBDD)llega el dato que esperabatermina

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 ] ...

El intercalado basta para las condiciones de carrera (sección 7): el planificador puede trocear un hilo en cualquier punto, sin necesidad de varios núcleos.

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.

Hilo de la peticiónprocesaespera a la BBDD (bloqueado)procesaNúcleohilo de la peticiónotro hilohilo de la petición012

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.

  • 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.

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.

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

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();
}

Todo valor guardado en un ThreadLocal debe eliminarse al terminar la tarea. Si el hilo se reutiliza, el valor se filtra a la siguiente.

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 el Socket y 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.

ServidorHilo 1Hilo 2Cliente ACliente BCliente CServidorHilo 1Hilo 2Cliente ACliente BCliente CServidorHilo 1Hilo 2Cliente ACliente BCliente Cconexión Ahandle(socketA)conexión B (simultánea)handle(socketB)respuestarespuestaconexión C (posterior)reutiliza un hilo del poolhandle(socketC)respuesta

Las reglas de los hilos siguen valiendo en el servidor:

  • Estado static compartido → condición de carrera entre peticiones.
  • ThreadLocal sin limpiar → fuga de datos a la siguiente petición del mismo hilo.
  • Variables locales → seguras: cada hilo tiene su propia pila.

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.

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.

Este servidor es el modelo mental con el que entender Tomcat (tema 06): un contenedor que acepta conexiones, las reparte entre hilos de un pool y delega cada petición en un servlet a través de la cadena de filtros.
  • clase/daw/si/1eval/hilos.txt
  • Última modificación: 2026/10/17 10:59
  • por cesguiro