🧪 Problem-Driven Systems Lab

☕ Java#

Versión fijada: 25 (LTS) · Imagen base: eclipse-temurin:25-jdk-alpine · Hub: :8400 · Casos operativos: 20 / 20

⬅️ Volver a los perfiles de lenguaje · 🗺️ Mapa de stacks · 🔄 Protocolo de actualización


🪪 Identidad#

Java es un lenguaje de tipado estático que compila a bytecode y corre sobre la JVM, una máquina virtual con compilación JIT y recolección de basura generacional. Su rasgo definitorio no es el lenguaje sino la plataforma: treinta años de compatibilidad hacia atrás, un ecosistema de bibliotecas maduro y un runtime que optimiza el código mientras corre.

Para qué se usa en la industria: sistemas empresariales de larga vida, banca, seguros, telecomunicaciones, plataformas de datos (Hadoop, Kafka, Elasticsearch, Spark) y Android. Cuando un sistema tiene que seguir funcionando y siendo mantenible dentro de quince años, Java es la apuesta conservadora — y suele ser la correcta.

Por qué está en este laboratorio: porque tiene el catálogo de primitivas concurrentes más rico del set. java.util.concurrent no es una biblioteca externa: es la respuesta canónica a la mayoría de los problemas de este laboratorio, y en el caso 11 —donde el problema es el pool de threads— eso deja de ser ceremonia y pasa a ser la herramienta exacta.

Nota de precisión sobre el laboratorio: los veinte casos corren con javac Main.java + java Main, sin Maven, sin Gradle y sin frameworks. HttpServer viene en el JDK. La decisión es deliberada: hace visible qué parte de la solución es del lenguaje y qué parte sería del framework.


⚙️ Modelo de ejecución#

Threads del sistema operativo con paralelismo real, sobre una JVM con JIT y GC generacional.

ConsecuenciaDónde se nota
Paralelismo real sin GILDos threads ejecutan bytecode simultáneamente en dos núcleos. Python no puede; Java sí — caso 11
El pool es explícito y observablegetActiveCount() y getQueue().size() reportan saturación desde adentro, sin agente. Es el event_loop_lag del mundo JVM — caso 11
El contexto puede acotarse lexicalmenteScopedValue comparte el correlation ID sin estado mutable ni limpieza manual; el binding desaparece al salir del handler — caso 03
El JIT calientaLas primeras requests son más lentas que las siguientes. Cualquier medición debe descartar el arranquetransversal

🧰 Primitivas que usa el laboratorio#

CasoPrimitiva centralPor qué esta y no otra
01 · API lentaConcurrentHashMap + ScheduledExecutorService + LongAdderCache leída sin lock; worker con shutdown limpio en SIGTERM. LongAdder supera a synchronized bajo contención
02 · N+1PreparedStatement + try-with-resourcesCleanup garantizado incluso bajo excepción; plan cacheado por sqlite-jdbc
03 · ObservabilidadScopedValue<RequestContext>Contexto inmutable, acotado al handler y restaurado automaticamente; API final desde JDK 25
04 · TimeoutsCompletableFuture.orTimeout(Duration)Deadline a nivel future. Limitación documentada: completa el future a tiempo, pero el thread sigue dormido
05 · MemoriaLinkedHashMap con removeEldestEntryLa única LRU built-in del set completo. En Go, Rust y .NET hay que construirla a mano
06 · Pipelinerecord EnvState + ConcurrentHashMapSnapshots inmutables por ambiente, sin boilerplate de getters
07 · MonolitoConcurrentHashMap<String, Function<Request,Response>>La firma del handler es el contrato; registrar un módulo es una línea
08 · ExtracciónCopyOnWriteArrayList<Consumer<String>>EventBus thread-safe sin biblioteca externa. Limitación: los subscribers son síncronos
09 · Integración externaSemaphore + AtomicReferencetryAcquire() no bloquea. Permits explícitos = cuota explícita
10 · Sobre-arquitecturaHashMap.get + System.nanoTime()El "right-sized" del caso, con medición directa del CPU por request
11 · ReportesThreadPoolExecutor acotado + pool de reporting separadoEl modelo canónico del problema. Dos pools, telemetría directa de ambos
12 · Punto únicoOptional<T> + map/flatMap/orElseRunbook codificado. Limitación: .get() sin isPresent() compila igual
13 · Cache stampedeConcurrentHashMap.computeIfAbsentAtómico por clave: mirar si existe y crearlo son una sola operación indivisible
14 · Pool de conexionestry-with-resources sobre ArrayBlockingQueueEl compilador genera el finally; fugar exige no usarlo
15 · BackpressureArrayBlockingQueue + put/offerUn nombre para cada rechazo; ConcurrentLinkedQueue comparte interfaz y no tiene tope
16 · IdempotenciaConcurrentHashMap.putIfAbsentResuelve la carrera y dice quién ganó, en una sola llamada
17 · Migración sin downtimeReentrantReadWriteLock(true) + tryLock(timeout)El único con deadline y equidad de fábrica
18 · Arranque en fríocompilación en capas (C1/C2)51,9x medidos: el arranque en frío canónico, y el único que realimenta al autoescalador
19 · Deriva del índiceConcurrentSkipListMap.tailMapLa mejor expresión del outbox — y @Transactional, que engaña sobre su alcance
20 · DLQ olvidadajerarquía sealed ... permitsLa clasificación más expresiva del set — pero capturar desenrolla la pila

