Guía completa de concurrencia y rendimiento en Java para desarrolladores
Un repaso práctico y orientado a la producción sobre cómo escribir código concurrente eficiente en Java: conceptos centrales, primitivas, patrones, errores comunes y un ejemplo completo que puedes ejecutar y adaptar.
Conceptos clave (rápido)
- Happens-before: orden de visibilidad entre operaciones. Es la base para razonar sobre seguridad de memoria.
- Volatile: garantiza visibilidad y orden de lectura/escritura, no atomicidad para operaciones compuestas.
- Synchronized / Locks: exclusión mutua + establishes happens-before; cuidado con la contención.
- Atomics (AtomicInteger, AtomicReference): operaciones atómicas sin bloquear; utilísimos para contadores y flags.
- Colas concurrentes: ConcurrentLinkedQueue, LinkedBlockingQueue y SynchronousQueue para desacoplar productores/consumidores.
- Thread pools: nunca crees hilos por tu cuenta en producción; usa ExecutorService o ForkJoinPool según el tipo de tareas.
Primitivas y cuándo usarlas
- volatile: flags, estados simples donde sólo necesitas visibilidad. No lo uses para incrementar contadores.
- synchronized: lógica crítica que requiere exclusión simple. Fácil de razonar, pero puede bloquear y crear contención.
- ReentrantLock: más flexible: tryLock, condiciones, tiempo de espera.
- Atomic*: contadores, CAS loops, referencias sustitutas; mejores para alta concurrencia y baja latencia.
- Concurrent Collections: evita implementar estructuras concurrentes por tu cuenta; prueba ConcurrentHashMap para caches compartidos.
APIs de alto nivel
- ExecutorService (ThreadPoolExecutor): para tareas cortas/medias; configura corePoolSize, maxPoolSize, queue y RejectedExecutionHandler.
- ForkJoinPool: ideal para tareas recursivas, divide y vencerás; usa RecursiveTask/Action.
- CompletableFuture: programación asíncrona, encadenamiento, composición no bloqueante.
Patrones y tuning
- Tamaño del pool: para CPU-bound ≈ núcleos disponibles ±1; para IO-bound incrementar según la relación de espera/ejecución.
- Backpressure: usa colas con bounded capacity o semáforos para evitar OOM u overflow bajo carga.
- Batching: agrupa tareas pequeñas en lotes para reducir overhead de scheduling.
- Evita bloquear en hilos críticos del pool: si necesitas IO bloqueante, usa un pool separado o migración a un modelo reactivo.
Errores de rendimiento frecuentes
- Bloquear demasiado (synchronized en métodos calientes).
- Crear demasiados hilos (thrashing y GC adicional).
- Contención en estructuras compartidas (usar striping o ConcurrentHashMap).
- False sharing: evitar variables contiguas modificadas por hilos distintos; alinear o usar padding cuando sea crítico.
- No medir: las optimizaciones prematuras sin benchmark pueden empeorar el sistema.
Herramientas de profiling y benchmarking
- Java Flight Recorder (JFR)
- async-profiler
- jvisualvm / Mission Control
- JMH para microbenchmarks
Ejemplo práctico: pipeline paralelo con backpressure y composición
Proyecto minimal: procesar trabajos que hacen llamadas simuladas (latencia IO), limitando concurrencia y encadenando etapas con CompletableFuture.
Estructura de carpetas
/concurrency-guide-java
├─ pom.xml
└─ src/main/java/com/example/concurrency
├─ App.java
├─ Worker.java
└─ BoundedExecutor.java
pom.xml (dependencias mínimas)
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>concurrency-guide-java</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
</project>
BoundedExecutor.java
package com.example.concurrency;
import java.util.concurrent.*;
// Ejecuta tareas limitando concurrencia con un Semaphore (backpressure)
public class BoundedExecutor {
private final ExecutorService exec;
private final Semaphore semaphore;
public BoundedExecutor(ExecutorService exec, int maxConcurrent) {
this.exec = exec;
this.semaphore = new Semaphore(maxConcurrent);
}
public void submit(final Runnable task) throws InterruptedException {
semaphore.acquire();
try {
exec.submit(() -> {
try {
task.run();
} finally {
semaphore.release();
}
});
} catch (RejectedExecutionException e) {
semaphore.release();
throw e;
}
}
public void shutdown() {
exec.shutdown();
}
}
Worker.java
package com.example.concurrency;
import java.util.concurrent.ThreadLocalRandom;
public class Worker {
// Simula IO o trabajo costoso
public static String process(String input) {
try {
// latencia aleatoria entre 50-200ms
Thread.sleep(ThreadLocalRandom.current().nextInt(50, 200));
} catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
return input.toUpperCase();
}
}
App.java
package com.example.concurrency;
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class App {
public static void main(String[] args) throws Exception {
int items = 200;
int poolSize = Math.max(2, Runtime.getRuntime().availableProcessors());
int maxConcurrent = 50; // backpressure limit for submitted tasks
ExecutorService ioPool = Executors.newFixedThreadPool(poolSize * 2);
BoundedExecutor bounded = new BoundedExecutor(ioPool, maxConcurrent);
// Lista de inputs
List inputs = IntStream.range(0, items)
.mapToObj(i -> "item-" + i)
.collect(Collectors.toList());
// Procesamiento con CompletableFuture: no bloquea el hilo principal
List> futures = inputs.stream().map(input -> {
CompletableFuture cf = new CompletableFuture<>();
try {
bounded.submit(() -> {
try {
String result = Worker.process(input);
cf.complete(result);
} catch (Exception ex) {
cf.completeExceptionally(ex);
}
});
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
cf.completeExceptionally(e);
}
return cf;
}).collect(Collectors.toList());
// Componer: cuando todas completan, agrupar resultados
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList()))
.thenAccept(list -> {
System.out.println("Processed " + list.size() + " items. Sample: " + list.subList(0, Math.min(5, list.size())));
}).join();
bounded.shutdown();
}
}
Por qué esta arquitectura
- BoundedExecutor: protege el sistema ante ráfagas y evita acumular tareas indefinidamente en la memoria.
- ExecutorService separado: si tienes tareas CPU-bound y IO-bound, usa pools distintos para evitar bloquear hilos CPU.
- CompletableFuture: composición asíncrona que evita join/blocks explícitos y facilita encadenar transformaciones.
Cómo medir cambios de rendimiento
- Usa JMH para microbenchmarks de módulos calientes.
- Para integraciones, perfílalo con JFR o async-profiler bajo carga realista.
- Mantén métricas de producción: latencia 50/95/99, throughput, uso de hilos, GC y contención.
Checklist rápido antes de desplegar
- ¿No creas hilos ilimitados? Usa pools configurados.
- ¿Hay límites para entradas entrantes (bounded queues / semáforos)?
- ¿Se están usando estructuras concurrentes probadas (ConcurrentHashMap, queues)?
- ¿Has medido y perfilado en entorno cercano a producción?
- ¿Tienes circuit breakers/backpressure para servicios remotos?
Consejo avanzado: cuando la latencia es crítica y la contención domina, mide con async-profiler y busca hot-spots de sincronización; sustituir synchronized por algoritmos lock-free o usar sharding/striping puede reducir latencias drásticamente. Si migras a un modelo reactivo, separa claramente pools para evitar bloquear event loops.
Advertencia: no optimices prematuramente sin benchmarks; pequeñas mejoras sintácticas pueden esconder graves problemas bajo carga.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación