🧪 Problem-Driven Systems Lab

☕ Java#

Versión fijada: 21 (LTS) · Imagen base: eclipse-temurin:21-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 doce 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 thread se reutiliza entre requestsUn ThreadLocal sin limpiar arrastra el correlation ID al request siguiente. El caso 03 documenta ese riesgo en vez de esconderlo — 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 · ObservabilidadThreadLocal<RequestContext>Equivalente disponible hoy de ScopedValue, que en JDK 21 sigue en preview
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
ThreadLocal se filtra entre requestsUn thread reutilizado sin limpiar arrastra el correlation ID. ScopedValue lo resuelve, pero en 21 es previewcaso 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

Desafío abierto del stack en este laboratorio: el caso 03 está escrito sobre ThreadLocal porque ScopedValue sigue en preview en JDK 21. Cuando salga de preview, el caso pasa a enseñar la forma vieja de hacer las cosas. Es el disparador de revisión más concreto de todo el repositorio y está anotado explícitamente en scripts/language_drift.py.


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

  • 🥉 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 19ConcurrentSkipListMap.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 1851,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 05, 07, 09 y 12
  • 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 quedan a dos décimas y ganan exactamente el mismo caso. Son casi intercambiables en este laboratorio, y las diferencias reales aparecen en los detalles: AsyncLocal de .NET fluye por await mejor que ThreadLocal; LinkedHashMap de Java es la única LRU built-in del set.


🔄 Ciclo de versiones#

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

Qué revisar en el próximo salto:

  1. 🚨 ScopedValue fuera de preview (JDK 25) — el ThreadLocal del caso 03 pasa de "alternativa razonable" a "lo que ya no se hace". Hay que reescribir el caso y su comparison.md, no solo cambiar el FROM.
  2. Virtual threads (Loom) — estables desde 21. El caso 11 está construido sobre pools acotados de threads de plataforma; con virtual threads el argumento cambia de fondo. Vale la pena evaluar si el caso debería mostrar ambos modelos.
  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 ↗