Guía completa de concurrencia y rendimiento en Java para desarrolladores

java

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

  1. Bloquear demasiado (synchronized en métodos calientes).
  2. Crear demasiados hilos (thrashing y GC adicional).
  3. Contención en estructuras compartidas (usar striping o ConcurrentHashMap).
  4. False sharing: evitar variables contiguas modificadas por hilos distintos; alinear o usar padding cuando sea crítico.
  5. 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión