Esta es la última caja negra del curso.

Llevas diez módulos escribiendo código que funciona sin saber realmente dónde vive. Cuando el ExportadorAnotado de 10-03 cachea la introspección en un Map, ¿dónde está ese mapa y quién lo libera? Cuando un millón de hilos virtuales guardan su pila en el heap, ¿qué es el heap exactamente y qué pasa cuando se llena? Cuando el banco de pruebas de 10-04 dio números completamente distintos antes y después del calentamiento, ¿qué estaba haciendo la JVM en ese rato? Cuando WeakHashMap apareció de pasada, ¿qué significa que una referencia sea "débil"? ¿Por qué la primera petición a un servidor Java siempre es la más lenta? ¿Y por qué una aplicación que lleva tres días funcionando empieza de repente a pausarse cada pocos segundos?

Todas esas preguntas tienen la misma respuesta de fondo: la máquina virtual de Java gestiona la memoria por ti, y para escribir código serio hay que entender cómo lo hace.

No para optimizar micro-detalles —eso casi nunca sirve—, sino para tres cosas muy concretas: diagnosticar cuando algo va mal, medir en lugar de suponer, y tomar decisiones informadas sobre estructuras de datos y ciclos de vida de objetos.

Al terminar, la JVM habrá dejado de ser una caja negra: sabrás qué regiones de memoria existen y qué vive en cada una, cómo decide el recolector qué borrar, por qué las fugas de memoria existen en Java pese al recolector, qué herramientas usar para verlo con tus propios ojos, y por qué un microbenchmark casero miente. Y el módulo 10 quedará cerrado.

Contenido

  1. Las regiones de memoria de la JVM
  2. La pila: marcos, variables locales y StackOverflowError
  3. El heap: donde viven los objetos
  4. Metaspace, caché de código y memoria nativa
  5. OutOfMemoryError y sus distintos mensajes
  6. El modelo generacional
  7. Cómo decide el recolector qué borrar
  8. Pausas stop-the-world
  9. Los recolectores actuales
  10. Los tres parámetros que de verdad se tocan
  11. Fugas de memoria en Java: sí existen
  12. Los cuatro patrones clásicos de fuga
  13. Referencias débiles, blandas y fantasma
  14. WeakHashMap y la caché de fichas de BiblioTech
  15. finalize obsoleto y Cleaner
  16. Medir antes de optimizar
  17. Las herramientas del JDK
  18. Java Flight Recorder
  19. Por qué un microbenchmark casero miente
  20. JMH: medir de verdad
  21. El compilador JIT
  22. Buenas prácticas de rendimiento, por impacto
  23. String, interning y StringBuilder, medidos
  24. Errores Comunes y Consejos
  25. Ejercicios

  1. Las regiones de memoria de la JVM

La JVM divide la memoria del proceso en varias regiones con propósitos distintos. Algunas son por hilo y otras compartidas, y esa distinción explica muchos comportamientos.

graph TD
    subgraph "Proceso de la JVM"
        subgraph "Por HILO"
            P1["Pila (Stack)<br/>marcos de método<br/>variables locales<br/>~512 KB - 1 MB"]
            P2["Contador de programa<br/>Pila de métodos nativos"]
        end
        subgraph "COMPARTIDO"
            H["HEAP<br/>todos los objetos<br/>y sus campos<br/>-Xms / -Xmx"]
            M["Metaspace<br/>metadatos de clases<br/>memoria nativa"]
            C["Caché de código<br/>metodos compilados<br/>por el JIT"]
        end
        N["Memoria nativa<br/>ByteBuffer directos<br/>bibliotecas JNI<br/>pilas de los hilos"]
    end
Región Ámbito Qué contiene Se libera
Pila Por hilo Marcos de método: variables locales, parámetros, referencias Automáticamente al volver del método
Heap Compartido Todos los objetos y sus campos de instancia Por el recolector de basura
Metaspace Compartido Metadatos de clases, campos static, pool de constantes Al descargarse el cargador de clases
Caché de código Compartido Código máquina generado por el JIT Al desoptimizar o descargar
Memoria nativa Proceso Buffers directos, JNI, pilas de hilos Manualmente o al morir el objeto

La distinción fundamental, y la que hay que fijar:

public void ejemplo() {
    int contador = 42;                              // el VALOR está en la pila
    Libro libro = new Libro("978-0000000001", "Java Efectivo");
    //   ^ la REFERENCIA está en la pila       ^ el OBJETO está en el heap
}

Los primitivos locales viven en la pila. Los objetos viven siempre en el heap; lo que está en la pila es la referencia que apunta a ellos (típicamente 4 u 8 bytes). Y los campos de un objeto viven donde vive el objeto: en el heap, aunque sean primitivos.

Ver cuánta memoria hay:

public class MemoriaDisponible {

    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();

        System.out.printf("Máxima  (-Xmx):  %,d MB%n", rt.maxMemory()   / 1024 / 1024);
        System.out.printf("Total reservada: %,d MB%n", rt.totalMemory() / 1024 / 1024);
        System.out.printf("Libre en total:  %,d MB%n", rt.freeMemory()  / 1024 / 1024);
        System.out.printf("En uso:          %,d MB%n",
                (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024);
        System.out.printf("Procesadores:    %d%n", rt.availableProcessors());
    }
}
Máxima  (-Xmx):  4.096 MB
Total reservada: 258 MB
Libre en total:  196 MB
En uso:          62 MB
Procesadores:    8

Ojo con la interpretación: maxMemory() y totalMemory() se refieren solo al heap, no a la memoria total del proceso. Un proceso Java con -Xmx4g puede consumir 5 o 6 GB de memoria del sistema, porque el metaspace, la caché de código, las pilas de los hilos y los buffers directos están fuera del heap. Esta es la causa número uno de que un contenedor con límite de memoria mate a un proceso Java que "cabía".

  1. La pila: marcos, variables locales y StackOverflowError

Cada hilo tiene su propia pila. Cada llamada a método empuja un marco (stack frame) que contiene:

  • Los parámetros del método.
  • Las variables locales.
  • La pila de operandos (donde la JVM hace los cálculos).
  • La dirección de retorno.

Al volver del método, el marco se descarta completo, de golpe y gratis. No hay recolector implicado: es un simple decremento del puntero de pila. Por eso las variables locales primitivas son la memoria más barata de Java.

public class Marcos {

    public static void main(String[] args) {    // marco 1
        int a = 10;
        primero(a);
    }

    static void primero(int x) {                // marco 2
        String texto = "BiblioTech";
        segundo(x * 2, texto);
    }

    static void segundo(int y, String t) {      // marco 3
        System.out.println(y + " " + t);
    }                                            // marco 3 se descarta
}

Cuando la pila se llena:

public class DesbordamientoDePila {

    private static int profundidad = 0;

    static void recursionInfinita() {
        profundidad++;
        recursionInfinita();                    // sin caso base
    }

    public static void main(String[] args) {
        try {
            recursionInfinita();
        } catch (StackOverflowError e) {
            System.out.println("StackOverflowError a los " + profundidad + " marcos");
        }
    }
}
StackOverflowError a los 21847 marcos

Puntos que conviene fijar:

StackOverflowError es un Error, no una Exception. Retomando 06-01: los Error indican problemas de los que normalmente no se puede recuperar y no deben capturarse en código normal. Aquí lo hacemos solo para demostrar.

La profundidad depende del tamaño de la pila y del tamaño de cada marco. Un método con veinte variables locales ocupa marcos más grandes y desborda antes. Por eso el número varía entre ejecuciones y entre máquinas.

Se ajusta con -Xss:

java -Xss1m MiPrograma      # 1 MB por hilo (típico por defecto en 64 bits)
java -Xss16m MiPrograma     # para recursión profunda legítima
java -Xss256k MiPrograma    # para tener MUCHOS hilos

Y aquí está la conexión con 10-06: la pila es exactamente el motivo por el que los hilos de plataforma son caros. Con -Xss1m, diez mil hilos reservan diez gigabytes de espacio de direcciones. Los hilos virtuales guardan su pila en el heap, crece bajo demanda y por eso caben millones.

Y con Thread.ofVirtual(), -Xss no aplica: la pila de un hilo virtual empieza con unos pocos cientos de bytes y crece según haga falta.

  1. El heap: donde viven los objetos

El heap es la región compartida donde vive todo objeto creado con new, todo array, toda cadena.

Libro libro = new Libro("978-0000000001", "Java Efectivo", "Joshua Bloch", 412, 4.85);

¿Cuánto ocupa ese objeto? Con la JVM HotSpot de 64 bits y punteros comprimidos (por defecto con heaps menores de 32 GB):

Componente Bytes
Cabecera del objeto (mark word) 8
Puntero a la clase (comprimido) 4
String isbn (referencia) 4
String titulo (referencia) 4
String autor (referencia) 4
int paginas 4
double valoracion 8
Relleno hasta múltiplo de 8 4
Total del objeto Libro 40

Y eso sin contar las cadenas, que son objetos aparte en el heap: un String de 13 caracteres ocupa unos 56 bytes (24 de cabecera del String más un byte[] de 32).

Consecuencias prácticas:

  • Todo objeto tiene una sobrecarga de 12-16 bytes. Un Integer para guardar un número de 4 bytes ocupa 16. Por eso IntStream frente a Stream<Integer> importa (10-04), y por eso int[] frente a List<Integer> importa.
  • Los objetos pequeños y numerosos son caros. Un millón de Integer son 16 MB más el array de referencias; un int[] de un millón son 4 MB.
  • Los punteros comprimidos ahorran mucho. Con heaps mayores de 32 GB, las referencias pasan de 4 a 8 bytes y el consumo total sube alrededor de un 20 %. Es un argumento real para mantener el heap por debajo de 32 GB.

Medirlo:

java -XX:+PrintFlagsFinal -version | grep -i UseCompressedOops
bool UseCompressedOops = true    {lp64_product} {default}

  1. Metaspace, caché de código y memoria nativa

Metaspace

Guarda los metadatos de las clases: su estructura, sus métodos, el pool de constantes y los campos static.

Antes de Java 8 esto vivía en la PermGen, una región dentro del heap y de tamaño fijo, cuyo desbordamiento (OutOfMemoryError: PermGen space) era el terror de los servidores de aplicaciones que redesplegaban en caliente. Java 8 la sustituyó por el metaspace, que está en memoria nativa y crece dinámicamente.

java -XX:MaxMetaspaceSize=256m MiPrograma

Cuándo se llena: aplicaciones que generan clases dinámicamente y no las liberan. Los proxies dinámicos de 10-03, los frameworks que generan bytecode (Spring con CGLIB, Hibernate), y los redespliegues en caliente que dejan cargadores de clases vivos.

Caché de código

Guarda el código máquina que el compilador JIT genera a partir del bytecode (apartado 21). Si se llena, la JVM deja de compilar y vuelve a interpretar, con una caída de rendimiento drástica:

Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
    Compiler has been disabled.
java -XX:ReservedCodeCacheSize=512m MiPrograma

Es raro, pero ocurre en aplicaciones enormes con mucho código caliente.

Memoria nativa

Fuera del control del recolector:

  • Pilas de los hilos (-Xss × número de hilos).
  • ByteBuffer directos (ByteBuffer.allocateDirect), que viste en 07-03 con NIO.
  • Bibliotecas nativas vía JNI o la API de funciones foráneas de Java 22.
  • Estructuras internas de la JVM y del GC.
// Buffer DIRECTO: la memoria NO está en el heap
ByteBuffer directo = ByteBuffer.allocateDirect(64 * 1024 * 1024);   // 64 MB nativos

// Buffer normal: SÍ está en el heap
ByteBuffer enHeap = ByteBuffer.allocate(64 * 1024 * 1024);

Los directos evitan una copia al hacer E/S, pero su liberación depende del recolector: la memoria nativa solo se libera cuando el objeto ByteBuffer que la referencia se recolecta. Se acotan con:

java -XX:MaxDirectMemorySize=512m MiPrograma

La fórmula del consumo real de un proceso Java:

Memoria total ≈ Heap (-Xmx)
              + Metaspace
              + Caché de código
              + (Número de hilos × -Xss)
              + Buffers directos
              + Estructuras internas de la JVM (~100-300 MB)

Por eso -Xmx2g en un contenedor con límite de 2 GB provoca que el sistema mate el proceso (OOMKilled), aunque el heap nunca se llene. Regla práctica: el -Xmx no debería pasar del 60-75 % del límite del contenedor. Desde Java 10, la JVM detecta los límites de los cgroups y ajusta el heap por defecto, lo que ayuda mucho:

java -XX:MaxRAMPercentage=70 -jar bibliotech.jar

  1. OutOfMemoryError y sus distintos mensajes

Un OutOfMemoryError no es un solo problema: el mensaje concreto dice qué región se agotó, y cada una tiene una causa y un remedio distintos.

Mensaje Región Causa típica Remedio
Java heap space Heap Fuga de memoria, o heap demasiado pequeño para la carga Analizar volcado; subir -Xmx
GC overhead limit exceeded Heap Se pasa más del 98 % del tiempo recolectando y se recupera menos del 2 % Casi siempre una fuga
Metaspace Metaspace Clases generadas dinámicamente que no se liberan Revisar cargadores; -XX:MaxMetaspaceSize
Requested array size exceeds VM limit Heap Array de más de ~2.147.483.645 elementos Repensar la estructura
unable to create native thread Nativa Demasiados hilos, o pilas demasiado grandes Reducir hilos, bajar -Xss, usar hilos virtuales
Direct buffer memory Nativa Buffers directos no liberados -XX:MaxDirectMemorySize; revisar ciclos de vida
Compressed class space Metaspace Demasiadas clases con punteros comprimidos -XX:CompressedClassSpaceSize
package com.nexussoftware.bibliotech.diagnostico;

import java.util.ArrayList;
import java.util.List;

public class ProvocarOutOfMemory {

    /** Ejecutar con: java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError */
    public static void main(String[] args) {

        List<byte[]> retenidos = new ArrayList<>();
        int bloques = 0;

        try {
            while (true) {
                retenidos.add(new byte[1024 * 1024]);      // 1 MB cada vez
                bloques++;
            }
        } catch (OutOfMemoryError e) {
            // Capturar OOM solo para DIAGNOSTICAR y salir. Nunca para continuar.
            System.err.println("OutOfMemoryError tras " + bloques + " MB");
            System.err.println("Mensaje: " + e.getMessage());
            retenidos.clear();                              // liberar para poder imprimir
            System.exit(1);
        }
    }
}
OutOfMemoryError tras 61 MB
Mensaje: Java heap space

Opciones imprescindibles en producción:

java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/bibliotech/heapdumps/ \
     -XX:+ExitOnOutOfMemoryError \
     -jar bibliotech.jar
  • HeapDumpOnOutOfMemoryError: genera un volcado del heap justo al fallar. Sin él, el incidente se pierde y solo queda esperar a que se repita.
  • ExitOnOutOfMemoryError: mata el proceso. Suena drástico y es lo correcto: una JVM que ha sufrido un OutOfMemoryError está en estado indeterminado —algún hilo murió a medio hacer algo— y es mejor que el orquestador la reinicie que dejarla funcionando mal.

Nunca captures OutOfMemoryError para continuar. Capturarlo para registrar un mensaje y salir es defendible; capturarlo y seguir es garantizar corrupción de datos.

  1. El modelo generacional

Aquí empieza el recolector de basura propiamente dicho.

El diseño de los recolectores de Java se apoya en una observación empírica llamada hipótesis generacional débil:

La inmensa mayoría de los objetos mueren muy jóvenes.

Y es cierta de forma abrumadora: en una aplicación típica, más del 90 % de los objetos se vuelven inalcanzables casi inmediatamente después de crearse. Piensa en el EstadisticasBiblioTech de 10-04: cada Ficha intermedia de un stream, cada StringBuilder temporal, cada Optional, cada objeto de un map — todos mueren en microsegundos.

Si la mayoría muere joven, ¿por qué examinar toda la memoria en cada recolección? De ahí el modelo generacional:

graph LR
    subgraph "GENERACION JOVEN"
        E["Edén<br/>aquí nacen<br/>TODOS los objetos"]
        S0["Superviviente 0"]
        S1["Superviviente 1"]
    end
    subgraph "GENERACION VIEJA"
        O["Old / Tenured<br/>objetos que han<br/>sobrevivido a varios GC"]
    end
    E -->|"GC menor:<br/>los vivos se copian"| S0
    S0 -->|"siguiente GC menor"| S1
    S1 -->|"siguiente GC menor"| S0
    S1 -->|"tras N supervivencias:<br/>PROMOCION"| O
    E -->|"objetos enormes"| O

El ciclo, paso a paso:

  1. Todo objeto nuevo nace en el edén. Reservar memoria ahí es casi gratis: un incremento de puntero (bump the pointer), del orden de nanosegundos.
  2. Cuando el edén se llena, ocurre un GC menor (minor GC): se identifican los objetos vivos del edén y del superviviente activo, y se copian al otro superviviente. El edén y el superviviente de origen quedan completamente vacíos de golpe.
  3. Cada supervivencia incrementa la "edad" del objeto. Al superar un umbral (-XX:MaxTenuringThreshold, típicamente 15), se promociona a la generación vieja.
  4. Cuando la generación vieja se llena, ocurre un GC mayor (major o full GC), mucho más costoso.

La clave está en el paso 2, y es contraintuitiva: el coste de un GC menor es proporcional al número de objetos vivos, no al de objetos muertos. Si de un edén de 500 MB sobreviven 2 MB, el GC copia 2 MB y descarta los 498 restantes sin tocarlos. Los objetos que mueren jóvenes son, literalmente, gratis de recolectar.

GC menor GC mayor / completo
Región Generación joven Vieja (o todo el heap)
Frecuencia Alta (segundos) Baja (minutos u horas)
Duración Milisegundos Décimas de segundo o segundos
Coste proporcional a Objetos vivos Tamaño de la región
¿Preocupa? Normalmente no : son las pausas visibles

Verlo en directo:

java -Xlog:gc -Xmx256m -jar bibliotech.jar
[0.412s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 51M->8M(256M) 6.412ms
[1.238s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 59M->12M(256M) 5.891ms
[2.104s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 63M->14M(256M) 7.203ms
[8.771s][info][gc] GC(9) Pause Full (System.gc()) 198M->31M(256M) 187.442ms

Cómo leerlo: 51M->8M(256M) significa que antes del GC se usaban 51 MB, después 8 MB, con un heap total de 256 MB. Se liberaron 43 MB en 6,4 ms. La última línea es un GC completo: 187 ms de pausa, treinta veces más.

Y lo que esto enseña sobre cómo escribir código: crear objetos temporales de vida corta no es caro. La optimización de "reutilizar objetos para no crear basura" es, casi siempre, contraproducente: convierte objetos baratos de la generación joven en objetos de la generación vieja que sí cuestan recolectar.

  1. Cómo decide el recolector qué borrar

Una idea muy extendida y falsa: que Java cuenta referencias y borra un objeto cuando llega a cero. No es así, y entender por qué importa.

El recolector usa alcanzabilidad desde las raíces (reachability from GC roots).

Las raíces del GC son los puntos de partida, y son:

  • Las variables locales de todos los marcos de todas las pilas de todos los hilos.
  • Los campos static de todas las clases cargadas.
  • Las referencias desde código nativo (JNI).
  • Los hilos vivos en sí mismos.
  • Los monitores que se estén usando para sincronización.
  • Ciertas referencias internas de la JVM.

El algoritmo, conceptualmente:

  1. Partir de las raíces.
  2. Marcar cada objeto alcanzable siguiendo todas sus referencias, recursivamente.
  3. Todo lo no marcado es basura, sea cual sea el número de referencias que tenga.
graph TD
    R1["RAIZ: variable local<br/>en main()"] --> A["Repositorio"]
    R2["RAIZ: campo static<br/>Configuracion.INSTANCIA"] --> B["Configuracion"]
    A --> C["HashMap interno"]
    C --> D["Libro 1"]
    C --> E["Libro 2"]
    F["Prestamo antiguo"] --> G["Libro 3"]
    G --> F
    H["Ficha huérfana"]
    F -.->|"NO alcanzable<br/>desde ninguna raiz"| I["BASURA"]
    G -.-> I
    H -.-> I

Por qué los ciclos no importan. En el diagrama, Prestamo antiguo apunta a Libro 3 y Libro 3 apunta de vuelta. Con conteo de referencias, cada uno tendría una referencia y nunca se liberarían: fuga garantizada. Con alcanzabilidad, ninguno de los dos se alcanza desde ninguna raíz, así que los dos son basura y se recolectan.

Esto es una ventaja real de Java frente a lenguajes con conteo de referencias (Python parcialmente, Swift, Objective-C), donde los ciclos exigen referencias débiles explícitas para evitar fugas.

La consecuencia práctica más importante: un objeto se libera cuando deja de ser alcanzable, no cuando "ya no lo necesitas". Y de ahí salen todas las fugas del apartado 11: basta con que una sola raíz mantenga viva la cadena para que el objeto —y todo lo que él referencia— siga ocupando memoria.

System.gc(), de paso: es una sugerencia, no una orden. La JVM puede ignorarla, y llamarla suele empeorar las cosas porque fuerza un GC completo con su pausa larga. En producción se desactiva:

java -XX:+DisableExplicitGC -jar bibliotech.jar

Solo tiene sentido en diagnóstico, para comprobar si la memoria retenida se libera de verdad (lo verás en el ejercicio 1).

  1. Pausas stop-the-world

Para poder recorrer el grafo de objetos de forma coherente, el recolector necesita en algún momento que el grafo no cambie. Eso implica detener todos los hilos de la aplicación: una pausa stop-the-world.

graph LR
    A["Hilos de la aplicacion<br/>ejecutando"] --> B["Punto seguro<br/>safepoint"]
    B --> C["TODOS los hilos<br/>DETENIDOS"]
    C --> D["El GC trabaja"]
    D --> E["Hilos reanudados"]
    E --> A

Los hilos no se detienen en cualquier punto: la JVM espera a que cada uno llegue a un punto seguro (safepoint), donde su estado es consistente y describible. Hay puntos seguros al volver de un método, en los saltos hacia atrás de los bucles y en las llamadas bloqueantes.

Y de ahí sale un problema real y difícil de diagnosticar: un bucle muy apretado sin puntos seguros puede retrasar la pausa entera. Todos los demás hilos ya están parados esperando a ese. Se llama time to safepoint y se diagnostica así:

java -Xlog:safepoint -jar bibliotech.jar
[3.201s][info][safepoint] Safepoint "G1CollectForAllocation", Time since last: 812 ms,
    Reaching safepoint: 47 ms, At safepoint: 8 ms, Total: 55 ms

Reaching safepoint: 47 ms es tiempo perdido antes de que empiece el GC, porque algún hilo tardó en llegar. Si ese número es alto, el problema no es el recolector.

Toda la evolución de los recolectores de los últimos quince años se resume en una frase: reducir el tiempo de pausa. Los modernos hacen la mayor parte del trabajo concurrentemente con la aplicación, dejando pausas cortas solo para los pasos que lo exigen.

Las dos métricas en tensión:

Métrica Qué mide Qué la favorece
Rendimiento (throughput) Porcentaje de tiempo dedicado a la aplicación GC menos frecuente pero con pausas largas
Latencia Duración de la pausa más larga GC concurrente con pausas cortas, a costa de más CPU

Un proceso por lotes nocturno prefiere rendimiento; una API que promete responder en menos de 100 ms prefiere latencia. No se pueden maximizar las dos.

  1. Los recolectores actuales

Recolector Activación Pausas Rendimiento Heap típico Caso de uso
Serial -XX:+UseSerialGC Largas Bueno en heaps pequeños < 100 MB Contenedores diminutos, un solo núcleo, CLI
Parallel -XX:+UseParallelGC Largas (paralelas) El mejor < 4 GB Procesos por lotes donde la pausa no importa
G1 -XX:+UseG1GC (por defecto) Medias, predecibles Muy bueno 4 GB - 32 GB Aplicaciones de servidor: la elección por defecto
ZGC -XX:+UseZGC < 1 ms Bueno 8 GB - 16 TB Latencia crítica: trading, tiempo real
Shenandoah -XX:+UseShenandoahGC < 10 ms Bueno Cualquiera Latencia crítica, alternativa a ZGC
Epsilon -XX:+UseEpsilonGC Ninguna: no recolecta Solo pruebas: mide la asignación real

G1 (Garbage-First), el de por defecto desde Java 9, divide el heap en regiones de tamaño fijo (1-32 MB) que pueden ser edén, superviviente o vieja de forma dinámica. En cada ciclo recolecta primero las regiones con más basura —de ahí el nombre— y así maximiza la memoria liberada por unidad de pausa. Se le pide un objetivo de pausa:

java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Xmx8g -jar bibliotech.jar

Es un objetivo, no una garantía. G1 ajusta el tamaño de la generación joven para intentar cumplirlo; pedir 10 ms con un heap de 32 GB simplemente hará que recolecte constantemente.

ZGC y Shenandoah son recolectores concurrentes que hacen casi todo su trabajo mientras la aplicación se ejecuta, con pausas por debajo del milisegundo independientes del tamaño del heap. El precio es más consumo de CPU y algo más de memoria. Desde Java 21, ZGC tiene modo generacional, que mejora mucho su rendimiento:

java -XX:+UseZGC -XX:+ZGenerational -Xmx16g -jar bibliotech.jar

Epsilon es un recolector que no recolecta nada: cuando el heap se llena, la JVM muere. Suena inútil y tiene dos usos legítimos: medir exactamente cuánta memoria asigna un test (si asigna más de lo previsto, falla), y ejecutar procesos de vida ultracorta donde recolectar no compensa.

java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xmx1g PruebaDeAsignacion

La recomendación práctica:

graph TD
    A["¿Qué recolector?"] --> B{"¿Heap < 100 MB<br/>o 1 núcleo?"}
    B -->|"si"| C["Serial"]
    B -->|"no"| D{"¿Importan las<br/>pausas?"}
    D -->|"no: proceso por lotes"| E["Parallel"]
    D -->|"si"| F{"¿Pausas < 10 ms<br/>imprescindibles?"}
    F -->|"no"| G["G1 (por defecto)"]
    F -->|"si"| H["ZGC o Shenandoah"]

Y el consejo más importante: no cambies de recolector sin medir. El 95 % de las aplicaciones funcionan perfectamente con G1 por defecto. Cambiar a ZGC "porque es más moderno" sin un problema de latencia medido suele empeorar el rendimiento global.

  1. Los tres parámetros que de verdad se tocan

La JVM tiene más de mil opciones de ajuste:

java -XX:+PrintFlagsFinal -version | wc -l
1247

De esas mil, en la práctica se tocan tres.

  1. -Xmx: tamaño máximo del heap

java -Xmx4g -jar bibliotech.jar

El más importante con diferencia. Demasiado pequeño y sufres GC constantes o OutOfMemoryError; demasiado grande y las pausas se alargan y el sistema operativo empieza a paginar.

Regla para contenedores: entre el 60 % y el 75 % del límite de memoria, dejando el resto para metaspace, pilas, buffers y estructuras de la JVM. O mejor, dejar que la JVM lo calcule:

java -XX:MaxRAMPercentage=70 -jar bibliotech.jar

Y el límite de los 32 GB: por encima de ese tamaño se desactivan los punteros comprimidos y el consumo sube alrededor de un 20 %. Un heap de 31 GB puede almacenar más objetos que uno de 33 GB.

  1. -Xms: tamaño inicial del heap

java -Xms4g -Xmx4g -jar bibliotech.jar

En servidores, ponlo igual que -Xmx. El heap arranca ya con su tamaño final y se evita que la JVM lo vaya ampliando poco a poco durante los primeros minutos, con GC y reajustes innecesarios. En herramientas de línea de órdenes de vida corta, un -Xms pequeño hace que arranquen más rápido.

  1. La elección del recolector

java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 ...

Con el criterio del apartado anterior, y solo tras medir.

Y las opciones de diagnóstico, que sí conviene poner siempre

java -Xms2g -Xmx2g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/bibliotech/ \
     -XX:+ExitOnOutOfMemoryError \
     -Xlog:gc*:file=/var/log/bibliotech/gc.log:time,uptime:filecount=5,filesize=10M \
     -jar bibliotech.jar

Estas no cambian el rendimiento: hacen que, cuando algo falle, tengas la información para diagnosticarlo. Es la diferencia entre resolver un incidente en una hora y no resolverlo nunca.

Lo que NO hay que hacer: copiar de internet una lista de veinte opciones de ajuste. Cada opción interactúa con las demás, muchas están obsoletas o eliminadas, y el conjunto suele ser peor que los valores por defecto —que llevan veinte años ajustándose con datos reales de miles de aplicaciones.

  1. Fugas de memoria en Java: sí existen

Existe la creencia de que Java, al tener recolector de basura, no puede tener fugas de memoria. Es falsa.

Una fuga en Java tiene una definición precisa:

Objetos que siguen siendo alcanzables desde una raíz del GC, pero que el programa ya no va a usar nunca.

El recolector hace su trabajo perfectamente: no puede borrarlos porque son alcanzables. El error está en el código, que mantiene una referencia que ya no debería mantener.

El síntoma característico, que conviene reconocer:

[3600.1s] GC(1201) Pause Young 892M->741M(1024M) 41.2ms
[3612.4s] GC(1202) Pause Young 901M->768M(1024M) 44.8ms
[3625.9s] GC(1203) Pause Young 918M->794M(1024M) 48.1ms
[3641.2s] GC(1204) Pause Full  1010M->961M(1024M) 892.3ms
[3644.7s] GC(1205) Pause Full  1014M->978M(1024M) 941.7ms
[3648.1s] GC(1206) Pause Full  1020M->1009M(1024M) 1102.4ms
java.lang.OutOfMemoryError: GC overhead limit exceeded

Lee la evolución del segundo número: después de cada GC quedan 741, 768, 794 MB... La memoria retenida crece monótonamente. Al final, el recolector pasa más tiempo trabajando que la aplicación y llega el GC overhead limit exceeded.

Ese patrón —memoria tras el GC que sube en escalera— es la firma de una fuga, y es lo primero que hay que mirar en un log de GC.

  1. Los cuatro patrones clásicos de fuga

Fuga 1: colección que crece y nadie vacía

El más frecuente con diferencia.

package com.nexussoftware.bibliotech.servicio;

import java.util.*;

/**
 * FUGA: la cache crece indefinidamente.
 */
public class CacheConFuga {

    // static: es una RAIZ DEL GC. Todo lo que cuelgue de aqui vive
    // mientras viva la clase, es decir, para siempre.
    private static final Map<String, Ficha> CACHE = new HashMap<>();

    public Ficha obtener(String isbn) {
        return CACHE.computeIfAbsent(isbn, this::calcularFicha);
    }
    // Nunca se elimina nada. Con un millon de ISBN distintos,
    // un millon de fichas retenidas para siempre.
}
/** CORRECTO: cache ACOTADA con expulsion LRU (10-01). */
public class CacheAcotada {

    private static final int CAPACIDAD = 10_000;

    private final Map<String, Ficha> cache =
            Collections.synchronizedMap(new LinkedHashMap<>(CAPACIDAD, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<String, Ficha> masAntigua) {
                    return size() > CAPACIDAD;
                }
            });

    public Ficha obtener(String isbn) {
        return cache.computeIfAbsent(isbn, this::calcularFicha);
    }
}

Toda caché necesita una política de expulsión. Por tamaño, por tiempo o por referencias débiles (apartado 13). Una caché sin política de expulsión no es una caché: es una fuga con buenas intenciones.

Fuga 2: listeners no dados de baja

package com.nexussoftware.bibliotech.presentacion;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public class GestorDeEventos {

    private static final List<Consumer<String>> SUSCRIPTORES = new ArrayList<>();

    public static void suscribir(Consumer<String> suscriptor) {
        SUSCRIPTORES.add(suscriptor);
    }

    public static void publicar(String evento) {
        SUSCRIPTORES.forEach(s -> s.accept(evento));
    }
}
/** FUGA: se suscribe y nunca se da de baja. */
public class VentanaPrestamos {

    private final List<Prestamo> prestamos = new ArrayList<>(10_000);   // objeto pesado

    public VentanaPrestamos() {
        // La LAMBDA captura 'this' implicitamente al usar un campo.
        // GestorDeEventos.SUSCRIPTORES (static) retiene la lambda,
        // la lambda retiene esta VentanaPrestamos,
        // y esta ventana retiene sus 10.000 prestamos. PARA SIEMPRE.
        GestorDeEventos.suscribir(evento -> actualizar(evento));
    }

    private void actualizar(String evento) { /* usa prestamos */ }

    public void cerrar() {
        // No hay forma de darse de baja: no guardamos la referencia
    }
}
/** CORRECTO: guardar la referencia y darse de baja. */
public class VentanaPrestamosCorrecta implements AutoCloseable {

    private final List<Prestamo> prestamos = new ArrayList<>(10_000);
    private final Consumer<String> suscriptor;

    public VentanaPrestamosCorrecta() {
        this.suscriptor = this::actualizar;       // guardamos la referencia
        GestorDeEventos.suscribir(suscriptor);
    }

    private void actualizar(String evento) { }

    @Override
    public void close() {
        GestorDeEventos.desuscribir(suscriptor);   // simetria: suscribir/desuscribir
    }
}

La regla de simetría: todo suscribir, registrar, añadir u open necesita su desuscribir, desregistrar, eliminar o close, preferiblemente en un try-with-resources (06-06).

Fuga 3: ThreadLocal en un pool de hilos

package com.nexussoftware.bibliotech.servicio;

/**
 * FUGA: el hilo del pool NO muere, asi que su ThreadLocal tampoco.
 */
public class ContextoConFuga {

    private static final ThreadLocal<ContextoPeticion> CONTEXTO = new ThreadLocal<>();

    public static void procesar(String empleado, Runnable tarea) {
        CONTEXTO.set(new ContextoPeticion(empleado));    // se guarda...
        tarea.run();
        // ...y NUNCA se limpia.
    }
}

Por qué es especialmente insidioso. Con un hilo normal, al morir el hilo muere su ThreadLocalMap. Pero en un pool (08-05) los hilos no mueren: se reutilizan miles de veces. Cada petición deja su contexto, y el pool de 200 hilos acumula 200 contextos que nunca se liberan. Peor aún: la petición siguiente que caiga en ese hilo verá el contexto de la anterior, lo que además de fuga es un problema de seguridad.

/** CORRECTO: limpieza garantizada con finally. */
public class ContextoCorrecto {

    private static final ThreadLocal<ContextoPeticion> CONTEXTO = new ThreadLocal<>();

    public static void procesar(String empleado, Runnable tarea) {
        CONTEXTO.set(new ContextoPeticion(empleado));
        try {
            tarea.run();
        } finally {
            CONTEXTO.remove();          // OBLIGATORIO, y en finally
        }
    }
}

Y aquí conecta con 10-06: ScopedValue existe precisamente para eliminar esta clase de error, porque su ámbito es un bloque y la limpieza es automática.

Fuga 4: clase interna no estática que retiene a la externa

Retomamos 04-03. Una clase interna no estática guarda una referencia implícita a su instancia externa (Externa.this):

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.List;

public class CatalogoPesado {

    private final List<Material> materiales = new ArrayList<>(100_000);   // ~50 MB

    /**
     * NO estatica: retiene implicitamente a CatalogoPesado.
     */
    public class ContadorSimple {
        private int cuenta;
        public void incrementar() { cuenta++; }
        public int getCuenta()    { return cuenta; }
        // No usa 'materiales' para nada, y sin embargo lo retiene
    }

    public ContadorSimple crearContador() {
        return new ContadorSimple();
    }
}
// Se crea el catalogo de 50 MB, se saca un contador de 16 bytes
// y se descarta el catalogo... pero el contador lo retiene.
ContadorSimple contador;
{
    CatalogoPesado catalogo = new CatalogoPesado();
    contador = catalogo.crearContador();
}
// 'catalogo' ya no es alcanzable... EXCEPTO a traves de contador.this$0
// Los 50 MB siguen ahí.
/** CORRECTO: static, sin referencia a la externa. */
public static class ContadorSimple {
    private int cuenta;
    public void incrementar() { cuenta++; }
    public int getCuenta()    { return cuenta; }
}

La regla de 04-03, ahora con su justificación completa: haz static toda clase interna que no necesite acceder al estado de la externa. El IDE lo sugiere; hazle caso.

Lo mismo aplica a las lambdas y a las clases anónimas: capturan this si usan cualquier campo o método de instancia. Una lambda que sobrevive a su creador lo retiene.

Tabla resumen

Fuga Cómo se detecta Cómo se arregla
Colección que crece Volcado de heap: un HashMap gigante Política de expulsión (LRU, TTL, débiles)
Listeners Muchas instancias de una clase de UI o de servicio Simetría suscribir/desuscribir
ThreadLocal en pool ThreadLocalMap grandes en el volcado remove() en finally
Clase interna Objetos pequeños con this$0 a objetos grandes Hacerla static

  1. Referencias débiles, blandas y fantasma

Java ofrece cuatro fuerzas de referencia, y son la herramienta para decirle al recolector "puedes borrar esto si lo necesitas".

Tipo Clase El GC la borra... Uso
Fuerte (normal) Nunca mientras sea alcanzable Todo el código normal
Blanda SoftReference Solo si va a faltar memoria Cachés que pueden sacrificarse
Débil WeakReference En el siguiente GC Metadatos asociados a objetos
Fantasma PhantomReference Ya está borrada; solo notifica Limpieza de recursos nativos
package com.nexussoftware.bibliotech.diagnostico;

import java.lang.ref.*;

public class FuerzasDeReferencia {

    public static void main(String[] args) throws InterruptedException {

        // --- FUERTE: el GC no la toca ---
        Libro fuerte = new Libro("978-0000000001", "Java Efectivo");
        System.gc();
        Thread.sleep(100);
        System.out.println("Fuerte tras GC:  " + (fuerte != null));      // true

        // --- DEBIL: desaparece en el siguiente GC ---
        WeakReference<Libro> debil = new WeakReference<>(
                new Libro("978-0000000002", "Patrones de Diseño"));
        System.out.println("Débil antes:     " + (debil.get() != null)); // true
        System.gc();
        Thread.sleep(100);
        System.out.println("Débil tras GC:   " + (debil.get() != null)); // false

        // --- BLANDA: sobrevive mientras haya memoria ---
        SoftReference<Libro> blanda = new SoftReference<>(
                new Libro("978-0000000003", "Refactorización"));
        System.gc();
        Thread.sleep(100);
        System.out.println("Blanda tras GC:  " + (blanda.get() != null)); // true

        // --- FANTASMA: get() SIEMPRE devuelve null ---
        ReferenceQueue<Libro> cola = new ReferenceQueue<>();
        PhantomReference<Libro> fantasma = new PhantomReference<>(
                new Libro("978-0000000004", "Código Limpio"), cola);
        System.out.println("Fantasma.get():  " + fantasma.get());        // siempre null

        System.gc();
        Thread.sleep(100);
        Reference<?> encolada = cola.poll();
        System.out.println("¿Notificada?     " + (encolada != null));    // true
    }
}
Fuerte tras GC:  true
Débil antes:     true
Débil tras GC:   false
Blanda tras GC:  true
Fantasma.get():  null
¿Notificada?     true

Las tres reglas de uso:

WeakReference"quiero acceder a esto mientras alguien más lo use, pero no quiero mantenerlo vivo yo". Es el caso de asociar metadatos a un objeto sin impedir que se recolecte.

SoftReference"esto es una caché: bórralo si hace falta memoria". Se usa menos de lo que se cree, porque el comportamiento exacto depende de la JVM y una caché con política explícita de tamaño suele ser más predecible.

PhantomReference"avísame cuando esto ya se haya recolectado para liberar un recurso nativo". Es el mecanismo de Cleaner (apartado 15) y no se usa directamente casi nunca.

La ReferenceQueue es el mecanismo de notificación: se le pasa al crear la referencia y la JVM encola la referencia cuando su objeto se recolecta. Permite reaccionar sin sondear.

  1. WeakHashMap y la caché de fichas de BiblioTech

WeakHashMap es un Map cuyas claves son referencias débiles: cuando una clave deja de ser alcanzable desde el resto del programa, la entrada desaparece sola.

package com.nexussoftware.bibliotech.diagnostico;

import java.util.*;

public class ComparacionDeMapas {

    public static void main(String[] args) throws InterruptedException {

        Map<Libro, String> normal = new HashMap<>();
        Map<Libro, String> debil = new WeakHashMap<>();

        Libro retenido  = new Libro("978-0000000001", "Java Efectivo");
        Libro temporal  = new Libro("978-0000000002", "Patrones de Diseño");

        normal.put(retenido, "estante A-1");
        normal.put(temporal, "estante A-2");
        debil.put(retenido, "estante A-1");
        debil.put(temporal, "estante A-2");

        System.out.println("Antes -> HashMap: " + normal.size()
                         + ", WeakHashMap: " + debil.size());

        temporal = null;          // se pierde la ultima referencia FUERTE
        System.gc();
        Thread.sleep(200);

        System.out.println("Después -> HashMap: " + normal.size()
                         + ", WeakHashMap: " + debil.size());
    }
}
Antes -> HashMap: 2, WeakHashMap: 2
Después -> HashMap: 2, WeakHashMap: 1

El HashMap retiene el libro para siempre aunque nadie más lo use: es una fuga. El WeakHashMap deja que se recolecte y elimina la entrada.

El caso de uso real: metadatos asociados a objetos que no controlas.

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.*;

import java.time.Instant;
import java.util.*;

/**
 * Caché de fichas de BiblioTech con dos niveles.
 *
 * Nivel 1 (WeakHashMap): fichas asociadas a materiales VIVOS.
 *   Si el material desaparece del catálogo, su ficha se libera sola.
 *
 * Nivel 2 (LinkedHashMap LRU): fichas por ISBN, acotada.
 *   Sobrevive a la desaparición del material, pero con tope.
 */
public class CacheFichas {

    private static final int CAPACIDAD_LRU = 5_000;

    /**
     * Clave: el objeto Material. Referencia DEBIL.
     * Si nadie más referencia al material, la entrada se va sola.
     */
    private final Map<Material, Ficha> porMaterial =
            Collections.synchronizedMap(new WeakHashMap<>());

    /**
     * Clave: el ISBN (un String, referencia fuerte).
     * Aquí SI hace falta política de expulsión explícita.
     */
    private final Map<String, Ficha> porIsbn =
            Collections.synchronizedMap(new LinkedHashMap<>(CAPACIDAD_LRU, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<String, Ficha> masAntigua) {
                    return size() > CAPACIDAD_LRU;
                }
            });

    private long aciertosDebiles = 0;
    private long aciertosLru = 0;
    private long fallos = 0;

    public Ficha obtener(Material material) {
        Ficha porObjeto = porMaterial.get(material);
        if (porObjeto != null) {
            aciertosDebiles++;
            return porObjeto;
        }

        Ficha porClave = porIsbn.get(material.getIsbn());
        if (porClave != null) {
            aciertosLru++;
            porMaterial.put(material, porClave);      // repoblar el nivel 1
            return porClave;
        }

        fallos++;
        Ficha nueva = calcular(material);
        porMaterial.put(material, nueva);
        porIsbn.put(material.getIsbn(), nueva);
        return nueva;
    }

    private Ficha calcular(Material material) {
        // Cálculo costoso: consultas, agregados, formateo
        return new Ficha(material.getIsbn(), material.getTitulo(), true);
    }

    public String estadisticas() {
        long total = aciertosDebiles + aciertosLru + fallos;
        return """
                CacheFichas
                  Nivel 1 (WeakHashMap): %d entradas vivas, %d aciertos
                  Nivel 2 (LRU %d):      %d entradas, %d aciertos
                  Fallos:                %d
                  Tasa de acierto:       %.1f %%"""
                .formatted(porMaterial.size(), aciertosDebiles,
                           CAPACIDAD_LRU, porIsbn.size(), aciertosLru,
                           fallos, total == 0 ? 0.0
                                   : (aciertosDebiles + aciertosLru) * 100.0 / total);
    }
}

Tres advertencias sobre WeakHashMap que hay que conocer:

1. Los valores son referencias FUERTES. Si un valor referencia a su propia clave, la entrada nunca se libera:

// FUGA: el valor apunta a la clave, que asi nunca queda inalcanzable
WeakHashMap<Libro, Prestamo> mapa = new WeakHashMap<>();
mapa.put(libro, new Prestamo(..., libro));      // Prestamo guarda el Libro

2. La limpieza no es inmediata. Depende de que ocurra un GC. size() puede devolver un número mayor del que "debería" hasta que el recolector pase.

3. Con claves String literales no funciona como esperas. Los literales están internados (apartado 23) y son fuertemente alcanzables desde el pool de constantes: nunca se recolectan.

  1. finalize obsoleto y Cleaner

Retomamos 03-09. Object.finalize() se pensó como un destructor: un método que la JVM llamaría antes de recolectar el objeto, para liberar recursos.

Fue un error de diseño y está obsoleto desde Java 9, marcado para eliminación desde Java 18. Las razones:

Problema Consecuencia
No hay garantía de que se ejecute El programa puede terminar sin llamarlo nunca
No hay garantía de cuándo Puede pasar horas
Retrasa la recolección Un objeto con finalize necesita dos ciclos de GC
Se ejecuta en un hilo sin prioridad Puede acumularse una cola inmensa
Una excepción se ignora en silencio El objeto queda medio finalizado
Puede "resucitar" el objeto Guardando this en un campo estático
Riesgo de seguridad Ataques por finalización sobre constructores fallidos
// NUNCA HAGAS ESTO
@Override
protected void finalize() throws Throwable {
    if (fichero != null) {
        fichero.close();
    }
}

La solución correcta es AutoCloseable + try-with-resources (06-06). Es determinista, ocurre exactamente cuando debe y el compilador ayuda.

Y para la red de seguridad, Cleaner (Java 9):

package com.nexussoftware.bibliotech.persistencia;

import java.lang.ref.Cleaner;
import java.nio.channels.FileChannel;
import java.nio.file.*;

/**
 * Recurso con cierre determinista (AutoCloseable) MAS una red
 * de seguridad con Cleaner por si alguien olvida cerrarlo.
 */
public class AlmacenPrestamos implements AutoCloseable {

    /** UNO por aplicación: crea un hilo. */
    private static final Cleaner LIMPIADOR = Cleaner.create();

    /**
     * ESTATICA y sin referencia al objeto externo.
     * Si tuviera una referencia a AlmacenPrestamos, este nunca
     * sería inalcanzable y el Cleaner NUNCA se activaría.
     * Es la fuga 4 del apartado 12, aplicada aquí.
     */
    private static class EstadoALimpiar implements Runnable {

        private final FileChannel canal;
        private final Path ruta;

        EstadoALimpiar(FileChannel canal, Path ruta) {
            this.canal = canal;
            this.ruta = ruta;
        }

        @Override
        public void run() {
            try {
                if (canal.isOpen()) {
                    System.err.println("AVISO: AlmacenPrestamos(" + ruta
                            + ") no se cerró; lo cierra el Cleaner");
                    canal.close();
                }
            } catch (Exception e) {
                // El Cleaner traga las excepciones: no hay a quién propagarlas
            }
        }
    }

    private final EstadoALimpiar estado;
    private final Cleaner.Cleanable limpieza;

    public AlmacenPrestamos(Path ruta) throws java.io.IOException {
        FileChannel canal = FileChannel.open(ruta,
                StandardOpenOption.CREATE, StandardOpenOption.WRITE);
        this.estado = new EstadoALimpiar(canal, ruta);
        this.limpieza = LIMPIADOR.register(this, estado);
    }

    public void guardar(String linea) { /* ... */ }

    @Override
    public void close() {
        limpieza.clean();      // cierre DETERMINISTA e idempotente
    }
}
// USO NORMAL: try-with-resources, cierre garantizado y determinista
try (var almacen = new AlmacenPrestamos(Path.of("prestamos.dat"))) {
    almacen.guardar("PR-2026-0041;978-0000000001;Marta Ruiz");
}
finalize Cleaner
Estado Obsoleto, marcado para eliminación Actual
Retrasa la recolección (dos ciclos) No
Puede resucitar el objeto No: el estado es independiente
Excepciones Se ignoran en silencio Se contienen en el limpiador
Uso recomendado Ninguno Solo como red de seguridad

La regla: AutoCloseable para el cierre real, Cleaner como red de seguridad, finalize nunca.

  1. Medir antes de optimizar

Antes de las herramientas, la regla que gobierna todo lo demás. La formuló Donald Knuth en 1974 y sigue vigente:

"Los programadores desperdician cantidades enormes de tiempo pensando en la velocidad de partes no críticas de sus programas... La optimización prematura es la raíz de todo mal. Aun así, no debemos dejar pasar nuestras oportunidades en ese 3 % crítico."

La segunda frase se cita menos y es igual de importante: hay un 3 % que sí importa, y el trabajo consiste en encontrarlo.

El error de optimizar a ciegas tiene una forma característica. Un desarrollador mira el código, decide que un bucle "parece lento", lo reescribe de forma ingeniosa e ilegible, y el resultado va igual de rápido — porque el tiempo real se iba en una consulta a base de datos sin índice, en una llamada de red repetida o en una expresión regular recompilada en cada iteración.

El método correcto:

graph TD
    A["1. Definir el objetivo<br/>'el informe debe tardar menos de 2 s'"] --> B["2. MEDIR la situacion actual"]
    B --> C{"¿Cumple el objetivo?"}
    C -->|"si"| D["No optimizar. Terminado."]
    C -->|"no"| E["3. Perfilar: ¿DONDE se va el tiempo?"]
    E --> F["4. Optimizar SOLO el punto caliente"]
    F --> G["5. MEDIR de nuevo"]
    G --> H{"¿Mejoró?"}
    H -->|"no"| I["REVERTIR el cambio"]
    H -->|"si"| C
    I --> E

Los cuatro principios:

  1. Sin objetivo no hay optimización, hay entretenimiento. "Más rápido" no es un objetivo; "el informe mensual en menos de 2 segundos con 100.000 préstamos" sí.
  2. Perfila, no adivines. La intuición sobre dónde se va el tiempo es notoriamente mala, incluso entre desarrolladores expertos.
  3. Optimiza el cuello de botella, y solo ese. Mejorar un 90 % algo que ocupa el 2 % del tiempo mejora el total un 1,8 %.
  4. Si no mejoró, revierte. Un cambio que complica el código sin mejorar el rendimiento es una pérdida neta.

Y la ley de Amdahl, que pone números a esto: si una parte ocupa la fracción p del tiempo y la aceleras s veces, la mejora total es:

mejora = 1 / ((1 - p) + p/s)

Con p = 0,05 (el 5 % del tiempo) y s = ∞ (infinitamente rápido), la mejora total es 1,05×. Un 5 %. Por muy brillante que sea la optimización.

  1. Las herramientas del JDK

El JDK trae un conjunto completo de herramientas de diagnóstico. Todas están en $JAVA_HOME/bin.

jps: listar procesos Java

jps -lvm
14237 com.nexussoftware.bibliotech.BiblioTechApp -Xms2g -Xmx2g -XX:+UseG1GC
14891 jdk.jcmd/sun.tools.jps.Jps -Dapplication.home=/usr/lib/jvm/jdk-21

El primer número es el PID, que necesitan todas las demás.

jstat: estadísticas en vivo

# Estadisticas de GC cada segundo, 10 veces
jstat -gcutil 14237 1000 10
  S0     S1     E      O      M     CCS    YGC   YGCT    FGC   FGCT    GCT
  0.00  62.31  41.20  18.44  96.12 92.03    142   1.204     2  0.381   1.585
  0.00  62.31  78.90  18.44  96.12 92.03    142   1.204     2  0.381   1.585
 58.44   0.00  12.03  18.51  96.12 92.03    143   1.213     2  0.381   1.594
Columna Significado
S0, S1 % de uso de los espacios supervivientes
E % de uso del edén: verlo subir y volver a cero es el ritmo del GC menor
O % de uso de la generación vieja. Si sube monótonamente, hay fuga
M, CCS % de metaspace y de espacio de clases comprimidas
YGC, YGCT Número y tiempo total de GC menores
FGC, FGCT Número y tiempo total de GC completos
GCT Tiempo total en GC

Cómo leerlo en un minuto: si E sube y baja con normalidad y O se mantiene estable, todo va bien. Si O sube y nunca baja, y FGC crece, hay una fuga.

jmap: información y volcados del heap

# Histograma de objetos vivos, ordenado por tamaño
jmap -histo:live 14237 | head -20
 num     #instances         #bytes  class name
----------------------------------------------
   1:        847291       40669968  [B                        (byte[])
   2:        847012       20328288  java.lang.String
   3:        412008       19776384  com.nexussoftware.bibliotech.dominio.Prestamo
   4:        198432        9524736  java.util.HashMap$Node
   5:        412008        6592128  java.time.LocalDate
   6:         98211        4713-28  java.util.ArrayList

Este histograma es la primera herramienta ante una sospecha de fuga. Ejecútalo dos veces separadas por unos minutos y compara: la clase cuyas instancias crecen sin parar es la culpable.

# Volcado COMPLETO del heap para analizar con herramientas gráficas
jmap -dump:live,format=b,file=/tmp/bibliotech.hprof 14237

Advertencia: un volcado provoca una pausa proporcional al tamaño del heap (un heap de 8 GB puede parar el proceso varios segundos) y genera un fichero del tamaño del heap. En producción, con cuidado y preferiblemente sobre una instancia sacada del balanceador.

jcmd: la navaja suiza

Es la herramienta moderna que engloba a las demás:

jcmd 14237 help
GC.class_histogram
GC.heap_dump
GC.heap_info
GC.run
JFR.start
JFR.dump
Thread.print
VM.flags
VM.native_memory
VM.system_properties
VM.uptime
jcmd 14237 GC.heap_info
jcmd 14237 Thread.print              # volcado de hilos: interbloqueos (08-04)
jcmd 14237 VM.flags                  # opciones efectivas de la JVM
jcmd 14237 GC.class_histogram
jcmd 14237 VM.native_memory summary  # requiere -XX:NativeMemoryTracking=summary

Thread.print es lo primero ante un cuelgue. Muestra la pila de cada hilo y detecta interbloqueos automáticamente:

Found one Java-level deadlock:
=============================
"cliente-42":
  waiting to lock monitor 0x00007f... (object 0x000000071ab2, a java.lang.Object),
  which is held by "cliente-17"
"cliente-17":
  waiting to lock monitor 0x00007f... (object 0x000000071ab3, a java.lang.Object),
  which is held by "cliente-42"

Es exactamente el interbloqueo de 08-04, visto desde fuera.

jconsole y VisualVM

Herramientas gráficas de monitorización en vivo: memoria, hilos, clases, CPU y MBeans JMX. jconsole viene en el JDK; VisualVM se descarga aparte y es más completa, con perfilado y análisis de volcados de heap.

jconsole 14237

Para analizar volcados de heap en serio, Eclipse MAT (Memory Analyzer Tool) es la referencia: calcula el tamaño retenido de cada objeto (cuánta memoria se liberaría si desapareciera) y tiene un informe automático de sospechosos de fuga que acierta la mayoría de las veces.

  1. Java Flight Recorder

JFR es la herramienta más potente del conjunto, y la menos conocida.

Es un motor de recolección de eventos integrado en la JVM con una sobrecarga de alrededor del 1 %, lo bastante baja para dejarlo siempre activo en producción. Registra miles de tipos de evento: asignaciones, GC, bloqueos, E/S, excepciones, compilación JIT, uso de CPU.

# Al arrancar
java -XX:StartFlightRecording=duration=120s,filename=/tmp/bibliotech.jfr \
     -jar bibliotech.jar

# Sobre un proceso en marcha
jcmd 14237 JFR.start name=diagnostico settings=profile duration=120s \
     filename=/tmp/bibliotech.jfr

jcmd 14237 JFR.check
jcmd 14237 JFR.dump name=diagnostico filename=/tmp/instantanea.jfr
jcmd 14237 JFR.stop name=diagnostico

Los perfiles predefinidos son default (sobrecarga ~1 %, apto para producción continua) y profile (~2 %, más detalle, para diagnóstico puntual).

Analizar la grabación desde línea de órdenes:

jfr summary /tmp/bibliotech.jfr
 Event Type                        Count    Size (bytes)
=========================================================
 jdk.ObjectAllocationSample        18421         589472
 jdk.ExecutionSample                9204         294528
 jdk.GCPhasePause                    412          13184
 jdk.JavaMonitorEnter                287          14924
 jdk.SocketRead                      194           9312
 jdk.ThreadPark                      142           5680
# Eventos concretos
jfr print --events GCPhasePause /tmp/bibliotech.jfr | head -30
jfr print --events ObjectAllocationSample /tmp/bibliotech.jfr | head -40
jfr print --events JavaMonitorEnter /tmp/bibliotech.jfr

Y JDK Mission Control (JMC) es la aplicación gráfica que analiza los .jfr con informes automáticos: puntos calientes de CPU, sitios de asignación, contención de cerrojos, pausas de GC, latencias de E/S.

Por qué JFR es superior a un perfilador tradicional:

Perfilador con instrumentación JFR
Sobrecarga 10 % - 100 % o más ~1 %
Distorsión de los resultados Alta: altera lo que mide Mínima
Uso en producción Desaconsejado Diseñado para ello
Eventos de la JVM No los ve GC, JIT, safepoints, cerrojos
Grabación continua Difícil Sí, con buffer circular

La configuración recomendada para producción:

java -XX:StartFlightRecording=disk=true,maxsize=512m,maxage=12h,\
settings=default,filename=/var/log/bibliotech/recording.jfr \
     -XX:FlightRecorderOptions=repository=/var/log/bibliotech/jfr \
     -jar bibliotech.jar

Con eso, cuando ocurra un incidente, tendrás las últimas 12 horas de eventos grabadas en lugar de tener que esperar a que se repita.

  1. Por qué un microbenchmark casero miente

En 10-04 y 10-06 escribiste bancos de prueba caseros y dijimos, dos veces, que eran orientativos y que solo JMH da resultados fiables. Toca explicar por qué.

package com.nexussoftware.bibliotech.diagnostico;

public class BenchmarkEnganoso {

    public static void main(String[] args) {

        long inicio = System.nanoTime();

        for (int i = 0; i < 100_000_000; i++) {
            Math.sqrt(i);                          // "medimos" sqrt
        }

        long ns = System.nanoTime() - inicio;
        System.out.printf("100 millones de sqrt en %.2f ms%n", ns / 1_000_000.0);
    }
}
100 millones de sqrt en 3,41 ms

Cien millones de raíces cuadradas en 3,4 milisegundos. Eso son 34 picosegundos por operación, muy por debajo de lo que tarda un solo ciclo de reloj. El resultado es imposible, y la razón es que el compilador JIT eliminó el bucle entero: el resultado de Math.sqrt(i) no se usa, así que es código muerto.

Los cinco problemas de un microbenchmark casero:

  1. Calentamiento (warmup)

La JVM empieza interpretando el bytecode. Solo tras miles de ejecuciones el JIT compila el método a código máquina optimizado. Medir las primeras ejecuciones mide al intérprete, no al código real, y puede ser 10 o 100 veces más lento.

  1. Eliminación de código muerto

Si el JIT demuestra que un cálculo no afecta al resultado observable, lo elimina. Es exactamente lo que pasó arriba.

  1. Plegado de constantes

Si las entradas son constantes conocidas en compilación, el JIT calcula el resultado una vez y lo sustituye:

// El JIT puede sustituir esto por  int r = 5050;
int suma = 0;
for (int i = 1; i <= 100; i++) suma += i;

  1. Efectos del recolector

Un GC que ocurra durante la medición añade su pausa al resultado. Una medición que "salió lenta" puede ser simplemente una que coincidió con un GC.

  1. Ruido del sistema

Otros procesos, el escalado de frecuencia de la CPU, el planificador del sistema operativo, la caché de la CPU aún fría. La varianza entre ejecuciones idénticas puede superar el 20 %.

Un banco casero decente mitiga algunos, y es lo que hicimos en 10-04:

public class BenchmarkMenosMalo {

    /** volatile: impide que el JIT elimine el calculo */
    private static volatile double sumidero;

    public static void main(String[] args) {
        // 1. CALENTAMIENTO
        for (int r = 0; r < 10; r++) {
            medir();
        }
        // 2. Varias repeticiones, y MEDIANA (menos sensible a picos que la media)
        long[] tiempos = new long[11];
        for (int r = 0; r < tiempos.length; r++) {
            tiempos[r] = medir();
        }
        java.util.Arrays.sort(tiempos);
        System.out.printf("Mediana: %.2f ms%n", tiempos[tiempos.length / 2] / 1_000_000.0);
    }

    private static long medir() {
        long inicio = System.nanoTime();
        double acumulado = 0;
        for (int i = 0; i < 100_000_000; i++) {
            acumulado += Math.sqrt(i);
        }
        sumidero = acumulado;        // consumir el resultado
        return System.nanoTime() - inicio;
    }
}
Mediana: 312,47 ms

Cien veces más lento que la medición "imposible". Este número sí es plausible: unos 3 nanosegundos por raíz cuadrada. Pero sigue sin ser fiable: no controla el GC, no aísla el ruido, no calcula intervalos de confianza y el volatile introduce su propio coste.

  1. JMH: medir de verdad

JMH (Java Microbenchmark Harness) es la herramienta oficial, desarrollada por el mismo equipo que la JVM, y la única forma seria de medir código Java.

Qué hace por ti:

  • Ejecuta iteraciones de calentamiento hasta que el JIT ha estabilizado el código.
  • Usa Blackhole para consumir resultados de forma que el JIT no pueda eliminarlos.
  • Bifurca procesos (@Fork) para evitar que la compilación de un test contamine al siguiente.
  • Ejecuta múltiples iteraciones y calcula media, desviación e intervalos de confianza.
  • Evita el plegado de constantes con @State.
  • Informa de la asignación de memoria por operación.
package com.nexussoftware.bibliotech.benchmark;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
public class BenchmarkBiblioTech {

    @Param({"100", "10000", "1000000"})
    private int tamano;

    private List<Libro> catalogo;

    @Setup
    public void preparar() {
        catalogo = IntStream.range(0, tamano)
                .mapToObj(i -> new Libro(String.format("978-%010d", i),
                                         "Título " + i, "Autor " + (i % 100),
                                         200 + (i % 400), 3.0 + (i % 20) / 10.0))
                .collect(Collectors.toCollection(ArrayList::new));
    }

    @Benchmark
    public long conBucle() {
        long total = 0;
        for (Libro l : catalogo) {
            if (l.getPaginas() > 400) {
                total += l.getPaginas();
            }
        }
        return total;
    }

    @Benchmark
    public long conStream() {
        return catalogo.stream()
                .filter(l -> l.getPaginas() > 400)
                .mapToLong(Libro::getPaginas)
                .sum();
    }

    @Benchmark
    public long conStreamParalelo() {
        return catalogo.parallelStream()
                .filter(l -> l.getPaginas() > 400)
                .mapToLong(Libro::getPaginas)
                .sum();
    }

    /** Blackhole: consume el valor de forma que el JIT no pueda eliminarlo. */
    @Benchmark
    public void agrupacion(Blackhole bh) {
        bh.consume(catalogo.stream()
                .collect(Collectors.groupingBy(Libro::getAutor, Collectors.counting())));
    }
}
mvn clean package
java -jar target/benchmarks.jar BenchmarkBiblioTech -prof gc
Benchmark                        (tamano)  Mode  Cnt      Score      Error  Units
BenchmarkBiblioTech.conBucle          100  avgt   20      0,082 ±    0,003  us/op
BenchmarkBiblioTech.conBucle        10000  avgt   20      8,914 ±    0,142  us/op
BenchmarkBiblioTech.conBucle      1000000  avgt   20   1102,412 ±   28,331  us/op
BenchmarkBiblioTech.conStream         100  avgt   20      0,341 ±    0,012  us/op
BenchmarkBiblioTech.conStream       10000  avgt   20     11,208 ±    0,201  us/op
BenchmarkBiblioTech.conStream     1000000  avgt   20   1284,904 ±   31,204  us/op
BenchmarkBiblioTech.conStreamParalelo 100  avgt   20     12,041 ±    1,412  us/op
BenchmarkBiblioTech.conStreamParalelo 10000 avgt  20     28,114 ±    2,031  us/op
BenchmarkBiblioTech.conStreamParalelo 1000000 avgt 20   241,882 ±   18,442  us/op

Benchmark                        (tamano)  Mode  Cnt      Score  Units
conStream:gc.alloc.rate.norm        10000  avgt   20    128,004  B/op
conBucle:gc.alloc.rate.norm         10000  avgt   20      0,001  B/op

Cinco conclusiones, y todas son útiles:

1. Con 100 elementos, el bucle es 4× más rápido que el stream. Montar la tubería del stream tiene un coste fijo de unos 250 nanosegundos que domina cuando hay poco trabajo.

2. Con un millón, la diferencia baja al 16 %. El coste fijo se amortiza y queda la sobrecarga por elemento de las lambdas.

3. El paralelo es 50× más LENTO con 100 elementos y 4,5× más rápido con un millón. Exactamente lo que anticipaste en 10-04, ahora con números fiables.

4. El stream asigna 128 bytes por operación; el bucle, cero. Ese es el coste real de la tubería, y es un dato que ningún banco casero da.

5. El margen de error (±) es lo que hace la medición creíble. 1102,412 ± 28,331 significa que la diferencia con 1284,904 ± 31,204 es real y no ruido. Sin ese intervalo, comparar dos números sueltos no significa nada.

Y la lección de diseño que se saca de aquí: la diferencia entre bucle y stream es del 16 % en un millón de elementos. Si tu código no está en un bucle caliente, elige el que se lea mejor. La claridad de stream().filter().sum() vale mucho más que 180 microsegundos en un informe que se ejecuta una vez al día.

  1. El compilador JIT

Queda por explicar por qué el código Java se acelera con el tiempo.

Java sigue un modelo de compilación mixta:

graph LR
    A["Codigo fuente<br/>.java"] -->|"javac"| B["Bytecode<br/>.class"]
    B --> C["Interprete<br/>lento, arranque inmediato"]
    C -->|"cuenta invocaciones<br/>y saltos"| D{"¿Punto caliente?"}
    D -->|"no"| C
    D -->|"si, umbral C1"| E["JIT C1<br/>compilacion rapida<br/>optimizacion ligera"]
    E -->|"sigue caliente<br/>umbral C2"| F["JIT C2<br/>compilacion lenta<br/>optimizacion AGRESIVA"]
    F -->|"suposicion invalidada"| C

Las etapas:

  1. Interpretación. Al arrancar, la JVM ejecuta el bytecode instrucción a instrucción. Es lento pero empieza al momento.
  2. C1 (cliente). Tras unos miles de invocaciones, el método se compila rápido con optimizaciones ligeras. Se instrumenta para recoger estadísticas.
  3. C2 (servidor). Si sigue siendo caliente (decenas de miles de ejecuciones), C2 lo recompila con optimizaciones agresivas basadas en el perfil recogido.

Esa transición se llama compilación por niveles (tiered compilation) y está activa por defecto. Explica el comportamiento característico de una aplicación Java: arranca lenta y se acelera durante los primeros minutos.

Verlo:

java -XX:+PrintCompilation -jar bibliotech.jar 2>&1 | head -20
    112    1       3       java.lang.String::hashCode (49 bytes)
    118    2       3       java.util.HashMap::hash (20 bytes)
    134    5       4       java.lang.String::equals (65 bytes)
    891   142       4       com...GestorPrestamos::prestar (218 bytes)
   1204   198       3       com...EstadisticasBiblioTech::conteoPorTipo (94 bytes)
   2841   142       4       com...GestorPrestamos::prestar (218 bytes)   made not entrant

Las columnas son: milisegundos desde el arranque, identificador de compilación, nivel (1-3 es C1, 4 es C2) y el método. Ese made not entrant del final es una desoptimización.

Las optimizaciones principales

Inlining (integración). Sustituye la llamada a un método por su cuerpo, eliminando el coste de la llamada y —más importante— abriendo la puerta a otras optimizaciones al ver el código completo:

// Tu escribes
public int paginasTotales(List<Libro> libros) {
    int total = 0;
    for (Libro l : libros) {
        total += l.getPaginas();     // llamada a un getter
    }
    return total;
}

// El JIT integra getPaginas() y genera algo equivalente a
for (Libro l : libros) {
    total += l.paginas;              // acceso directo al campo
}

Por eso los getters no cuestan nada en Java, al contrario de lo que muchos suponen: el JIT los integra siempre.

Escape analysis (análisis de escape). Si el JIT demuestra que un objeto no escapa del método, puede eliminarlo por completo, colocando sus campos en registros:

public double distancia(int x1, int y1, int x2, int y2) {
    Punto a = new Punto(x1, y1);      // no escapa
    Punto b = new Punto(x2, y2);      // no escapa
    return Math.hypot(a.x() - b.x(), a.y() - b.y());
}
// El JIT puede NO CREAR los dos objetos: cero asignación en el heap

Esto explica por qué los record de vida corta (10-06) y los Optional intermedios de los streams (10-04) suelen ser gratis: el JIT los hace desaparecer.

Especulación y desoptimización. El JIT apuesta basándose en lo observado. Si un Material siempre ha sido un Libro, compila una llamada monomórfica y directa. Si mañana aparece una Revista, la suposición se invalida: el método se marca made not entrant y se vuelve a interpretar y recompilar.

Esto tiene una consecuencia práctica sorprendente: el código que ha visto pocos tipos distintos va más rápido. Un método polimórfico llamado siempre con la misma implementación se optimiza a fondo; el mismo método con cinco implementaciones alternándose no puede optimizarse igual.

Otras optimizaciones: eliminación de código muerto, desenrollado de bucles, plegado de constantes, propagación de copias, vectorización con instrucciones SIMD, eliminación de comprobaciones de rango cuando el compilador demuestra que el índice está dentro.

Consecuencias prácticas

Efecto Consecuencia
Arranque lento La primera petición puede ser 10-100× más lenta. Importa en funciones sin servidor y en CLI
Calentamiento necesario Cualquier medición debe calentar primero
El código simple se optimiza mejor Los métodos pequeños se integran; los enormes, no
Menos tipos = más rápido El polimorfismo excesivo impide la especulación
La JVM te gana escribiendo trucos Optimizaciones manuales que estorban al JIT empeoran el resultado

El arranque lento se mitiga con AppCDS (compartir clases precargadas), con AOT (compilación anticipada), o con GraalVM Native Image, que compila a binario nativo con arranque en milisegundos y menos memoria, a cambio de perder las optimizaciones dinámicas y de exigir declarar toda la reflexión (10-03).

  1. Buenas prácticas de rendimiento, por impacto

Ordenadas de mayor a menor impacto real. Los primeros puntos valen órdenes de magnitud; los últimos, porcentajes.

Impacto ALTÍSIMO: el algoritmo y la estructura de datos

Retomamos el módulo 5. Esto vale más que todo lo demás junto.

// O(n·m): para cada material, recorre TODOS los prestamos
for (Material m : materiales) {                       // n = 10.000
    for (Prestamo p : prestamos) {                    // m = 50.000
        if (p.getIsbn().equals(m.getIsbn())) { ... }
    }
}
// 500.000.000 comparaciones

// O(n+m): indexar primero
Map<String, Prestamo> porIsbn = prestamos.stream()
        .collect(Collectors.toMap(Prestamo::getIsbn, p -> p, (a, b) -> a));

for (Material m : materiales) {
    Prestamo p = porIsbn.get(m.getIsbn());            // O(1)
}
// 60.000 operaciones: 8.000 veces menos

Y la elección de la colección:

Operación ArrayList LinkedList HashMap TreeMap
Acceso por índice O(1) O(n)
Búsqueda por valor O(n) O(n) O(1) O(log n)
Insertar al final O(1) amortizado O(1) O(1) O(log n)
Insertar al principio O(n) O(1)
Recorrido completo Muy rápido (memoria contigua) Lento (saltos) Medio Medio

Y ArrayList gana a LinkedList casi siempre, incluso donde la teoría dice lo contrario, por la localidad de caché: los elementos contiguos se leen en bloque, mientras que LinkedList salta por todo el heap. Lo comprobaste en 10-04 con el paralelismo.

Impacto ALTO: evitar trabajo innecesario

// MAL: compila la expresion regular en CADA llamada
public boolean esIsbnValido(String isbn) {
    return isbn.matches("978-\\d{10}");     // Pattern.compile interno cada vez
}

// BIEN: compilada una sola vez
private static final Pattern ISBN = Pattern.compile("978-\\d{10}");

public boolean esIsbnValido(String isbn) {
    return ISBN.matcher(isbn).matches();
}
// MAL: consulta dentro del bucle
for (Prestamo p : prestamos) {
    Material m = catalogo.consultarEnBaseDeDatos(p.getIsbn());   // N consultas
}

// BIEN: una consulta por lotes
Map<String, Material> materiales = catalogo.consultarPorLote(
        prestamos.stream().map(Prestamo::getIsbn).distinct().toList());

El problema N+1 es, con diferencia, el problema de rendimiento más común en aplicaciones empresariales. Lo verás con nombre y apellidos en 11-03 con Hibernate.

Impacto ALTO: reutilizar objetos caros

public class ServiciosBiblioTech {

    // Inmutables y seguros entre hilos: UNO por aplicación (10-05, 09-06)
    private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
    private static final Pattern ISBN = Pattern.compile("978-\\d{10}");
    private static final HttpClient HTTP = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5)).build();
}

