Día 17 de 30 Fase 4: Seguridad, Concurrencia y Reactividad vs Virtual Threads

Concurrencia Clásica y Java Memory Model

ExecutorService, synchronized y ReentrantLock, volatile y happens-before, atómicos y ConcurrentHashMap, y CompletableFuture

Para dominar. Al terminar deberías poder hacerlo sin ayuda.

  • 4h de estudio
  • 5 conceptos
  • 3 preguntas
  • 2 katas
Java 17 LTSJava 21 LTSConcurrenciaJMMExecutorServiceCompletableFuture
Al terminar este día sabrás 4
  • Ejecutar tareas en paralelo con un ExecutorService acotado y cerrarlo correctamente.
  • Proteger datos compartidos con synchronized, ReentrantLock o atómicos según el caso.
  • Explicar visibilidad y happens-before, y cuándo basta con volatile.
  • Combinar llamadas lentas con CompletableFuture, con timeouts y tratamiento de errores.

Hilos y ExecutorService

Crear new Thread() para cada tarea es caro y difícil de controlar. Un ExecutorService gestiona un pool de hilos reutilizables: le envías tareas con submit y recibes un Future con el resultado. Al terminar hay que cerrarlo.

En una línea ExecutorService pool = Executors.newFixedThreadPool(8); Future<Informe> f = pool.submit(() -> generar(id));

Pool acotado, timeouts y cierre ordenado java
List<Long> ids = List.of(1L, 2L, 3L, 4L);

// Java 17: cierre manual
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
    List<Future<Informe>> futuros = pool.invokeAll(
        ids.stream().<Callable<Informe>>map(id -> () -> generador.generar(id)).toList(),
        30, TimeUnit.SECONDS);                        // las que no acaben a tiempo se cancelan

    for (Future<Informe> f : futuros) {
        try {
            guardar(f.get());
        } catch (ExecutionException e) {
            log.error("Falló un informe", e.getCause());
        } catch (CancellationException e) {
            log.warn("Informe cancelado por timeout");
        }
    }
} finally {
    pool.shutdown();
    if (!pool.awaitTermination(10, TimeUnit.SECONDS)) pool.shutdownNow();
}

// Java 21: el executor es AutoCloseable (close() espera a que terminen las tareas)
try (var vt = Executors.newVirtualThreadPerTaskExecutor()) {
    ids.forEach(id -> vt.submit(() -> generador.generar(id)));
}
Cómo funciona por dentro

Un hilo de plataforma es un hilo del sistema operativo: reserva memoria para su pila (del orden de 1 MB) y crearlo y destruirlo cuesta. Por eso no se crea uno por tarea, sino que se reutilizan en un pool. Executors.newFixedThreadPool(n) crea n hilos y una cola para las tareas pendientes; submit encola un Callable o un Runnable y devuelve un Future, e invokeAll envía varias tareas y espera a todas.

Future.get() bloquea hasta tener el resultado y relanza cualquier excepción de la tarea envuelta en ExecutionException; usa get(timeout, unidad) para no esperar indefinidamente. Si nadie llama a get, la excepción de una tarea enviada con submit se pierde en silencio.

El tamaño del pool depende del trabajo: para tareas de CPU, cerca del número de núcleos (Runtime.getRuntime().availableProcessors()); para tareas que esperan I/O, más, pero acotado por lo que aguanta el recurso compartido, como el pool de conexiones. Al terminar se llama a shutdown() y awaitTermination(); en Java 21, el executor es AutoCloseable y un try-with-resources lo hace por ti. Un newCachedThreadPool sin límite puede crear miles de hilos bajo carga y tumbar el proceso.

Limitar el número de hilos evita quedarse sin memoria o saturar la base de datos. Spring usa pools en @Async, en el servidor web y en los listeners de mensajería.

Se conecta con Pools de hilosFutureVirtual threads

Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.

  1. Bloque 1: Condiciones de Carrera (0 - 25 min)

    Qué estudiar. ExecutorService, contador++ y synchronized.

    Pregunta a responder. ¿Por qué el contador no llega a 1.000.000 con diez hilos?

    Resultado esperado. Programa que reproduce la condición de carrera y tres arreglos: synchronized, AtomicLong y LongAdder.

  2. Bloque 2: Visibilidad y Java Memory Model (25 - 45 min)

    Qué estudiar. volatile, happens-before y el patrón de bandera de parada.

    Pregunta a responder. ¿Qué relaciones happens-before crea un bloque synchronized?

    Resultado esperado. Hilo de trabajo que se detiene con una bandera volatile y una explicación escrita de por qué, sin volatile, podría no parar nunca.

  3. Bloque 3: Componer con CompletableFuture (45 - 80 min)

    Qué estudiar. supplyAsync, thenCombine, allOf, orTimeout y exceptionally.

    Pregunta a responder. ¿Por qué no usar el pool común para llamadas de red?

    Resultado esperado. Agregador que llama en paralelo a tres servicios simulados con distinta latencia y devuelve un resultado parcial si uno supera el timeout.

  4. Cierre de Consolidación (80 - 90 min)

    Qué estudiar. Repaso de la rúbrica del tutor.

    Pregunta a responder. Explicar volatile frente a synchronized sin mirar.

    Resultado esperado. Mapa de qué herramienta usar para cada problema: visibilidad, atomicidad, exclusión y composición.

Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.

Explícalo desde cero

Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.

Por qué contador++ no es seguro entre hilos

Lo que debes entender

contador++ son tres pasos: leer el valor, sumar uno y escribirlo. Si dos hilos leen 5 a la vez, los dos escriben 6 y se pierde un incremento. Hace falta que esos tres pasos ocurran como uno solo, con un lock o un atómico.

Analogía para contarlo

Dos personas actualizan a la vez el mismo número en una pizarra: las dos leen 5 y las dos escriben 6. Debería poner 7.

Lo que suele explicarse mal

Decir que el problema es que «los hilos van a la vez». El problema es que la operación no es atómica y que, sin sincronización, un hilo puede no ver lo que escribió el otro.

Señal de que aún no lo dominas

Si un contador compartido da un resultado distinto cada vez que ejecutas el test.

Preguntas trampa

Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.

Nivel 1 (Básico) ¿Qué diferencia hay entre volatile y synchronized? Ver respuesta

Respuesta razonada

volatile actúa sobre una variable: garantiza que cada lectura ve la última escritura de cualquier hilo y que las operaciones de alrededor no se reordenan con ella. No da exclusión mutua, así que contador++ sobre un volatile sigue perdiendo incrementos.

synchronized actúa sobre un bloque de código: solo un hilo lo ejecuta a la vez para el mismo monitor, y al salir publica todo lo que se escribió dentro para el siguiente hilo que entre. Regla práctica: volatile para banderas y referencias que se publican; synchronized, locks o atómicos cuando la operación lee y escribe.

Qué está evaluando la pregunta

volatile garantiza visibilidad y orden de una variable concreta: sus escrituras se ven en el resto de hilos. No hace atómicas las operaciones compuestas. synchronized garantiza exclusión mutua y, además, la visibilidad de todo lo escrito dentro del bloque.

Nivel 2 (Intermedio) ¿Cómo se produce un deadlock y cómo se evita? Ver respuesta

Respuesta razonada

Hacen falta cuatro condiciones a la vez: exclusión mutua, que un hilo retenga un recurso mientras espera otro, que nadie pueda quitárselo y una espera circular (A espera a B y B espera a A). Basta con romper una.

En la práctica: adquirir los locks siempre en el mismo orden global (por ejemplo, por id), no llamar a código ajeno ni a servicios externos con un lock tomado, usar tryLock(tiempo) y desistir si no se consigue, y reducir el número de locks. Para diagnosticarlo, jcmd <pid> Thread.print o jstack muestran los hilos bloqueados, y la JVM informa explícitamente de los deadlocks que detecta.

Qué está evaluando la pregunta

Cuando dos hilos esperan cada uno un lock que tiene el otro: A tiene L1 y pide L2; B tiene L2 y pide L1. Se evita tomando los locks siempre en el mismo orden, usando tryLock con tiempo límite o reduciendo el número de locks. jstack o jcmd <pid> Thread.print detectan e informan del deadlock.

Nivel 3 (Entrevista Senior) Un HashMap normal compartido entre hilos «funciona en las pruebas». ¿Qué puede pasar en producción? Ver respuesta

Respuesta razonada

HashMap no está diseñado para escrituras concurrentes. Dos put simultáneos pueden escribir en el mismo cubo y uno sobrescribir al otro; un redimensionado a mitad de una lectura puede devolver null para una clave que existe; y el tamaño interno puede quedar incoherente. En Java 7 y anteriores, un redimensionado concurrente podía crear un ciclo en la lista de un cubo y dejar un hilo en un bucle infinito al 100 % de CPU.

Aunque solo un hilo escriba, sin happens-before los lectores pueden no ver nunca los cambios o ver objetos a medio construir. En las pruebas suele «funcionar» porque hay poca concurrencia; en producción aparece de forma intermitente. La solución es ConcurrentHashMap, o publicar un mapa inmutable (Map.copyOf) a través de un campo volatile cuando se reemplaza entero.

Qué está evaluando la pregunta

Escrituras perdidas, lecturas de datos a medio actualizar y estructuras internas corruptas durante un redimensionado (en versiones antiguas, incluso bucles infinitos). Además, sin happens-before, un hilo puede no ver nunca lo que escribió otro. Hay que usar ConcurrentHashMap o sincronizar todos los accesos.

Reto del tutor

Un compañero quiere acelerar un informe que llama de forma secuencial a tres servicios externos (2 s cada uno). Propone un new Thread() por llamada y guardar los resultados en un ArrayList compartido. Evalúa la propuesta.

Lo que una respuesta suele tener bien

Detecta que las llamadas son independientes y pueden ir en paralelo.

Lo que suele quedarse en la superficie

Decir «usa hilos» o «hazlo asíncrono» sin más.

El concepto de fondo que suele faltar

ArrayList no es seguro entre hilos, no hay forma de esperar a que terminen los tres ni de propagar errores, crear hilos sin control no escala y no hay timeouts.

Cómo lo respondería alguien senior en una entrevista

Usar un ExecutorService acotado (o virtual threads en Java 21) y tres CompletableFuture.supplyAsync(..., executor) combinados con allOf o thenCombine, cada uno con orTimeout y exceptionally para degradar con elegancia. La latencia pasa de 6 s a unos 2 s. En Java 21 también vale un newVirtualThreadPerTaskExecutor() con código bloqueante sencillo.

Proyecto «Pedidos» · paso 17 de 30

Stock sin vender de más

Que dos clientes que compran a la vez la última unidad no la consigan los dos.

De qué va el proyecto

El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.

Qué hacer

  1. Escribe un test que lance 50 compras simultáneas del mismo producto con stock 10 usando un ExecutorService.
  2. Corrige la reserva de stock para que el test pase (lock por producto, operación atómica o bloqueo optimista con @Version).
  3. Construye la ficha de un pedido consultando en paralelo stock, cliente y envío con CompletableFuture y un timeout.

Está listo cuando

  • El test concurrente pasa siempre: exactamente 10 compras correctas y 40 rechazadas.
  • La ficha responde en el tiempo de la consulta más lenta, no en la suma de todas.

Katas: ejercicios cortos

Reglas de oro

  • volatile da visibilidad, no atomicidad.
  • Todo lock() lleva su unlock() en un finally.
  • Nada de colecciones normales compartidas entre hilos: ConcurrentHashMap y compañía.
  • Los CompletableFuture de I/O, siempre con un executor propio y un timeout.

Java 17 frente a Java 21

CaracterísticaJava 17 LTSJava 21 LTS
ExecutorService en try-with-resources No (shutdown y awaitTermination) Sí (AutoCloseable desde Java 19)
Virtual threads No Sí (newVirtualThreadPerTaskExecutor)
synchronized con virtual threads No aplica Puede causar pinning; mejor ReentrantLock
Se guarda automáticamente

Fuentes oficiales

Revisado el . Este día en Markdown