# Día 17: Concurrencia Clásica y Java Memory Model

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

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 17 de 30
- Fase 4: Seguridad, Concurrencia y Reactividad vs Virtual Threads
- Dedicación: 4h
- Profundidad: para dominar
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/concurrencia-java-memory-model/

## Al terminar este día sabrás

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


## Conceptos

### 1. 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.

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

*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)));
}
```

- **En una línea:** `ExecutorService pool = Executors.newFixedThreadPool(8); Future<Informe> f = pool.submit(() -> generar(id));`
- **Por qué importa:** 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.
- **Error típico:** No cerrar el executor: sus hilos impiden que la JVM termine.
- **Java 17 frente a 21:** Desde Java 19, `ExecutorService` implementa `AutoCloseable`: en Java 21 puedes usarlo en un try-with-resources. En Java 17 hay que llamar a `shutdown()` y `awaitTermination()`.
- **Se conecta con:** Pools de hilos, Future, Virtual threads

### 2. Exclusión Mutua: synchronized y ReentrantLock

Cuando varios hilos modifican el mismo dato, `synchronized` garantiza que solo uno ejecuta la sección crítica a la vez. `ReentrantLock` hace lo mismo con más control: intentar con tiempo límite (`tryLock`), interrumpir la espera o usar varias condiciones.

**Cómo funciona por dentro.**

Una condición de carrera ocurre cuando el resultado depende del orden en que se intercalan varios hilos. El caso típico es leer, modificar y escribir: `saldo = saldo - importe` o `contador++` son varios pasos, y dos hilos pueden leer el mismo valor antes de que ninguno escriba. Una sección crítica protegida con un lock solo la ejecuta un hilo a la vez.

`synchronized` usa el monitor de un objeto: en un método de instancia, `this`; en un bloque, el objeto que indiques (mejor uno privado, para que nadie de fuera pueda bloquearlo). Es reentrante, se libera automáticamente al salir del bloque aunque haya una excepción y además garantiza visibilidad. `ReentrantLock` hace lo mismo de forma explícita y añade `tryLock` con tiempo límite, espera interrumpible, varias `Condition` y modo justo. A cambio, el `unlock()` es responsabilidad tuya y va siempre en un `finally`.

Un deadlock aparece cuando dos hilos esperan cada uno el lock del otro. Se previene tomando los locks siempre en el mismo orden, haciendo lo mínimo dentro de la sección crítica (nada de llamadas de red) y usando `tryLock` con timeout cuando no se puede garantizar el orden.

*Transferencia sin carreras ni deadlocks*

```java
public class Cuenta {
    private final long id;
    private final ReentrantLock lock = new ReentrantLock();
    private BigDecimal saldo;

    public Cuenta(long id, BigDecimal saldo) { this.id = id; this.saldo = saldo; }

    public static void transferir(Cuenta origen, Cuenta destino, BigDecimal importe) {
        // Orden global por id: dos transferencias cruzadas nunca se bloquean mutuamente
        Cuenta primera = origen.id < destino.id ? origen : destino;
        Cuenta segunda = primera == origen ? destino : origen;

        primera.lock.lock();
        try {
            segunda.lock.lock();
            try {
                if (origen.saldo.compareTo(importe) < 0) {
                    throw new SaldoInsuficienteException(origen.id);
                }
                origen.saldo = origen.saldo.subtract(importe);
                destino.saldo = destino.saldo.add(importe);
            } finally {
                segunda.lock.unlock();
            }
        } finally {
            primera.lock.unlock();
        }
    }
}
```

- **En una línea:** `lock.lock(); try { saldo -= importe; } finally { lock.unlock(); }`
- **Por qué importa:** Sin exclusión mutua, operaciones como `contador++` (leer, sumar y escribir) pierden actualizaciones: son condiciones de carrera que solo aparecen bajo carga.
- **Error típico:** No poner `unlock()` en un bloque `finally`: si salta una excepción, el lock queda tomado para siempre.
- **Java 17 frente a 21:** Igual en Java 17 y 21. En Java 21, un virtual thread que se bloquea dentro de `synchronized` queda anclado a su hilo de plataforma; con `ReentrantLock` no.
- **Se conecta con:** Condiciones de carrera, Deadlock, Pinning de virtual threads

### 3. El Java Memory Model: volatile y happens-before

Cada hilo puede trabajar con valores en caché y el compilador puede reordenar instrucciones. El Java Memory Model define cuándo un hilo ve lo que escribió otro: a través de relaciones happens-before, que crean `synchronized`, los locks, `volatile` y el arranque y final de los hilos.

**Cómo funciona por dentro.**

Los procesadores modernos tienen cachés por núcleo y el compilador JIT reordena instrucciones si el resultado es el mismo para un único hilo. Sin sincronización, nada garantiza que lo que escribe un hilo llegue a verlo otro, ni en qué orden. El Java Memory Model (JSR-133) define las reglas: una escritura es visible para una lectura si existe una relación happens-before entre ellas.

Las principales relaciones happens-before son: liberar un monitor o un lock y adquirirlo después; escribir un campo `volatile` y leerlo después; `Thread.start()` con todo lo que hace el hilo arrancado; todo lo que hace un hilo con quien hace `join()` sobre él; y el final del constructor con la lectura de los campos `final`. Además son transitivas: si A ocurre antes que B y B antes que C, A ocurre antes que C. Las clases de `java.util.concurrent` documentan las suyas, por ejemplo poner un elemento en una `BlockingQueue` y sacarlo.

`volatile` es la herramienta más ligera: cada lectura ve la última escritura y no se reordena con las operaciones de alrededor. Es perfecto para banderas (`parar`, `configuracionActual`) que escribe un hilo y leen otros. No sirve para operaciones compuestas como `contador++`, que necesitan un atómico o un lock.

*Bandera de parada: el bug de visibilidad y su arreglo*

```java
public class Trabajador implements Runnable {
    // Sin volatile, el JIT puede leer 'parar' una vez y convertir el bucle en while(true)
    private volatile boolean parar;

    @Override
    public void run() {
        while (!parar) {
            procesarSiguiente();
        }
        log.info("Trabajador detenido");
    }

    public void detener() {
        parar = true;              // escritura volatile: visible en la siguiente lectura del otro hilo
    }
}

var trabajador = new Trabajador();
var hilo = new Thread(trabajador);
hilo.start();                      // start() happens-before todo lo que hace el hilo
Thread.sleep(1000);
trabajador.detener();
hilo.join();                       // lo que hizo el hilo happens-before el retorno de join()
```

- **En una línea:** `private volatile boolean parar;` para que el hilo de trabajo vea la orden de parada que da otro hilo.
- **Por qué importa:** Explica bugs que «no pueden pasar»: un hilo que nunca ve que otro cambió una bandera, o un objeto que se ve a medio construir.
- **Error típico:** Creer que `volatile` hace atómico `contador++`: garantiza visibilidad, no atomicidad. Para eso, `AtomicInteger` o un lock.
- **Java 17 frente a 21:** El Java Memory Model es el mismo en Java 17 y 21.
- **Se conecta con:** Visibilidad, Reordenación, Double-checked locking

### 4. Atómicos y Colecciones Concurrentes

`AtomicInteger`, `AtomicLong` y `LongAdder` hacen operaciones atómicas sin locks (compare-and-set). `ConcurrentHashMap` permite leer y escribir a la vez desde varios hilos y ofrece operaciones compuestas atómicas como `merge` y `computeIfAbsent`.

**Cómo funciona por dentro.**

Las clases atómicas (`AtomicInteger`, `AtomicLong`, `AtomicReference`) usan instrucciones compare-and-set del procesador: leen el valor, calculan el nuevo y lo escriben solo si nadie lo cambió entretanto; si alguien lo hizo, reintentan. `incrementAndGet`, `updateAndGet` y `accumulateAndGet` empaquetan ese bucle. Con mucha contención todos los hilos compiten por la misma variable, y ahí `LongAdder` reparte la suma en varias celdas y es bastante más rápido para contadores muy escritos y poco leídos.

`ConcurrentHashMap` permite lecturas sin bloqueo y escrituras concurrentes en distintas partes del mapa. Lo importante es usar sus operaciones compuestas atómicas: `putIfAbsent`, `computeIfAbsent`, `compute` y `merge`. Combinar `get` y `put` por separado vuelve a crear una condición de carrera, aunque cada llamada sea segura.

Otras colecciones concurrentes útiles: `CopyOnWriteArrayList` para listas que se leen mucho y casi nunca cambian (listeners), y las `BlockingQueue` (`ArrayBlockingQueue`, `LinkedBlockingQueue`) para el patrón productor-consumidor, donde `put` espera si la cola está llena y `take` si está vacía.

*Contadores y caché sin locks explícitos*

```java
// Contador de visitas por página, seguro con muchos hilos
private final ConcurrentHashMap<String, LongAdder> visitas = new ConcurrentHashMap<>();