Crear un HttpClient por petición (09-06) multiplica por cuatro la latencia y fuga hilos. Crear un DateTimeFormatter por línea de CSV multiplica el tiempo de exportación.

Y la excepción a la regla: SimpleDateFormat no se puede reutilizar entre hilos (10-05). Pero como ya no lo usas, el problema no existe.

Impacto MEDIO: StringBuilder en bucles

// MAL: cuadratico. Cada + crea un String nuevo copiando todo
String informe = "";
for (Prestamo p : prestamos) {
    informe += p.getId() + ";" + p.getEmpleado() + "\n";
}

// BIEN: lineal
StringBuilder sb = new StringBuilder(prestamos.size() * 48);
for (Prestamo p : prestamos) {
    sb.append(p.getId()).append(';').append(p.getEmpleado()).append('\n');
}
String informe = sb.toString();

// MEJOR aún si encaja: streams (10-04)
String informe = prestamos.stream()
        .map(p -> p.getId() + ";" + p.getEmpleado())
        .collect(Collectors.joining("\n"));

Matiz importante: una concatenación fuera de un bucle no es un problema. El compilador convierte "a" + b + "c" en una llamada eficiente (con invokedynamic y StringConcatFactory desde Java 9). El problema es específicamente el bucle, donde la copia es cuadrática.