💡 El patrón que solo se ve mirando la columna entera: Java tiene una clase distinta para cada problema de concurrencia — Semaphore, CompletableFuture, ConcurrentHashMap, CopyOnWriteArrayList, ThreadPoolExecutor, AtomicReference, LongAdder. Es lo opuesto a Go, donde canal + select cubre casi todo. Más superficie que aprender; también más precisión cuando se conoce.


📈 Rendimiento: qué mide el laboratorio y cómo reproducirlo#

⚠️ Este repositorio no publica benchmarks entre lenguajes. Se mide la pendiente dentro de cada stack: legacy contra optimized, mismo runtime, misma máquina. En Java, además, hay que descartar el arranque: el JIT necesita tráfico antes de estabilizarse.

SeñalDe dónde saleQué caso la expone
avg_ms · p95_ms · p99_msLongAdder + muestras en memoria01, 02, 10
heap usado / total / máximoRuntime.getRuntime().totalMemory(), freeMemory(), maxMemory()05
threads activos del poolThreadPoolExecutor.getActiveCount()11
profundidad de colagetQueue().size()11
db_hits por requestcontador propio alrededor de JDBC01, 02

Reproducir la medición del caso 11 (saturación de pool):

bash
docker compose -f compose.java.yml up -d --build
curl -s localhost:8400/11/activity                     # pool en reposo
for i in $(seq 1 8); do curl -s "localhost:8400/11/report-legacy?rows=200000" & done; wait
curl -s localhost:8400/11/activity                     # activeCount saturado, queueSize creciendo
curl -s "localhost:8400/11/order-write"                # degraded: true — el trafico normal lo paga
curl -s "localhost:8400/11/report-isolated?rows=200000"  # corre en el pool dedicado
curl -s "localhost:8400/11/order-write"                # el pool principal quedo libre

Especificación de rendimiento que este stack verifica mejor que ningún otro: el pool acotado a 4 threads hace que la saturación sea predecible y observable. En Go no hay pool que saturar; en Node el bloqueo es del proceso entero; en Java se ve exactamente cuántos threads están ocupados y cuántas tareas esperan. Por eso el caso 11 es el único donde Java gana el primer puesto.


🚧 Límites, problemas sin solución y desafíos#

LímitePor qué importaDónde se ve
orTimeout no interrumpe el trabajoCompleta el future a tiempo, pero el thread proveedor sigue dormido. Parece que cortó, y no cortócaso 04
ScopedValue exige un alcance activoFuera del binding, get() falla; el contexto no debe delegarse a trabajo no estructurado que sobreviva al handlercaso 03
Optional.get() sin isPresent() compilaEl tipo expresa la ausencia; el compilador no la exige. En Rust, omitir el brazo None no compilacaso 12
El ecosistema fabrica N+1 soloHibernate y JPA con lazy loading generan el bug del caso 02 sin que nadie lo escriba. Por eso Java queda 6º ahícaso 02
Arranque lento y huella de memoria altaLa JVM tarda en calentar y reserva heap con generosidad. Relevante en funciones serverless o contenedores efímerostransversal
Subscribers síncronos en el EventBusCopyOnWriteArrayList es thread-safe pero notifica en línea: un subscriber lento frena al publicadorcaso 08

Revisión completada en JDK 25: el caso 03 ya usa ScopedValue; ThreadLocal queda solo como contraste histórico. El desafío abierto pasa al caso 11: virtual threads y concurrencia estructurada pueden cambiar cuándo un pool acotado de threads de plataforma sigue siendo el modelo correcto.


🏆 Dónde gana y dónde pierde en el laboratorio#

Agregado de los veredictos de las 19 comparativas que rankean: 2 primeros puestos, media 3.3.

  • 🥉 Tercero en 20 — la jerarquía sealed ... permits es la clasificación más expresiva del set y lo más cerca que llega a la exhaustividad de Rust; detrás de .NET porque clasificar obliga a capturar, y capturar acorta la pila que la DLQ necesita.
  • 🥉 Tercero en 20 — la jerarquía sealed ... permits es la clasificación más expresiva del set y lo más cerca que llega a la exhaustividad de Rust; detrás de .NET porque clasificar obliga a capturar, y capturar acorta la pila que la DLQ necesita.
  • 6º en 19 — ConcurrentSkipListMap.tailMap es la mejor expresión del outbox del set, y @Transactional el único elemento del lab que activamente sugiere una garantía que no da: un framework que engaña pesa más que una primitiva que ayuda.
  • 7º en 18 — 51,9x medidos de curva de calentamiento: el arranque en frío canónico, y el único stack donde la lentitud posterior a estar «listo» realimenta al autoescalador. Tiene las herramientas más potentes contra su propio problema (AppCDS, GraalVM) y ninguna viene activada.
  • 🥇 Gana en 11 y 17 — en el 17 por ser el único stack con deadline y equidad de fábrica en el mismo lock — cuando el problema es el pool de threads, tener pool explícito y observable es la herramienta exacta.
  • 🥈 Segundo en 01, 06, 13, 14 y 16 — paralelismo real, record types inmutables el computeIfAbsent atómico que elimina la ventana check-then-act, y try-with-resources, que hace que el compilador escriba el finally.
  • 🥉 Tercero en 03, 05, 07, 09 y 12 — en el 03, ScopedValue elimina la fuga entre requests sin limpieza manual.
  • 5º en 15 — le puso nombre a cada forma de rechazar, pero ConcurrentLinkedQueue comparte interfaz con ArrayBlockingQueue y sacar el freno es una línea que compila. — sólido, con las limitaciones documentadas arriba.
  • 6º en 02 — no por la API (JDBC es correcto), sino porque es uno de los dos ecosistemas donde el N+1 nace solo.

Lectura honesta: Java y .NET siguen muy cerca, pero JDK 25 corrige una asimetria concreta: ScopedValue ya ofrece alcance inmutable y restauracion automatica para el caso 03. AsyncLocal conserva la ventaja de fluir por await; LinkedHashMap de Java sigue siendo la unica LRU built-in del set.


🔄 Ciclo de versiones#

Versión fijada hoy25 LTS (eclipse-temurin:25-jdk-alpine)
Cadencia upstreamUna release cada 6 meses; una LTS cada 2 años
Política de soporteTemurin da soporte extendido a las LTS (25 hasta al menos septiembre de 2031)
Producto en endoflife.dateeclipse-temurin

Qué revisar en el próximo salto:

  1. Concurrencia estructurada — cuando deje de ser preview, revisar si el caso 11 debe contrastar pools acotados con tareas estructuradas sobre virtual threads.
  2. Virtual threads (Loom) — estables desde 21. El caso 11 sigue construido sobre pools acotados de threads de plataforma porque el problema que enseña es precisamente su saturacion; cualquier cambio debe conservar ese contraste.
  3. Cambios en el GC por defecto — afectan la lectura del caso 05.
  4. Pattern matching y sealed types — si maduran, el caso 06 podría acercarse a la exhaustividad que hoy solo tiene Rust.

El detalle del procedimiento está en docs/language-upgrade-protocol.md.


🚀 Levantar el stack#

bash
docker compose -f compose.java.yml up -d --build

Los 20 casos quedan servidos en http://localhost:8400/NN/. Cada caso trae además su propio compose.yml para correrlo aislado.

Ver esta carpeta en GitHub ↗