public void registrar(String pagina) {
    visitas.computeIfAbsent(pagina, p -> new LongAdder()).increment();   // atómico
}

public long total(String pagina) {
    var contador = visitas.get(pagina);
    return contador == null ? 0 : contador.sum();
}

// Caché perezosa: calcula una sola vez por clave aunque lleguen varios hilos a la vez
private final ConcurrentHashMap<String, Tarifa> tarifas = new ConcurrentHashMap<>();

public Tarifa tarifa(String codigo) {
    return tarifas.computeIfAbsent(codigo, repositorio::cargarTarifa);
}

// MAL: dos operaciones seguras no forman una operación segura
// if (!tarifas.containsKey(codigo)) tarifas.put(codigo, repositorio.cargarTarifa(codigo));
```

- **En una línea:** `visitas.merge(pagina, 1L, Long::sum);`
- **Por qué importa:** Son más rápidos y seguros que sincronizar a mano una colección normal. Un `HashMap` compartido entre hilos puede corromperse y perder datos.
- **Error típico:** Hacer `if (!map.containsKey(k)) map.put(k, v)` en un `ConcurrentHashMap`: cada operación es segura, pero las dos juntas no. Usa `putIfAbsent` o `computeIfAbsent`.
- **Java 17 frente a 21:** Igual en Java 17 y 21.
- **Se conecta con:** Compare-and-set, Contadores, Cachés en memoria

### 5. CompletableFuture: Componer Tareas Asíncronas

`CompletableFuture` representa un resultado que llegará más tarde y permite encadenar pasos (`thenApply`, `thenCompose`), combinar varios (`thenCombine`, `allOf`) y tratar errores (`exceptionally`, `handle`) sin bloquear.

**Cómo funciona por dentro.**

Un `CompletableFuture` es un `Future` que se puede completar desde fuera y, sobre todo, componer. `supplyAsync(tarea, executor)` arranca una tarea y devuelve el futuro al momento. A partir de ahí se encadenan transformaciones: `thenApply` para transformar el resultado, `thenCompose` cuando el siguiente paso devuelve otro futuro (evita `CompletableFuture<CompletableFuture<T>>`), `thenCombine` para unir dos resultados independientes y `allOf` o `anyOf` para esperar a varios.

Los errores viajan por la cadena: si un paso falla, los siguientes no se ejecutan y la excepción llega envuelta en `CompletionException`. `exceptionally` sustituye el error por un valor de respaldo y `handle` recibe el resultado o el error para decidir. Desde Java 9, `orTimeout` completa el futuro con `TimeoutException` si tarda demasiado, y `completeOnTimeout` lo completa con un valor por defecto.

Sin executor explícito, las tareas `Async` corren en el `ForkJoinPool.commonPool()`, que comparte toda la JVM (también los parallel streams) y está dimensionado para CPU. Bloquearlo con llamadas de red degrada todo lo demás, así que para I/O se pasa siempre un executor propio. En Java 21, si cada llamada corre en un virtual thread, muchas veces basta con código secuencial dentro de cada tarea y un `ExecutorService` de virtual threads.

*Agregar tres servicios en paralelo con tolerancia a fallos*

```java
public Ficha fichaDeProducto(long id) {
    var precio = CompletableFuture
        .supplyAsync(() -> precios.de(id), ioPool)
        .orTimeout(800, TimeUnit.MILLISECONDS);

    var stock = CompletableFuture
        .supplyAsync(() -> inventario.stock(id), ioPool)
        .completeOnTimeout(Stock.desconocido(), 800, TimeUnit.MILLISECONDS);    // valor por defecto

    var opiniones = CompletableFuture
        .supplyAsync(() -> resenas.resumen(id), ioPool)
        .orTimeout(800, TimeUnit.MILLISECONDS)
        .exceptionally(error -> {
            log.warn("Sin opiniones para {}", id, error);
            return Resumen.vacio();                                              // degradar con elegancia
        });

    return precio
        .thenCombine(stock, Oferta::new)
        .thenCombine(opiniones, Ficha::new)
        .join();                          // el precio es obligatorio: si falla, la excepción sube
}
```

- **En una línea:** `CompletableFuture.supplyAsync(() -> precios.de(id), pool).thenCombine(stock, Oferta::new).orTimeout(2, SECONDS);`
- **Por qué importa:** Permite lanzar en paralelo varias llamadas lentas, por ejemplo a tres servicios, y combinar los resultados: la latencia total pasa a ser la de la más lenta.
- **Error típico:** Usar el pool común por defecto (`supplyAsync` sin executor) para llamadas de red: lo comparte toda la JVM y se satura. Pasa tu propio executor.
- **Java 17 frente a 21:** Igual en Java 17 y 21. En Java 21, con virtual threads, muchas veces basta con código bloqueante sencillo en lugar de encadenar futuros.
- **Se conecta con:** Future, Programación reactiva, Timeouts


## Sesión de 90 minutos

### 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?
- **Entregable:** Programa que reproduce la condición de carrera y tres arreglos: `synchronized`, `AtomicLong` y `LongAdder`.

### 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`?
- **Entregable:** 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.

### 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?
- **Entregable:** Agregador que llama en paralelo a tres servicios simulados con distinta latencia y devuelve un resultado parcial si uno supera el timeout.

### 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.
- **Entregable:** Mapa de qué herramienta usar para cada problema: visibilidad, atomicidad, exclusión y composición.


## Explícalo desde cero

### Por qué contador++ no es seguro entre hilos

- **Qué 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.
- **Qué 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.
- **Ejemplo sencillo:** 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.
- **Señal de alerta:** Si un contador compartido da un resultado distinto cada vez que ejecutas el test.


## Preguntas

### Nivel 1 (Básico): ¿Qué diferencia hay entre `volatile` y `synchronized`?

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

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

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

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.

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

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

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.

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


## Reto del tutor

**Reto:** 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 está bien:** Detecta que las llamadas son independientes y pueden ir en paralelo.
- **Respuesta superficial:** Decir «usa hilos» o «hazlo asíncrono» sin más.
- **Lo que falta de fondo:** `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.
- **Respuesta senior:** 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.


## Katas

### Kata 17.1: Contador bajo carga

Lanza 10 tareas en un `ExecutorService` que incrementan un contador 100.000 veces cada una. Demuestra con un test que un `long` normal pierde actualizaciones y que `AtomicLong` y `LongAdder` llegan a 1.000.000. Mide cuál es más rápido.

### Kata 17.2: Agregador paralelo con timeout

Simula tres servicios que tardan 300 ms, 800 ms y 3 s. Combínalos con `CompletableFuture` y un executor propio, aplica `orTimeout(1, SECONDS)` a cada uno y devuelve los resultados disponibles marcando el que falló. El total debe tardar alrededor de un segundo.


## 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ística | Java 17 | Java 21 |
| --- | --- | --- |
| 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 |


## Proyecto guiado «Pedidos»: Stock sin vender de más

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

**Tareas:**

- Escribe un test que lance 50 compras simultáneas del mismo producto con stock 10 usando un `ExecutorService`.
- Corrige la reserva de stock para que el test pase (lock por producto, operación atómica o bloqueo optimista con `@Version`).
- 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.


## Fuentes oficiales

- [Especificación del lenguaje Java 21: hilos y cerrojos (Java Memory Model)](https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html)
- [Javadoc de java.util.concurrent](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/package-summary.html)
- [Tutorial de Java: concurrencia](https://docs.oracle.com/javase/tutorial/essential/concurrency/index.html)