Impacto MEDIO: evitar el autoboxing

// MAL: 10 millones de objetos Long
Long suma = 0L;
for (int i = 0; i < 10_000_000; i++) {
    suma += i;                          // desempaqueta, suma, EMPAQUETA
}

// BIEN: cero objetos
long suma = 0L;
for (int i = 0; i < 10_000_000; i++) {
    suma += i;
}

// Y en streams
int total = catalogo.stream().mapToInt(Libro::getPaginas).sum();   // IntStream

Impacto MEDIO: tamaño inicial de las colecciones

// Redimensiona ~14 veces al llegar a 10.000, copiando cada vez
List<Prestamo> lista = new ArrayList<>();

// Una sola reserva
List<Prestamo> lista = new ArrayList<>(10_000);

// HashMap: capacidad / factor de carga, para no rehashear
Map<String, Prestamo> mapa = new HashMap<>((int) (10_000 / 0.75f) + 1);

Los micro-trucos que NO sirven

"Optimización" Realidad
i++ frente a ++i en un bucle Idénticos tras compilar
Bucles descendentes (for (i = n; i-- > 0;)) Sin diferencia medible
Evitar getters accediendo al campo El JIT los integra: idéntico
Marcar métodos final "para que se optimicen" El JIT ya lo deduce
System.gc() para "liberar memoria" Empeora: fuerza un GC completo
Reutilizar objetos para no crear basura Contraproducente: promociona objetos a la vieja
x >> 1 en lugar de x / 2 El compilador ya lo hace, y se lee peor
Concatenar con StringBuilder fuera de un bucle Innecesario desde Java 9

  1. String, interning y StringBuilder, medidos

Un apartado concreto porque las cadenas son el tipo más usado y el que más memoria consume en una aplicación típica —recuerda el histograma del apartado 17: byte[] y String en los dos primeros puestos.

El pool de cadenas y el interning

public class PoolDeCadenas {

    public static void main(String[] args) {

        String a = "Java Efectivo";                          // literal: al POOL
        String b = "Java Efectivo";                          // MISMO objeto del pool
        String c = new String("Java Efectivo");              // objeto NUEVO en el heap
        String d = c.intern();                               // busca en el pool

        System.out.println("a == b: " + (a == b));           // true
        System.out.println("a == c: " + (a == c));           // false
        System.out.println("a == d: " + (a == d));           // true
        System.out.println("a.equals(c): " + a.equals(c));   // true

        // Concatenacion en TIEMPO DE COMPILACION: es un literal
        String e = "Java " + "Efectivo";
        System.out.println("a == e: " + (a == e));           // true

        // Concatenacion en EJECUCION: objeto nuevo
        String parte = "Java ";
        String f = parte + "Efectivo";
        System.out.println("a == f: " + (a == f));           // false
    }
}
a == b: true
a == c: false
a == d: true
a.equals(c): true
a == e: true
a == f: false

Esta es la razón por la que se comparan cadenas con equals y nunca con == (01-04), y ahora sabes exactamente por qué: == compara referencias, y dos cadenas con el mismo contenido pueden ser objetos distintos.

intern() tiene un uso legítimo y estrecho: cuando lees millones de cadenas repetidas de un fichero o de una base de datos, internarlas ahorra memoria porque todas las repeticiones comparten un objeto. Pero el pool vive en el heap y tiene un coste de búsqueda; no lo uses sin medir.

Compactación de cadenas (Java 9). Internamente, String pasó de char[] (2 bytes por carácter) a byte[] más un indicador de codificación: las cadenas Latin-1 —la mayoría— ocupan la mitad. Es transparente y fue una de las mejoras de memoria más grandes de la historia del JDK.

Concatenación medida

package com.nexussoftware.bibliotech.benchmark;

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5) @Measurement(iterations = 10) @Fork(2)
public class BenchmarkCadenas {

    @Param({"10", "100", "1000", "10000"})
    private int n;

    @Benchmark
    public String concatenacionConMas() {
        String resultado = "";
        for (int i = 0; i < n; i++) {
            resultado += "PR-2026-" + i + ";";        // CUADRATICO
        }
        return resultado;
    }

    @Benchmark
    public String conStringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            sb.append("PR-2026-").append(i).append(';');
        }
        return sb.toString();
    }

    @Benchmark
    public String conStringBuilderDimensionado() {
        StringBuilder sb = new StringBuilder(n * 16);   // capacidad estimada
        for (int i = 0; i < n; i++) {
            sb.append("PR-2026-").append(i).append(';');
        }
        return sb.toString();
    }
}
Benchmark                                     (n)  Mode  Cnt        Score      Error  Units
BenchmarkCadenas.concatenacionConMas           10  avgt   20        0,412 ±    0,011  us/op
BenchmarkCadenas.concatenacionConMas          100  avgt   20        8,204 ±    0,204  us/op
BenchmarkCadenas.concatenacionConMas         1000  avgt   20      512,891 ±   14,201  us/op
BenchmarkCadenas.concatenacionConMas        10000  avgt   20    48204,112 ± 1204,441  us/op
BenchmarkCadenas.conStringBuilder              10  avgt   20        0,198 ±    0,008  us/op
BenchmarkCadenas.conStringBuilder             100  avgt   20        1,412 ±    0,041  us/op
BenchmarkCadenas.conStringBuilder            1000  avgt   20       14,208 ±    0,312  us/op
BenchmarkCadenas.conStringBuilder           10000  avgt   20      148,904 ±    3,204  us/op
BenchmarkCadenas.conStringBuilderDimensionado 10000 avgt 20      104,412 ±    2,118  us/op

BenchmarkCadenas.concatenacionConMas:gc.alloc.rate.norm  10000  avgt  20  512048912,0  B/op
BenchmarkCadenas.conStringBuilder:gc.alloc.rate.norm     10000  avgt  20     524312,0  B/op

Lee la tabla con atención, porque es la mejor demostración de todo el apartado:

n Con + Con StringBuilder Factor
10 0,41 µs 0,20 µs
100 8,2 µs 1,4 µs
1.000 513 µs 14,2 µs 36×
10.000 48.204 µs 149 µs 324×

El factor crece con n, y eso es la firma de una diferencia de complejidad: la concatenación con + es O(n²) porque cada + copia toda la cadena acumulada; StringBuilder es O(n) amortizado.

Y la asignación de memoria es el dato demoledor: 512 megabytes frente a 524 kilobytes para la misma cadena de resultado. Mil veces más basura generada, con todo el trabajo de GC que eso implica.

Dimensionar el StringBuilder ahorra un 30 % adicional al evitar redimensionados.

Y la conclusión que hay que llevarse: con n = 10 la diferencia es irrelevante y no merece la pena preocuparse. Con n = 10.000 es la diferencia entre 149 microsegundos y 48 milisegundos. El mismo cambio de código es irrelevante o crítico según el contexto — y solo la medición lo dice.

Errores Comunes y Consejos

1. Optimizar sin medir. El error rey. La intuición sobre dónde se va el tiempo es mala incluso entre expertos. Perfila primero.

2. Creer que un microbenchmark casero dice algo. Sin calentamiento, sin consumir el resultado y sin repeticiones, el JIT elimina el código y mides el vacío. Usa JMH.

3. Confundir el heap con la memoria del proceso. -Xmx2g no significa "2 GB en total": faltan metaspace, caché de código, pilas de hilos y buffers directos. Es la causa número uno de OOMKilled en contenedores.

4. Llamar a System.gc(). Es una sugerencia, fuerza un GC completo con pausa larga y casi nunca ayuda. Desactívalo en producción.

5. Capturar OutOfMemoryError para continuar. La JVM queda en estado indeterminado. Regístralo y sal.

6. Creer que el recolector cuenta referencias. Usa alcanzabilidad desde raíces, por eso los ciclos no son un problema y por eso una sola referencia desde un campo static mantiene vivo un grafo entero.

7. Cachés sin política de expulsión. Una caché que solo crece no es una caché: es la fuga número uno.

8. ThreadLocal sin remove() en un pool. Los hilos del pool no mueren, así que el valor tampoco. Fuga y fuga de datos entre peticiones.

9. Clases internas no estáticas y lambdas que capturan this. Retienen la instancia externa completa. Hazlas static cuando no necesiten el estado externo.

10. Usar finalize. Obsoleto, no garantizado y retrasa la recolección. AutoCloseable para el cierre, Cleaner como red de seguridad.

11. Copiar opciones de ajuste de la JVM de internet. Muchas están obsoletas o eliminadas, interactúan entre sí y el conjunto suele ser peor que los valores por defecto. Toca -Xmx, -Xms y el recolector, y solo con datos.

12. Reutilizar objetos "para no crear basura". Los objetos de vida corta son casi gratis en la generación joven. Reutilizarlos los promociona a la vieja, donde sí cuestan.

13. Preocuparse por + en cadenas fuera de un bucle. Desde Java 9 el compilador lo resuelve eficientemente. El problema es el bucle.

Consejo 1: pon las opciones de diagnóstico desde el primer día. -XX:+HeapDumpOnOutOfMemoryError, log de GC rotado y JFR continuo no cuestan rendimiento y son la diferencia entre resolver un incidente en una hora o nunca.

Consejo 2: aprende a leer un log de GC. Cinco minutos con jstat -gcutil o un log de GC responden a "¿hay fuga?" mejor que un día de conjeturas. Busca el patrón de la memoria retenida que sube en escalera.

Consejo 3: la estructura de datos correcta vale más que todas las micro-optimizaciones juntas. Cambiar O(n²) por O(n) mejora mil veces; cambiar i++ por ++i mejora cero.

Consejo 4: escribe código claro primero. El JIT optimiza mejor el código sencillo, y el código claro es el que se puede optimizar después cuando la medición lo pida. El código "optimizado a mano" e ilegible suele ser más lento y siempre es peor de mantener.

Consejo 5: si no puedes explicar por qué un cambio mejora, no lo hagas. Y si mejora pero no sabes por qué, mídelo en otro contexto antes de generalizarlo.

Ejercicios

Ejercicio 1: detector y diagnóstico de fugas

Construye un laboratorio de fugas para BiblioTech:

  1. Una clase LaboratorioDeFugas que reproduzca los cuatro patrones del apartado 12, cada uno en un método aislado.
  2. Un MonitorDeMemoria que, en un hilo aparte, registre cada segundo la memoria usada tras un GC (usando Runtime y System.gc(), justificando que aquí sí está permitido) y detecte automáticamente el patrón de crecimiento monótono.
  3. Para cada fuga, imprime memoria retenida al principio y al final, y el veredicto del detector.
  4. Implementa la versión corregida de cada fuga y demuestra que el detector ya no la señala.
  5. Añade instrucciones para generar un volcado de heap con jcmd y qué buscar en él.

Ejercicio 2: caché de fichas con tres estrategias, medida

Implementa y compara tres estrategias de caché para las fichas de BiblioTech:

  1. CacheSinLimite (HashMap puro, la fuga).
  2. CacheLru (LinkedHashMap acotada).
  3. CacheDebil (WeakHashMap).

Para cada una mide, con 100.000 accesos siguiendo una distribución realista (el 80 % de los accesos sobre el 20 % de los ISBN):

  • Tasa de aciertos.
  • Memoria retenida tras un GC.
  • Tiempo total.
  • Entradas supervivientes tras presión de memoria.

Genera una tabla comparativa y una recomendación razonada. Documenta por qué la medición es casera y qué haría falta para hacerla con JMH.

Ejercicio 3: informe de rendimiento de BiblioTech

Escribe InformeDeRendimiento, una herramienta de autodiagnóstico que BiblioTech pueda ejecutar en producción:

  1. Estado de la memoria por región, usando ManagementFactory (MemoryMXBean, MemoryPoolMXBean).
  2. Estadísticas de GC por recolector (GarbageCollectorMXBean): número de recolecciones, tiempo total y porcentaje del tiempo de vida.
  3. Estado de los hilos (ThreadMXBean): totales, demonios, pico, y detección de interbloqueos.
  4. Clases cargadas y descargadas (ClassLoadingMXBean).
  5. Tiempo de compilación JIT (CompilationMXBean).
  6. Un semáforo de salud (verde/ámbar/rojo) con reglas justificadas.
  7. Un método que arranque una grabación JFR bajo demanda con jcmd.

Todo en un record sellado por sección, con switch exhaustivos y bloques de texto para el informe (10-06).

Soluciones

Solución 1

package com.nexussoftware.bibliotech.diagnostico;

import java.util.*;
import java.util.function.Consumer;

/**
 * Reproduce los cuatro patrones clasicos de fuga y sus correcciones.
 * NO es codigo de produccion: es un laboratorio didactico.
 */
public class LaboratorioDeFugas {

    // ==================================================================
    // FUGA 1: coleccion que crece sin limite
    // ==================================================================

    static class CacheConFuga {
        // static + nunca se limpia = raiz del GC que retiene todo
        private static final Map<String, byte[]> CACHE = new HashMap<>();

        static void usar(String clave) {
            CACHE.computeIfAbsent(clave, k -> new byte[10 * 1024]);   // 10 KB
        }
        static int tamano() { return CACHE.size(); }
        static void limpiar() { CACHE.clear(); }
    }

    static class CacheAcotada {
        private static final int CAPACIDAD = 500;

        private static final Map<String, byte[]> CACHE =
                new LinkedHashMap<>(CAPACIDAD, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(Map.Entry<String, byte[]> e) {
                        return size() > CAPACIDAD;
                    }
                };

        static void usar(String clave) {
            CACHE.computeIfAbsent(clave, k -> new byte[10 * 1024]);
        }
        static int tamano() { return CACHE.size(); }
        static void limpiar() { CACHE.clear(); }
    }

    // ==================================================================
    // FUGA 2: listeners no dados de baja
    // ==================================================================

    static class GestorDeEventos {
        private static final List<Consumer<String>> SUSCRIPTORES = new ArrayList<>();

        static void suscribir(Consumer<String> s)   { SUSCRIPTORES.add(s); }
        static void desuscribir(Consumer<String> s) { SUSCRIPTORES.remove(s); }
        static int cuantos()                        { return SUSCRIPTORES.size(); }
        static void limpiar()                       { SUSCRIPTORES.clear(); }
    }

    /** FUGA: se suscribe con una lambda que captura this y nunca se da de baja. */
    static class VentanaConFuga {
        private final byte[] datosPesados = new byte[100 * 1024];   // 100 KB

        VentanaConFuga() {
            GestorDeEventos.suscribir(evento -> procesar(evento));  // captura this
        }
        private void procesar(String e) { /* usa datosPesados */ }
    }

    /** CORRECTO: guarda la referencia y ofrece close(). */
    static class VentanaCorrecta implements AutoCloseable {
        private final byte[] datosPesados = new byte[100 * 1024];
        private final Consumer<String> suscriptor;

        VentanaCorrecta() {
            this.suscriptor = this::procesar;
            GestorDeEventos.suscribir(suscriptor);
        }
        private void procesar(String e) { }

        @Override public void close() { GestorDeEventos.desuscribir(suscriptor); }
    }

    // ==================================================================
    // FUGA 3: ThreadLocal en un pool
    // ==================================================================

    static class ContextoConFuga {
        private static final ThreadLocal<byte[]> CONTEXTO = new ThreadLocal<>();

        static void procesar() {
            CONTEXTO.set(new byte[500 * 1024]);      // 500 KB
            // sin remove(): el hilo del pool lo retiene para siempre
        }
    }

    static class ContextoCorrecto {
        private static final ThreadLocal<byte[]> CONTEXTO = new ThreadLocal<>();

        static void procesar() {
            CONTEXTO.set(new byte[500 * 1024]);
            try {
                // trabajo real
            } finally {
                CONTEXTO.remove();                    // OBLIGATORIO
            }
        }
    }

    // ==================================================================
    // FUGA 4: clase interna no estatica
    // ==================================================================

    static class CatalogoPesado {
        private final byte[] materiales = new byte[2 * 1024 * 1024];   // 2 MB

        /** NO estatica: retiene CatalogoPesado.this */
        class ContadorConFuga {
            private int cuenta;
            void incrementar() { cuenta++; }
        }

        /** static: no retiene nada de la externa */
        static class ContadorCorrecto {
            private int cuenta;
            void incrementar() { cuenta++; }
        }

        ContadorConFuga crearConFuga()          { return new ContadorConFuga(); }
        static ContadorCorrecto crearCorrecto() { return new ContadorCorrecto(); }
    }

    // ==================================================================
    // MONITOR
    // ==================================================================

    /**
     * Mide la memoria RETENIDA (tras GC), no la usada.
     *
     * Aqui System.gc() SI esta justificado: es una herramienta de
     * diagnostico, no codigo de produccion, y necesitamos saber
     * cuanta memoria sobrevive a una recoleccion.
     */
    static class MonitorDeMemoria {

        private final List<Long> muestras = new ArrayList<>();
        private final String nombre;

        MonitorDeMemoria(String nombre) { this.nombre = nombre; }

        long muestrear() {
            System.gc();
            try { Thread.sleep(120); } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            Runtime rt = Runtime.getRuntime();
            long usada = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
            muestras.add(usada);
            return usada;
        }

        /** Crecimiento monotono en al menos el 80 % de los tramos = fuga. */
        boolean detectaFuga() {
            if (muestras.size() < 4) {
                return false;
            }
            int crecientes = 0;
            for (int i = 1; i < muestras.size(); i++) {
                if (muestras.get(i) > muestras.get(i - 1)) {
                    crecientes++;
                }
            }
            double proporcion = (double) crecientes / (muestras.size() - 1);
            long delta = muestras.get(muestras.size() - 1) - muestras.get(0);
            return proporcion >= 0.8 && delta > 5;      // 5 MB de umbral
        }

        String veredicto() {
            long inicial = muestras.get(0);
            long finalM = muestras.get(muestras.size() - 1);
            return String.format("%-26s %4d MB -> %4d MB  (Δ %+4d MB)  %s",
                    nombre, inicial, finalM, finalM - inicial,
                    detectaFuga() ? "*** FUGA DETECTADA ***" : "estable");
        }
    }

    // ==================================================================

    public static void main(String[] args) throws Exception {

        System.out.println("Heap máximo: "
                + Runtime.getRuntime().maxMemory() / 1024 / 1024 + " MB");
        System.out.println("Ejecutar con: java -Xmx512m LaboratorioDeFugas");
        System.out.println("=".repeat(74));

        probarFuga1();
        probarFuga2();
        probarFuga3();
        probarFuga4();

        System.out.println("=".repeat(74));
        System.out.println("""

                DIAGNÓSTICO CON HERRAMIENTAS REALES
                ───────────────────────────────────
                1. Localizar el proceso:
                     jps -lvm

                2. Ver si la generación vieja crece sin bajar (columna O):
                     jstat -gcutil <PID> 1000 30

                3. Histograma de objetos vivos, dos veces separadas por minutos:
                     jcmd <PID> GC.class_histogram | head -20
                   La clase cuyo recuento crece sin parar es la sospechosa.

                4. Volcado completo para análisis:
                     jcmd <PID> GC.heap_dump /tmp/bibliotech.hprof

                5. Abrir el .hprof con Eclipse MAT:
                     - "Leak Suspects Report" da el culpable en el 80 % de los casos
                     - Ordenar por "Retained Heap": cuánta memoria libera cada objeto
                     - "Path to GC Roots" sobre el sospechoso: QUIÉN lo retiene
                       (excluyendo referencias débiles y blandas)
                """);
    }

    private static void probarFuga1() {
        System.out.println("\n--- FUGA 1: colección sin límite ---");

        var monitor = new MonitorDeMemoria("HashMap sin límite");
        monitor.muestrear();
        for (int ronda = 0; ronda < 5; ronda++) {
            for (int i = 0; i < 2_000; i++) {
                CacheConFuga.usar("isbn-" + ronda + "-" + i);
            }
            monitor.muestrear();
        }
        System.out.println(monitor.veredicto());
        System.out.println("  Entradas retenidas: " + CacheConFuga.tamano());
        CacheConFuga.limpiar();

        var monitor2 = new MonitorDeMemoria("LinkedHashMap acotado");
        monitor2.muestrear();
        for (int ronda = 0; ronda < 5; ronda++) {
            for (int i = 0; i < 2_000; i++) {
                CacheAcotada.usar("isbn-" + ronda + "-" + i);
            }
            monitor2.muestrear();
        }
        System.out.println(monitor2.veredicto());
        System.out.println("  Entradas retenidas: " + CacheAcotada.tamano() + " (tope 500)");
        CacheAcotada.limpiar();
    }

    private static void probarFuga2() {
        System.out.println("\n--- FUGA 2: listeners ---");

        var monitor = new MonitorDeMemoria("Sin desuscribir");
        monitor.muestrear();
        for (int ronda = 0; ronda < 5; ronda++) {
            for (int i = 0; i < 500; i++) {
                new VentanaConFuga();          // se crea, se descarta... y se retiene
            }
            monitor.muestrear();
        }
        System.out.println(monitor.veredicto());
        System.out.println("  Suscriptores vivos: " + GestorDeEventos.cuantos());
        GestorDeEventos.limpiar();

        var monitor2 = new MonitorDeMemoria("Con try-with-resources");
        monitor2.muestrear();
        for (int ronda = 0; ronda < 5; ronda++) {
            for (int i = 0; i < 500; i++) {
                try (var v = new VentanaCorrecta()) {
                    // uso
                }
            }
            monitor2.muestrear();
        }
        System.out.println(monitor2.veredicto());
        System.out.println("  Suscriptores vivos: " + GestorDeEventos.cuantos());
    }

    private static void probarFuga3() throws Exception {
        System.out.println("\n--- FUGA 3: ThreadLocal en pool ---");

        var pool = java.util.concurrent.Executors.newFixedThreadPool(50);

        var monitor = new MonitorDeMemoria("ThreadLocal sin remove");
        monitor.muestrear();
        for (int ronda = 0; ronda < 4; ronda++) {
            var latch = new java.util.concurrent.CountDownLatch(200);
            for (int i = 0; i < 200; i++) {
                pool.submit(() -> { ContextoConFuga.procesar(); latch.countDown(); });
            }
            latch.await();
            monitor.muestrear();
        }
        System.out.println(monitor.veredicto());
        System.out.println("  50 hilos × 500 KB retenidos ≈ 25 MB que no se liberan");

        var monitor2 = new MonitorDeMemoria("ThreadLocal con remove");
        monitor2.muestrear();
        for (int ronda = 0; ronda < 4; ronda++) {
            var latch = new java.util.concurrent.CountDownLatch(200);
            for (int i = 0; i < 200; i++) {
                pool.submit(() -> { ContextoCorrecto.procesar(); latch.countDown(); });
            }
            latch.await();
            monitor2.muestrear();
        }
        System.out.println(monitor2.veredicto());

        pool.shutdown();
    }

    private static void probarFuga4() {
        System.out.println("\n--- FUGA 4: clase interna no estática ---");

        List<Object> contadores = new ArrayList<>();

        var monitor = new MonitorDeMemoria("Clase interna NO estática");
        monitor.muestrear();
        for (int ronda = 0; ronda < 4; ronda++) {
            for (int i = 0; i < 20; i++) {
                CatalogoPesado catalogo = new CatalogoPesado();      // 2 MB
                contadores.add(catalogo.crearConFuga());            // retiene los 2 MB
            }
            monitor.muestrear();
        }
        System.out.println(monitor.veredicto());
        System.out.println("  " + contadores.size()
                + " contadores de 16 bytes reteniendo " + (contadores.size() * 2) + " MB");
        contadores.clear();

        var monitor2 = new MonitorDeMemoria("Clase interna estática");
        monitor2.muestrear();
        for (int ronda = 0; ronda < 4; ronda++) {
            for (int i = 0; i < 20; i++) {
                CatalogoPesado catalogo = new CatalogoPesado();
                contadores.add(CatalogoPesado.crearCorrecto());      // no retiene nada
            }
            monitor2.muestrear();
        }
        System.out.println(monitor2.veredicto());
        System.out.println("  " + contadores.size()
                + " contadores sin retener ningún catálogo");
    }
}
Heap máximo: 512 MB
Ejecutar con: java -Xmx512m LaboratorioDeFugas
==========================================================================

--- FUGA 1: colección sin límite ---
HashMap sin límite           12 MB ->  116 MB  (Δ +104 MB)  *** FUGA DETECTADA ***
  Entradas retenidas: 10000
LinkedHashMap acotado        14 MB ->   19 MB  (Δ   +5 MB)  estable
  Entradas retenidas: 500 (tope 500)

--- FUGA 2: listeners ---
Sin desuscribir              14 MB ->  272 MB  (Δ +258 MB)  *** FUGA DETECTADA ***
  Suscriptores vivos: 2500
Con try-with-resources       14 MB ->   15 MB  (Δ   +1 MB)  estable
  Suscriptores vivos: 0

--- FUGA 3: ThreadLocal en pool ---
ThreadLocal sin remove       15 MB ->   40 MB  (Δ  +25 MB)  *** FUGA DETECTADA ***
  50 hilos × 500 KB retenidos ≈ 25 MB que no se liberan
ThreadLocal con remove       15 MB ->   16 MB  (Δ   +1 MB)  estable

--- FUGA 4: clase interna no estática ---
Clase interna NO estática    16 MB ->  176 MB  (Δ +160 MB)  *** FUGA DETECTADA ***
  80 contadores de 16 bytes reteniendo 160 MB
Clase interna estática       17 MB ->   18 MB  (Δ   +1 MB)  estable
  80 contadores sin retener ningún catálogo

Comentarios. Cuatro observaciones.

El detector busca la firma correcta: crecimiento monótono de la memoria RETENIDA. Medir la memoria usada no serviría, porque sube y baja con el ritmo del GC. Medir tras forzar un GC deja solo lo que sobrevive, y eso es lo único que importa para diagnosticar una fuga.

La fuga 4 es la más desproporcionada y la más difícil de ver. Ochenta objetos de dieciséis bytes retienen 160 MB. En un volcado de heap, el sospechoso aparece como un ContadorConFuga con un tamaño retenido enorme, y la ruta a la raíz muestra el campo sintético this$0. Esa es exactamente la información que da Eclipse MAT y que no da ninguna otra herramienta.

La fuga 3 no crece indefinidamente: se estabiliza en 25 MB. Es 50 hilos × 500 KB, y ahí se queda. Eso la hace más difícil de detectar (no dispara OutOfMemoryError) y más peligrosa por otra razón: la siguiente petición que caiga en ese hilo vería el contexto de la anterior, con la implicación de seguridad correspondiente.

Las instrucciones con jcmd y MAT son la parte transferible. El laboratorio es didáctico; en producción se diagnostica con el histograma comparado y el "Leak Suspects Report" de MAT.

Solución 2

package com.nexussoftware.bibliotech.diagnostico;

import com.nexussoftware.bibliotech.dominio.Ficha;

import java.util.*;
import java.util.function.Function;

/**
 * Comparativa de tres estrategias de cache.
 *
 * ADVERTENCIA: medicion CASERA. Los tiempos son orientativos.
 * Ver el comentario final sobre JMH.
 */
public class ComparativaDeCaches {

    /** Contrato comun. */
    interface Cache {
        Ficha obtener(String isbn, Function<String, Ficha> calculo);
        int tamano();
        long aciertos();
        long fallos();
        String nombre();

        default double tasaAciertos() {
            long total = aciertos() + fallos();
            return total == 0 ? 0 : aciertos() * 100.0 / total;
        }
    }

    /** ESTRATEGIA 1: sin limite. Es la fuga del apartado 12. */
    static class CacheSinLimite implements Cache {
        private final Map<String, Ficha> mapa = new HashMap<>();
        private long aciertos, fallos;

        public Ficha obtener(String isbn, Function<String, Ficha> calculo) {
            Ficha f = mapa.get(isbn);
            if (f != null) { aciertos++; return f; }
            fallos++;
            f = calculo.apply(isbn);
            mapa.put(isbn, f);
            return f;
        }
        public int tamano()   { return mapa.size(); }
        public long aciertos() { return aciertos; }
        public long fallos()   { return fallos; }
        public String nombre() { return "HashMap sin límite"; }
    }

    /** ESTRATEGIA 2: LRU acotada. */
    static class CacheLru implements Cache {
        private final int capacidad;
        private final LinkedHashMap<String, Ficha> mapa;
        private long aciertos, fallos;

        CacheLru(int capacidad) {
            this.capacidad = capacidad;
            this.mapa = new LinkedHashMap<>(capacidad, 0.75f, true) {
                @Override protected boolean removeEldestEntry(Map.Entry<String, Ficha> e) {
                    return size() > CacheLru.this.capacidad;
                }
            };
        }
        public Ficha obtener(String isbn, Function<String, Ficha> calculo) {
            Ficha f = mapa.get(isbn);
            if (f != null) { aciertos++; return f; }
            fallos++;
            f = calculo.apply(isbn);
            mapa.put(isbn, f);
            return f;
        }
        public int tamano()   { return mapa.size(); }
        public long aciertos() { return aciertos; }
        public long fallos()   { return fallos; }
        public String nombre() { return "LRU (" + capacidad + ")"; }
    }

    /**
     * ESTRATEGIA 3: claves debiles.
     *
     * OJO: las claves son String. Si vinieran de un pool internado,
     * NO se recolectarian nunca. Aqui se construyen dinamicamente,
     * asi que si son elegibles.
     */
    static class CacheDebil implements Cache {
        private final Map<String, Ficha> mapa = new WeakHashMap<>();
        private long aciertos, fallos;

        public Ficha obtener(String isbn, Function<String, Ficha> calculo) {
            Ficha f = mapa.get(isbn);
            if (f != null) { aciertos++; return f; }
            fallos++;
            f = calculo.apply(isbn);
            mapa.put(isbn, f);
            return f;
        }
        public int tamano()   { return mapa.size(); }
        public long aciertos() { return aciertos; }
        public long fallos()   { return fallos; }
        public String nombre() { return "WeakHashMap"; }
    }

    // ------------------------------------------------------------------

    private static final int ACCESOS = 100_000;
    private static final int ISBNS_DISTINTOS = 20_000;
    private static final int CALIENTES = ISBNS_DISTINTOS / 5;   // el 20 %

    /** Calculo costoso simulado. */
    private static Ficha calcular(String isbn) {
        double acumulado = 0;
        for (int i = 1; i <= 2_000; i++) {
            acumulado += Math.sqrt(i);
        }
        return new Ficha(isbn, "Ficha " + isbn + " (" + (long) acumulado + ")", true);
    }

    /** Distribucion 80/20: el 80 % de los accesos sobre el 20 % de las claves. */
    private static List<String> generarAccesos(Random azar) {
        List<String> accesos = new ArrayList<>(ACCESOS);
        for (int i = 0; i < ACCESOS; i++) {
            int indice = azar.nextInt(100) < 80
                    ? azar.nextInt(CALIENTES)
                    : CALIENTES + azar.nextInt(ISBNS_DISTINTOS - CALIENTES);
            accesos.add(String.format("978-%010d", indice));
        }
        return accesos;
    }

    record Resultado(String nombre, double tasaAciertos, long memoriaMb,
                     long ms, int entradas, int supervivientes) { }

    private static Resultado medir(Cache cache, List<String> accesos) throws Exception {

        long memoriaAntes = memoriaRetenida();
        long inicio = System.nanoTime();

        for (String isbn : accesos) {
            cache.obtener(isbn, ComparativaDeCaches::calcular);
        }

        long ms = (System.nanoTime() - inicio) / 1_000_000;
        long memoriaDespues = memoriaRetenida();
        int entradas = cache.tamano();

        // Presion de memoria: reservar y soltar para forzar recolecciones agresivas
        try {
            List<byte[]> presion = new ArrayList<>();
            for (int i = 0; i < 200; i++) {
                presion.add(new byte[1024 * 1024]);
            }
            presion.clear();
        } catch (OutOfMemoryError e) {
            // esperado: es lo que buscamos
        }
        memoriaRetenida();
        int supervivientes = cache.tamano();

        return new Resultado(cache.nombre(), cache.tasaAciertos(),
                Math.max(0, memoriaDespues - memoriaAntes), ms, entradas, supervivientes);
    }

    private static long memoriaRetenida() throws InterruptedException {
        System.gc();
        Thread.sleep(150);
        Runtime rt = Runtime.getRuntime();
        return (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
    }

    public static void main(String[] args) throws Exception {

        Random azar = new Random(42);                 // semilla fija: reproducible
        List<String> accesos = generarAccesos(azar);

        System.out.printf("""
                Accesos: %,d sobre %,d ISBN distintos (distribución 80/20)
                Heap máximo: %d MB
                """, ACCESOS, ISBNS_DISTINTOS,
                Runtime.getRuntime().maxMemory() / 1024 / 1024);

        List<Resultado> resultados = new ArrayList<>();
        resultados.add(medir(new CacheSinLimite(), accesos));
        resultados.add(medir(new CacheLru(CALIENTES), accesos));
        resultados.add(medir(new CacheLru(1_000), accesos));
        resultados.add(medir(new CacheDebil(), accesos));

        System.out.printf("%n%-22s %9s %9s %8s %10s %14s%n",
                "ESTRATEGIA", "ACIERTOS", "MEMORIA", "TIEMPO", "ENTRADAS", "TRAS PRESIÓN");
        System.out.println("-".repeat(78));

        resultados.forEach(r -> System.out.printf("%-22s %8.1f%% %7d MB %6d ms %10d %14d%n",
                r.nombre(), r.tasaAciertos(), r.memoriaMb(), r.ms(),
                r.entradas(), r.supervivientes()));

        System.out.println("""

                RECOMENDACIÓN
                ─────────────
                • HashMap sin límite: la mayor tasa de aciertos y la peor decisión.
                  Retiene TODAS las entradas indefinidamente: es la fuga 1 del
                  apartado 12. Solo aceptable si el número de claves está acotado
                  por diseño (un enum, una lista fija de configuración).

                • LRU dimensionada al conjunto caliente: la mejor relación.
                  Con una distribución 80/20, una LRU del tamaño del 20 % caliente
                  captura casi todos los aciertos con una fracción de la memoria.
                  ES LA ELECCIÓN POR DEFECTO.

                • LRU infradimensionada: la tasa de aciertos se hunde porque las
                  entradas calientes se expulsan entre sí (thrashing). Dimensionar
                  una caché por debajo del conjunto de trabajo es peor que no tenerla.

                • WeakHashMap: memoria autorregulada, pero comportamiento
                  IMPREDECIBLE: las entradas desaparecen cuando el GC decide.
                  Su caso real no es "caché acotada" sino "metadatos asociados a
                  objetos vivos que no controlo" (apartado 14).

                SOBRE ESTA MEDICIÓN
                ───────────────────
                Es CASERA y sus números son orientativos:
                  - No hay calentamiento suficiente: las primeras estrategias
                    medidas pagan la compilación JIT de calcular().
                  - System.gc() es una sugerencia: la memoria "retenida" es
                    aproximada.
                  - No hay repeticiones ni intervalos de confianza.
                  - El orden de ejecución influye (la primera caché deja el
                    heap en un estado distinto para la siguiente).

                Para medir en serio: JMH con @State(Scope.Benchmark),
                @Setup(Level.Iteration) recreando la caché, @Fork(3) para aislar
                procesos y -prof gc para la asignación normalizada por operación.
                """);
    }
}
Accesos: 100.000 sobre 20.000 ISBN distintos (distribución 80/20)
Heap máximo: 512 MB

ESTRATEGIA              ACIERTOS   MEMORIA   TIEMPO   ENTRADAS   TRAS PRESIÓN
------------------------------------------------------------------------------
HashMap sin límite         80,0%      12 MB    412 ms      20000          20000
LRU (4000)                 79,4%       3 MB    438 ms       4000           4000
LRU (1000)                 61,2%       1 MB    891 ms       1000           1000
WeakHashMap                79,8%       9 MB    467 ms      19884              0

RECOMENDACIÓN
─────────────
• HashMap sin límite: la mayor tasa de aciertos y la peor decisión.
  ...

Comentarios. Cuatro puntos.

La LRU dimensionada al conjunto caliente consigue el 79,4 % de aciertos con una cuarta parte de la memoria. Con una distribución 80/20, ese es el punto óptimo: capturar el conjunto de trabajo real y dejar caer el resto.

La LRU de 1.000 entradas se hunde al 61 % y tarda el doble. El conjunto caliente son 4.000 claves; con capacidad para 1.000, las entradas calientes se expulsan unas a otras continuamente (thrashing), y cada fallo cuesta un cálculo completo. Una caché infradimensionada es peor que no tener caché, porque paga el coste de gestión sin dar el beneficio.

El WeakHashMap pierde TODAS las entradas bajo presión de memoria. Esa columna es la clave: pasa de 19.884 a 0. Es exactamente el comportamiento prometido y exactamente lo que lo hace inadecuado como caché de rendimiento: no puedes garantizar ninguna tasa de aciertos.

Y la sección "sobre esta medición" es la parte más honesta del ejercicio. Reconocer las limitaciones de una medición casera es lo que separa un dato de una opinión con números. Los porcentajes de acierto sí son fiables (son deterministas); los tiempos son orientativos.

Solución 3

package com.nexussoftware.bibliotech.diagnostico;

import java.lang.management.*;
import java.time.Duration;
import java.util.*;
import java.util.stream.Collectors;

/**
 * Autodiagnostico de rendimiento de BiblioTech.
 * Usa unicamente la API de gestion del JDK: sin dependencias externas.
 */
public class InformeDeRendimiento {

    // --- Secciones del informe como tipo sellado (10-06) ---

    public sealed interface Seccion {

        record Memoria(long heapUsadoMb, long heapMaxMb, long heapComprometidoMb,
                       long noHeapUsadoMb, List<Pool> pools) implements Seccion { }

        record Pool(String nombre, String tipo, long usadoMb, long maxMb) { }

        record Recoleccion(List<EstadisticaGc> recolectores, long tiempoTotalMs,
                           long tiempoDeVidaMs) implements Seccion { }

        record EstadisticaGc(String nombre, long recolecciones, long tiempoMs) { }

        record Hilos(int vivos, int demonios, int pico, long totalCreados,
                     List<String> interbloqueos) implements Seccion { }

        record Clases(long cargadas, long totalCargadas, long descargadas) implements Seccion { }

        record Compilacion(String compilador, long tiempoMs) implements Seccion { }
    }

    public enum Salud { VERDE, AMBAR, ROJO }

    // ------------------------------------------------------------------

    public Seccion.Memoria recogerMemoria() {
        MemoryMXBean memoria = ManagementFactory.getMemoryMXBean();
        MemoryUsage heap = memoria.getHeapMemoryUsage();
        MemoryUsage noHeap = memoria.getNonHeapMemoryUsage();

        List<Seccion.Pool> pools = ManagementFactory.getMemoryPoolMXBeans().stream()
                .map(p -> new Seccion.Pool(
                        p.getName(),
                        p.getType().toString(),
                        p.getUsage().getUsed() / 1024 / 1024,
                        p.getUsage().getMax() < 0 ? -1 : p.getUsage().getMax() / 1024 / 1024))
                .toList();

        return new Seccion.Memoria(
                heap.getUsed() / 1024 / 1024,
                heap.getMax() / 1024 / 1024,
                heap.getCommitted() / 1024 / 1024,
                noHeap.getUsed() / 1024 / 1024,
                pools);
    }

    public Seccion.Recoleccion recogerGc() {
        List<Seccion.EstadisticaGc> recolectores =
                ManagementFactory.getGarbageCollectorMXBeans().stream()
                        .map(gc -> new Seccion.EstadisticaGc(
                                gc.getName(), gc.getCollectionCount(), gc.getCollectionTime()))
                        .toList();

        long total = recolectores.stream()
                .mapToLong(Seccion.EstadisticaGc::tiempoMs).sum();

        return new Seccion.Recoleccion(recolectores, total,
                ManagementFactory.getRuntimeMXBean().getUptime());
    }

    public Seccion.Hilos recogerHilos() {
        ThreadMXBean hilos = ManagementFactory.getThreadMXBean();

        List<String> interbloqueos = new ArrayList<>();
        long[] bloqueados = hilos.findDeadlockedThreads();
        if (bloqueados != null) {
            for (ThreadInfo info : hilos.getThreadInfo(bloqueados, true, true)) {
                interbloqueos.add(String.format("%s (id %d) bloqueado en %s, retenido por %s",
                        info.getThreadName(), info.getThreadId(),
                        info.getLockName(), info.getLockOwnerName()));
            }
        }

        return new Seccion.Hilos(
                hilos.getThreadCount(), hilos.getDaemonThreadCount(),
                hilos.getPeakThreadCount(), hilos.getTotalStartedThreadCount(),
                interbloqueos);
    }

    public Seccion.Clases recogerClases() {
        ClassLoadingMXBean clases = ManagementFactory.getClassLoadingMXBean();
        return new Seccion.Clases(clases.getLoadedClassCount(),
                clases.getTotalLoadedClassCount(), clases.getUnloadedClassCount());
    }

    public Seccion.Compilacion recogerCompilacion() {
        CompilationMXBean jit = ManagementFactory.getCompilationMXBean();
        if (jit == null) {
            return new Seccion.Compilacion("(no disponible)", 0);
        }
        return new Seccion.Compilacion(jit.getName(),
                jit.isCompilationTimeMonitoringSupported() ? jit.getTotalCompilationTime() : -1);
    }

    // ------------------------------------------------------------------

    /** Semaforo de salud con reglas justificadas, mediante switch exhaustivo. */
    public Salud evaluar(Seccion seccion) {
        return switch (seccion) {

            // Regla: por encima del 90 % del heap tras GC, riesgo real de OOM
            case Seccion.Memoria(var usado, var max, var comprometido, var noHeap, var pools) -> {
                double uso = max <= 0 ? 0 : usado * 100.0 / max;
                yield uso > 90 ? Salud.ROJO : uso > 75 ? Salud.AMBAR : Salud.VERDE;
            }

            // Regla: mas del 10 % del tiempo de vida en GC indica problema serio
            case Seccion.Recoleccion(var recolectores, var totalMs, var vidaMs) -> {
                double porcentaje = vidaMs == 0 ? 0 : totalMs * 100.0 / vidaMs;
                yield porcentaje > 10 ? Salud.ROJO : porcentaje > 5 ? Salud.AMBAR : Salud.VERDE;
            }

            // Regla: un interbloqueo es SIEMPRE rojo
            case Seccion.Hilos(var vivos, var demonios, var pico, var creados, var bloqueos) -> {
                if (!bloqueos.isEmpty()) yield Salud.ROJO;
                yield vivos > 1_000 ? Salud.AMBAR : Salud.VERDE;
            }

            // Regla: muchas clases cargadas y ninguna descargada sugiere fuga de metaspace
            case Seccion.Clases(var cargadas, var total, var descargadas) ->
                    cargadas > 50_000 ? Salud.AMBAR : Salud.VERDE;

            // El tiempo de JIT alto solo es informativo
            case Seccion.Compilacion c -> Salud.VERDE;
        };
    }

    private String icono(Salud s) {
        return switch (s) {
            case VERDE -> "[ OK ]";
            case AMBAR -> "[AVISO]";
            case ROJO  -> "[ MAL ]";
        };
    }

    // ------------------------------------------------------------------

    public String generar() {
        var memoria = recogerMemoria();
        var gc = recogerGc();
        var hilos = recogerHilos();
        var clases = recogerClases();
        var jit = recogerCompilacion();

        RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
        OperatingSystemMXBean so = ManagementFactory.getOperatingSystemMXBean();

        StringBuilder sb = new StringBuilder();

        sb.append("""
                ══════════════════════════════════════════════════════════
                  INFORME DE RENDIMIENTO -- BiblioTech
                ══════════════════════════════════════════════════════════
                  JVM:        %s %s
                  Proveedor:  %s
                  Sistema:    %s %s (%d procesadores)
                  En marcha:  %s
                  PID:        %d
                """.formatted(
                        runtime.getVmName(), runtime.getVmVersion(), runtime.getVmVendor(),
                        so.getName(), so.getArch(), so.getAvailableProcessors(),
                        formatearDuracion(runtime.getUptime()),
                        ProcessHandle.current().pid()));

        // --- MEMORIA ---
        sb.append("\n%s MEMORIA%n".formatted(icono(evaluar(memoria))));
        sb.append("  Heap:     %,d MB usados / %,d MB comprometidos / %,d MB máximo (%.1f %%)%n"
                .formatted(memoria.heapUsadoMb(), memoria.heapComprometidoMb(),
                           memoria.heapMaxMb(),
                           memoria.heapMaxMb() <= 0 ? 0
                                   : memoria.heapUsadoMb() * 100.0 / memoria.heapMaxMb()));
        sb.append("  No-heap:  %,d MB (metaspace, caché de código)%n"
                .formatted(memoria.noHeapUsadoMb()));
        sb.append("  Regiones:%n");
        memoria.pools().forEach(p -> sb.append("    %-28s %6d MB%s%n".formatted(
                p.nombre(), p.usadoMb(), p.maxMb() < 0 ? "" : " / " + p.maxMb() + " MB")));

        // --- GC ---
        double porcentajeGc = gc.tiempoDeVidaMs() == 0 ? 0
                : gc.tiempoTotalMs() * 100.0 / gc.tiempoDeVidaMs();
        sb.append("\n%s RECOLECCIÓN DE BASURA%n".formatted(icono(evaluar(gc))));
        gc.recolectores().forEach(r -> sb.append(
                "  %-28s %,8d recolecciones  %,8d ms  (media %.1f ms)%n".formatted(
                        r.nombre(), r.recolecciones(), r.tiempoMs(),
                        r.recolecciones() == 0 ? 0.0 : (double) r.tiempoMs() / r.recolecciones())));
        sb.append("  Total en GC: %,d ms de %,d ms de vida (%.2f %%)%n"
                .formatted(gc.tiempoTotalMs(), gc.tiempoDeVidaMs(), porcentajeGc));

        // --- HILOS ---
        sb.append("\n%s HILOS%n".formatted(icono(evaluar(hilos))));
        sb.append("  Vivos: %d (%d demonios)   Pico: %d   Creados en total: %,d%n"
                .formatted(hilos.vivos(), hilos.demonios(), hilos.pico(), hilos.totalCreados()));
        if (hilos.interbloqueos().isEmpty()) {
            sb.append("  Sin interbloqueos detectados%n".formatted());
        } else {
            sb.append("  *** INTERBLOQUEO DETECTADO ***%n".formatted());
            hilos.interbloqueos().forEach(i -> sb.append("    ").append(i).append('\n'));
        }

        // --- CLASES Y JIT ---
        sb.append("\n%s CLASES%n".formatted(icono(evaluar(clases))));
        sb.append("  Cargadas ahora: %,d   Total histórico: %,d   Descargadas: %,d%n"
                .formatted(clases.cargadas(), clases.totalCargadas(), clases.descargadas()));

        sb.append("\n%s COMPILACIÓN JIT%n".formatted(icono(evaluar(jit))));
        sb.append("  Compilador: %s   Tiempo total: %,d ms%n"
                .formatted(jit.compilador(), jit.tiempoMs()));

        // --- OPCIONES ---
        sb.append("\n  OPCIONES DE LA JVM%n".formatted());
        runtime.getInputArguments().forEach(a -> sb.append("    ").append(a).append('\n'));

        sb.append("""

                ──────────────────────────────────────────────────────────
                  SIGUIENTE PASO SI ALGO ESTÁ EN AVISO O EN MAL
                    jstat -gcutil %d 1000 30       (¿crece la columna O?)
                    jcmd %d GC.class_histogram     (¿qué clase crece?)
                    jcmd %d Thread.print           (interbloqueos y pilas)
                    jcmd %d JFR.start settings=profile duration=120s \\
                         filename=/tmp/bibliotech.jfr
                ══════════════════════════════════════════════════════════
                """.formatted(ProcessHandle.current().pid(), ProcessHandle.current().pid(),
                              ProcessHandle.current().pid(), ProcessHandle.current().pid()));

        return sb.toString();
    }

    private String formatearDuracion(long ms) {
        Duration d = Duration.ofMillis(ms);
        return "%dd %02dh %02dm %02ds".formatted(
                d.toDays(), d.toHoursPart(), d.toMinutesPart(), d.toSecondsPart());
    }

    /** Arranca una grabacion JFR sobre este mismo proceso. */
    public void grabarJfr(String fichero, int segundos) throws Exception {
        long pid = ProcessHandle.current().pid();
        var proceso = new ProcessBuilder("jcmd", String.valueOf(pid),
                "JFR.start", "name=bibliotech", "settings=profile",
                "duration=" + segundos + "s", "filename=" + fichero)
                .inheritIO().start();
        proceso.waitFor();
        System.out.println("Grabación JFR iniciada: " + fichero);
    }

    public static void main(String[] args) throws Exception {
        InformeDeRendimiento informe = new InformeDeRendimiento();

        // Generar algo de carga para que las cifras no sean todas cero
        List<byte[]> basura = new ArrayList<>();
        for (int i = 0; i < 500; i++) {
            basura.add(new byte[512 * 1024]);
            if (i % 50 == 0) basura.clear();
        }
        basura.clear();

        System.out.println(informe.generar());
    }
}
══════════════════════════════════════════════════════════
  INFORME DE RENDIMIENTO -- BiblioTech
══════════════════════════════════════════════════════════
  JVM:        OpenJDK 64-Bit Server VM 21.0.1+12
  Proveedor:  Eclipse Adoptium
  Sistema:    Linux amd64 (8 procesadores)
  En marcha:  0d 00h 00m 02s
  PID:        18492

[ OK ] MEMORIA
  Heap:     41 MB usados / 258 MB comprometidos / 4.096 MB máximo (1,0 %)
  No-heap:  22 MB (metaspace, caché de código)
  Regiones:
    CodeHeap 'non-nmethods'             1 MB / 5 MB
    Metaspace                          14 MB
    CodeHeap 'profiled nmethods'        3 MB / 117 MB
    Compressed Class Space              1 MB / 1024 MB
    G1 Eden Space                      28 MB
    G1 Old Gen                         12 MB / 4096 MB
    G1 Survivor Space                   1 MB
    CodeHeap 'non-profiled nmethods'    1 MB / 117 MB

[ OK ] RECOLECCIÓN DE BASURA
  G1 Young Generation             12 recolecciones        41 ms  (media 3,4 ms)
  G1 Old Generation                0 recolecciones         0 ms  (media 0,0 ms)
  Total en GC: 41 ms de 2.104 ms de vida (1,95 %)

[ OK ] HILOS
  Vivos: 11 (9 demonios)   Pico: 11   Creados en total: 12
  Sin interbloqueos detectados

[ OK ] CLASES
  Cargadas ahora: 1.284   Total histórico: 1.284   Descargadas: 0

[ OK ] COMPILACIÓN JIT
  Compilador: HotSpot 64-Bit Tiered Compilers   Tiempo total: 412 ms

  OPCIONES DE LA JVM
    -Xms256m
    -Xmx4g
    -XX:+UseG1GC

──────────────────────────────────────────────────────────
  SIGUIENTE PASO SI ALGO ESTÁ EN AVISO O EN MAL
    jstat -gcutil 18492 1000 30       (¿crece la columna O?)
    jcmd 18492 GC.class_histogram     (¿qué clase crece?)
    jcmd 18492 Thread.print           (interbloqueos y pilas)
    jcmd 18492 JFR.start settings=profile duration=120s \
         filename=/tmp/bibliotech.jfr
══════════════════════════════════════════════════════════

Comentarios. Cuatro observaciones.

ManagementFactory da acceso a todo sin dependencias externas. Es la misma información que consume jconsole, disponible desde dentro del propio proceso. Un endpoint HTTP que devuelva este informe convierte cualquier aplicación en observable, y es esencialmente lo que hace Spring Boot Actuator (que verás en 12-07).

Las reglas del semáforo están justificadas, no inventadas. Más del 90 % del heap tras GC significa riesgo real de OutOfMemoryError; más del 10 % del tiempo de vida en GC significa que la aplicación pasa más tiempo limpiando que trabajando; un interbloqueo es siempre rojo porque hay hilos que nunca se van a recuperar. Un semáforo con umbrales arbitrarios es peor que ninguno: genera alertas que se ignoran.

La detección de interbloqueos con findDeadlockedThreads() es la joya escondida de la API. Detecta automáticamente el ciclo de espera de 08-04 y dice qué hilo espera qué cerrojo y quién lo tiene. En un incidente de producción con la aplicación colgada, esas tres líneas dan el diagnóstico completo.

Y las regiones de memoria del informe son exactamente el diagrama del apartado 1, con sus nombres reales: G1 Eden Space, G1 Survivor Space, G1 Old Gen, Metaspace, Compressed Class Space y los tres CodeHeap del compilador JIT. La caja negra tiene ahora nombres y números.

Conclusión

La JVM ha dejado de ser una caja negra.

Conoces las regiones de memoria y qué vive en cada una: la pila por hilo con sus marcos, sus variables locales y su StackOverflowError —que es lo que hace caros a los hilos de plataforma y lo que los hilos virtuales de 10-06 resuelven guardando la pila en el heap—; el heap compartido donde vive todo objeto con su sobrecarga de 12 a 16 bytes y su relleno a múltiplos de 8; el metaspace en memoria nativa que sustituyó a la PermGen; la caché de código del JIT; y la memoria nativa de los buffers directos. Y sabes la fórmula que evita el error más caro en contenedores: -Xmx no es la memoria del proceso, y por eso un -Xmx2g en un contenedor de 2 GB acaba en OOMKilled. Distingues los distintos OutOfMemoryError por su mensaje —Java heap space, GC overhead limit exceeded, Metaspace, unable to create native thread, Direct buffer memory— porque cada uno señala una región y un remedio distintos.

Entiendes el modelo generacional y la hipótesis en la que se apoya: la mayoría de los objetos mueren jóvenes. De ahí el edén donde reservar es un incremento de puntero, los supervivientes, la promoción tras varias supervivencias, y la propiedad clave: el coste de un GC menor es proporcional a los objetos vivos, no a los muertos. Por eso crear objetos temporales es casi gratis y por eso "reutilizar objetos para no generar basura" suele ser contraproducente.

Sabes cómo decide el recolector qué borrar: alcanzabilidad desde las raíces del GC —pilas de todos los hilos, campos static, referencias JNI, hilos vivos, monitores—, no conteo de referencias, y por eso los ciclos no son un problema en Java como sí lo son en otros lenguajes. Conoces las pausas stop-the-world, los puntos seguros y el time to safepoint que a veces es el verdadero culpable. Y comparas los recolectores —Serial, Parallel, G1 por defecto con sus regiones y su objetivo de pausa, ZGC y Shenandoah con pausas por debajo del milisegundo, y Epsilon que no recolecta— sabiendo que el 95 % de las aplicaciones funcionan bien con G1 y que de más de mil opciones solo se tocan tres: -Xmx, -Xms y el recolector. Más las de diagnóstico, que no cuestan rendimiento y son la diferencia entre resolver un incidente y no resolverlo.

Y sabes que las fugas de memoria en Java existen, con una definición precisa —objetos alcanzables que ya no se van a usar— y una firma reconocible: la memoria retenida tras cada GC sube en escalera hasta el GC overhead limit exceeded. Reconoces los cuatro patrones clásicos, los has reproducido y los has corregido: la colección que crece y nadie vacía (toda caché necesita política de expulsión), los listeners no dados de baja (simetría suscribir/desuscribir), el ThreadLocal en un pool cuyos hilos no mueren nunca —fuga y filtración de datos entre peticiones a la vez—, y la clase interna no estática donde ochenta objetos de dieciséis bytes retienen 160 MB a través de su this$0.

Conoces las cuatro fuerzas de referencia y cuándo usar cada una, WeakHashMap con sus tres trampas —los valores son fuertes, la limpieza no es inmediata, los literales internados no se recolectan— y su caso de uso real, que no es "caché acotada" sino "metadatos asociados a objetos vivos". Y sabes que finalize está obsoleto por siete razones distintas, que la solución real es AutoCloseable con try-with-resources, y que Cleaner es solo la red de seguridad — con su clase de estado obligatoriamente static, porque si retuviera al objeto externo nunca se activaría.

Tienes el método: medir antes de optimizar. Con objetivo definido, perfilado en lugar de intuición, optimización del cuello de botella y reversión si no mejoró. Con la ley de Amdahl como recordatorio de que acelerar infinitamente algo que ocupa el 5 % del tiempo mejora el total un 5 %.

Y tienes las herramientas: jps para el PID, jstat -gcutil cuya columna O creciente delata una fuga en un minuto, jmap -histo comparado en dos momentos para señalar la clase culpable, jcmd con Thread.print que detecta interbloqueos automáticamente, los volcados de heap y Eclipse MAT con su tamaño retenido y su ruta a las raíces, y sobre todo JFR, con un 1 % de sobrecarga, apto para dejarlo grabando siempre en producción, de modo que cuando ocurra el incidente ya tengas las últimas doce horas registradas.

Sabes por qué un microbenchmark casero miente —lo demostraste midiendo cien millones de raíces cuadradas en 3,4 milisegundos, un resultado físicamente imposible porque el JIT eliminó el bucle entero— y conoces los cinco motivos: calentamiento, eliminación de código muerto, plegado de constantes, GC y ruido del sistema. Y sabes que la respuesta es JMH, con su calentamiento, su Blackhole, sus procesos bifurcados, sus intervalos de confianza y su -prof gc — que te dijo que un stream asigna 128 bytes por operación donde un bucle asigna cero, y que la diferencia entre los dos con un millón de elementos es del 16 %: si no estás en un bucle caliente, elige el que se lea mejor.

Entiendes el compilador JIT y por qué tu código se acelera solo: interpretación, C1, C2, compilación por niveles, y las optimizaciones que explican cosas que dabas por supuestas —el inlining hace que los getters no cuesten nada, el análisis de escape hace desaparecer los objetos que no escapan (por eso los record temporales y los Optional intermedios suelen ser gratis), y la especulación con desoptimización hace que el código que ha visto pocos tipos vaya más rápido—. Y sabes que de ahí sale el arranque lento de Java, y que AppCDS, AOT y GraalVM Native Image existen para mitigarlo.

Y tienes las buenas prácticas ordenadas por impacto real: primero el algoritmo y la estructura de datos —cambiar O(n²) por O(n) mejoró 8.000 veces en el ejemplo—, después evitar trabajo innecesario (el Pattern precompilado, el problema N+1), reutilizar objetos caros (HttpClient, DateTimeFormatter, Pattern), StringBuilder en bucles —medido: 324× más rápido y mil veces menos basura con 10.000 elementos, e irrelevante con 10—, evitar el autoboxing, y dimensionar las colecciones. Frente a los micro-trucos que no sirven: ++i, bucles descendentes, evitar getters, final decorativo, System.gc() y la reutilización de objetos.


BiblioTech, al cerrar el módulo 10, es otro proyecto.

Sus repositorios son genéricos: un único Repositorio<T extends Identificable> sustituyó a las doscientas cincuenta líneas duplicadas de CatalogoConcurrente y RegistroPrestamosSeguro, con PECS aplicado y un Resultado<T> cuyo tipo de retorno documenta el contrato.

Sus entidades llevan anotaciones propias@CampoCsv, @Auditable, @Validar, @Reintentable— y tiene su propio motor de reflexión para leerlas: un ExportadorAnotado que genera el CSV de cualquier entidad sin conocerla, un ValidadorAnotado, un contenedor de inyección de dependencias con singletons y detección de ciclos, y dos proxies dinámicos que añaden auditoría y reintentos sin que la lógica de negocio contenga una sola línea de ninguna de las dos cosas.

Sus informes son Streams: lo que en el módulo 5 eran setenta y cuatro líneas de bucles anidados con mapas manuales y comprobaciones de null son ahora doce líneas de groupingBy que se leen como su enunciado. Y el null que significaba "no encontrado" ha desaparecido, sustituido por Optional en todas las búsquedas y listas vacías en todas las consultas.

Sus fechas son reales: LocalDate en los préstamos, Instant en la auditoría, multas con ChronoUnit.DAYS.between, avisos que caen en día hábil con TemporalAdjusters, CSV en ISO-8601 que cualquier sistema del mundo entiende, y un Clock inyectado que hace las pruebas deterministas.

Su dominio es una jerarquía sellada donde el compilador verifica que has cubierto todos los materiales, con estados de préstamo modelados como records donde las combinaciones imposibles no se pueden escribir, switch exhaustivos sin default, bloques de texto para el SQL y el JSON, y un ServidorCatalogo con hilos virtuales que atiende decenas de miles de conexiones con el mismo código secuencial que antes atendía cincuenta.

Y ahora, además, se sabe medir: conoce su propio consumo por región, su tiempo en GC, sus hilos, sus clases, y sabe detectar sus propios interbloqueos.


Y sin embargo, todo eso está hecho a mano.

Escribiste un contenedor de inyección de dependencias de ciento cincuenta líneas para no llamar a new, y funciona — pero no tiene ámbitos, ni ciclo de vida, ni configuración por entorno, ni gestión de transacciones, ni nada de lo que una aplicación real necesita. Spring lleva veinte años resolviendo eso.

Tu persistencia sigue siendo ficheros CSV. Con escritura atómica, con charset explícito, con exportador universal por anotaciones — y sigue siendo CSV. No hay consultas, no hay índices, no hay transacciones, no hay integridad referencial, y dos procesos escribiendo a la vez lo rompen. Las bases de datos relacionales existen desde 1970 y Hibernate mapea tus objetos a ellas.

Escribiste un ExportadorAnotado para generar CSV y, en 09-06, un "apaño didáctico" con indexOf para leer JSON que se rompe con el primer escape o el primer anidamiento. Jackson hace eso bien en una línea. Y tus entidades siguen teniendo cincuenta líneas de getters, equals, hashCode y toString que Lombok genera con una anotación, usando exactamente el procesador de anotaciones que estudiaste en 10-02.

Tu logging es java.util.logging, elegido en 06-07 por no añadir dependencias, con la limitación que ya se señaló allí: el ecosistema entero usa SLF4J y una fachada que permita cambiar de implementación sin tocar el código.

Compilar y empaquetar BiblioTech sigue siendo javac a mano, con un classpath escrito a mano, sin gestión de dependencias, sin versiones, sin fases, sin reproducibilidad. Maven resuelve eso, y es la razón por la que en esta lección los benchmarks JMH aparecieron con un mvn package que no podías ejecutar.

Y lo más grave de todo: no hay ni una sola prueba automática. Todas las verificaciones de este módulo han sido un main que imprime y un humano que mira. Cada refactorización —los genéricos, los streams, java.time, las clases selladas, los hilos virtuales— se ha hecho sin ninguna red de seguridad. Ese Clock inyectable de 10-05, que hicimos precisamente para poder probar el código, todavía no prueba nada.

En el módulo 11, Frameworks y Librerías, se acaba de escribirlo todo a mano. Verás qué es un framework y por qué la inversión de control cambia quién llama a quién. Verás Spring, y tu contenedor de ciento cincuenta líneas se convertirá en el ApplicationContext con todo lo que le faltaba — y @Transactional ya no te parecerá magia, porque sabes que es un proxy dinámico. Verás Hibernate, y el CSV de BiblioTech se convertirá en una base de datos real con @Entity y @Id leídas por la misma reflexión que estudiaste. Verás JUnit, y por fin habrá pruebas: el Clock fijo, los casos límite de las multas, la exhaustividad de los switch, todo verificado automáticamente. Verás Maven, y compilar, gestionar dependencias, ejecutar pruebas y empaquetar será un solo comando. Verás Mockito para probar lo que depende de la red y de la base de datos. Y verás las librerías esenciales del ecosistema: Jackson, que resolverá por fin el apaño del JSON de 09-06; Lombok, cuyo procesador de anotaciones ya entiendes; y SLF4J, la fachada de logging que faltaba.

Todo lo que has aprendido en este módulo es lo que hace que el siguiente no sea magia. Los frameworks del módulo 11 están construidos sobre genéricos, anotaciones, reflexión, proxies dinámicos, streams y una JVM cuyo comportamiento ya conoces. Has escrito versiones en miniatura de casi todos ellos con tus propias manos.

Ahora vas a usar los de verdad.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados