# Día 4: Memoria JVM, Contrato equals/hashCode y Manejo de Excepciones

> El Ciclo de Vida de los Objetos, Colisiones en Tablas Hash y Try-With-Resources en Java 17/21

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 4 de 30
- Fase 1: Java Vanilla — El Núcleo del Lenguaje (17 & 21)
- Dedicación: 3.5h
- Profundidad: para dominar
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/memoria-jvm-equals-hashcode-excepciones/

## Al terminar este día sabrás

- Escribir `equals` y `hashCode` correctos y explicar cómo los usa un `HashMap`.
- Describir qué va al stack, qué va al heap y cómo trabaja el recolector de basura.
- Decidir entre excepciones comprobadas y no comprobadas en un diseño moderno.
- Cerrar recursos con try-with-resources y entender las excepciones suprimidas.


## Conceptos

### 1. El Contrato Sagrado: equals() y hashCode()

Si dos objetos son iguales según equals(), obligatoriamente deben generar el mismo hashCode numérico. Si esto se rompe, las tablas hash no funcionarán.

**Cómo funciona por dentro.**

`equals` define cuándo dos objetos representan lo mismo; `hashCode` devuelve un entero que las tablas hash usan para decidir en qué cubo guardar el objeto. El contrato dice: si `a.equals(b)` es `true`, entonces `a.hashCode() == b.hashCode()`. Lo contrario no es obligatorio: dos objetos distintos pueden compartir hash (colisión).

Así busca un `HashMap`: calcula `hashCode()` de la clave, lo mezcla y elige un cubo. Dentro del cubo recorre los elementos comparando primero el hash y después con `equals`. Si sobrescribes `equals` sin `hashCode`, dos objetos iguales caerán en cubos distintos y el mapa nunca encontrará el segundo. Desde Java 8, un cubo con muchas colisiones se convierte en un árbol para evitar degradarse a O(n).

`equals` además debe ser reflexivo, simétrico, transitivo, consistente y devolver `false` con `null`. Usa los mismos campos en ambos métodos, `Objects.equals` y `Objects.hash` para no equivocarte con los `null`, o deja que un `record` los genere.

*Contrato correcto y lo que pasa si lo rompes*

```java
public final class Email {
    private final String valor;

    public Email(String valor) {
        this.valor = valor.strip().toLowerCase(Locale.ROOT);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Email otro)) return false;   // pattern matching de instanceof
        return valor.equals(otro.valor);
    }

    @Override
    public int hashCode() {
        return valor.hashCode();                          // mismos campos que equals
    }
}

Set<Email> vistos = new HashSet<>();
vistos.add(new Email("Ana@Acme.com"));
System.out.println(vistos.contains(new Email("ana@acme.com"))); // true

// Si borras hashCode(): contains devuelve false casi siempre,
// porque cada instancia tiene un hash de identidad distinto y cae en otro cubo.
```

- **En una línea:** Sobrescribir ambos usando `Objects.equals(id, otro.id)` y `Objects.hash(id)`.
- **Por qué importa:** Estructuras como HashSet y HashMap dependen de este contrato para encontrar elementos en tiempo constante O(1).
- **Error típico:** Sobrescribir equals() basándose en un campo y olvidar hashCode(), o usar campos mutables en el cálculo del hash.
- **Java 17 frente a 21:** Idéntico en Java 17 y 21. En ambas versiones, los `records` lo implementan automáticamente y de forma óptima.
- **Se conecta con:** HashMap Interno, HashSet, Identidad de Objetos

### 2. Stack vs Heap y Garbage Collection

El Stack almacena variables locales por hilo y se libera al instante. El Heap guarda todas las instancias creadas y lo limpia el Garbage Collector.

**Cómo funciona por dentro.**

Cada hilo tiene su propia pila (stack) dividida en marcos, uno por llamada a método. Un marco guarda parámetros, variables locales primitivas y referencias. Se crea al entrar en el método y desaparece al salir, sin intervención del recolector. El heap es memoria compartida entre hilos donde viven todos los objetos; el recolector de basura (GC) libera los que ya no son alcanzables desde ninguna raíz (variables locales activas, campos estáticos, hilos vivos).

La mayoría de objetos mueren jóvenes, así que los recolectores separan el heap en generación joven y vieja. Recolectar la joven es barato porque casi todo es basura. G1 es el recolector por defecto desde Java 9 y busca pausas predecibles. En Java 21 llegó ZGC generacional (`-XX:+UseZGC -XX:+ZGenerational`), con pausas por debajo del milisegundo incluso con heaps grandes.

Una fuga de memoria en Java no es memoria sin liberar, sino objetos que siguen siendo alcanzables sin que los necesites: cachés estáticas que solo crecen, listeners que nunca se eliminan o `ThreadLocal` que no se limpian en un pool de hilos.

*Observar la memoria de una JVM en marcha*

```bash
# Arrancar con heap fijo y ZGC generacional (Java 21)
java -Xms512m -Xmx512m -XX:+UseZGC -XX:+ZGenerational -jar app.jar

# Registrar la actividad del GC en un fichero
java -Xlog:gc*:file=gc.log:time,uptime -jar app.jar

# Con la aplicación arrancada:
jcmd                                   # lista los procesos Java y sus PID
jcmd 12345 GC.heap_info                # ocupación por generación
jcmd 12345 GC.class_histogram | head   # qué clases ocupan más memoria
jcmd 12345 GC.heap_dump /tmp/heap.hprof  # volcado para analizar con Eclipse MAT o VisualVM
```

- **En una línea:** Al crear `new Tarea()`, la referencia vive en el Stack del hilo y la instancia en el Heap compartido.
- **Por qué importa:** Comprender cómo se generan objetos en el Heap permite evitar sobrecargar el Garbage Collector y prevenir OutOfMemoryError.
- **Error típico:** Creer que llamar a `System.gc()` fuerza la recolección inmediata; solo es una sugerencia que la JVM suele ignorar.
- **Java 17 frente a 21:** Java 21 introduce ZGC Generacional (`-XX:+UseZGC -XX:+ZGenerational`), reduciendo pausas de recolección en servidores de alta carga frente a Java 17.
- **Se conecta con:** Ciclo de Vida de Objetos, Punteros y Referencias, Afinamiento de JVM (-Xmx)

### 3. Excepciones Comprobadas (Checked) vs No Comprobadas (Unchecked)

Las comprobadas (heredan de Exception) obligan al desarrollador a manejarlas con try-catch. Las no comprobadas (RuntimeException) indican errores de programación.

**Cómo funciona por dentro.**

Todas las excepciones heredan de `Throwable`. Debajo hay dos ramas: `Error` (problemas de la JVM como `OutOfMemoryError`, que no se capturan) y `Exception`. Dentro de `Exception`, las que heredan de `RuntimeException` son no comprobadas; el resto son comprobadas y el compilador obliga a capturarlas o declararlas con `throws`.

Las comprobadas se pensaron para errores recuperables que el llamador debe considerar. En la práctica contaminan las firmas de todas las capas intermedias, no encajan con lambdas y streams, y suelen acabar en `catch` vacíos. Por eso el ecosistema moderno (Spring, JPA, la mayoría de librerías) usa excepciones no comprobadas y deja que una capa central las traduzca, por ejemplo a una respuesta HTTP.

Patrón recomendado: excepciones de dominio no comprobadas con nombre de negocio (`TareaNoEncontradaException`), y cuando captures una comprobada de bajo nivel (`IOException`, `SQLException`), envuélvela pasando la original como causa para no perder la traza.

*Excepción de dominio y traducción con causa*

```java
public class TareaNoEncontradaException extends RuntimeException {
    public TareaNoEncontradaException(long id) {
        super("No existe la tarea " + id);
    }
}

public class ExportadorCsv {
    public void exportar(List<TareaDto> tareas, Path destino) {
        try (var writer = Files.newBufferedWriter(destino)) {
            for (var t : tareas) {
                writer.write(t.id() + ";" + t.titulo());
                writer.newLine();
            }
        } catch (IOException e) {
            // Traduce a no comprobada SIN perder la causa original
            throw new UncheckedIOException("No se pudo exportar a " + destino, e);
        }
    }
}

// Antipatrón: tragarse la excepción
// catch (IOException e) { }                  // el fallo desaparece
// catch (IOException e) { e.printStackTrace(); return null; }  // NPE más adelante
```

- **En una línea:** `throw new RecursoNoEncontradoException("ID 5 no existe");` (RuntimeException).
- **Por qué importa:** En arquitecturas modernas de Java (incluyendo Spring Boot), la lógica de negocio debe usar excepciones RuntimeException no comprobadas para no ensuciar las firmas de los métodos.
- **Error típico:** Capturar `Exception` de forma genérica y tragarla con un bloque vacío `{ }` ocultando el fallo.
- **Java 17 frente a 21:** Misma jerarquía en 17 y 21.
- **Se conecta con:** try-with-resources, Manejo Global de Errores, Robustez

### 4. Try-With-Resources y la interfaz AutoCloseable

Estructura sintáctica que garantiza el cierre automático de conexiones, archivos y streams al terminar el bloque try, ocurra o no una excepción.

**Cómo funciona por dentro.**

Cualquier objeto que implemente `AutoCloseable` puede declararse en la cabecera de un `try`. Al salir del bloque, de forma normal o por excepción, Java llama a `close()` automáticamente, en orden inverso a la declaración. Es la forma correcta de manejar ficheros, conexiones JDBC, sockets o streams de `Files.lines`.

Lo que lo hace superior a `finally` es el tratamiento de errores. Si el cuerpo lanza una excepción y después `close()` lanza otra, la principal se conserva y la de `close()` se adjunta como suprimida (`getSuppressed()`). Con `finally` manual, la excepción de `close()` sustituía a la original y perdías la causa real del fallo.

Desde Java 9 puedes usar en la cabecera una variable ya declarada siempre que sea final o efectivamente final.

*Orden de cierre y excepciones suprimidas*

```java
record Recurso(String nombre) implements AutoCloseable {
    @Override
    public void close() {
        System.out.println("cerrando " + nombre);
        throw new IllegalStateException("fallo al cerrar " + nombre);
    }
}

try (var a = new Recurso("A"); var b = new Recurso("B")) {
    throw new RuntimeException("fallo principal");
} catch (RuntimeException e) {
    System.out.println("capturada: " + e.getMessage());
    for (Throwable s : e.getSuppressed()) {
        System.out.println("  suprimida: " + s.getMessage());
    }
}
// cerrando B
// cerrando A
// capturada: fallo principal
//   suprimida: fallo al cerrar B
//   suprimida: fallo al cerrar A
```

- **En una línea:** `try (var reader = new BufferedReader(new FileReader("datos.txt"))) { ... }`
- **Por qué importa:** Evita fugas de memoria y bloqueos de descriptores de ficheros o sockets en el sistema operativo.
- **Error típico:** Cerrar recursos a mano en un bloque `finally` donde el propio `.close()` puede lanzar otra excepción y ocultar la original.
- **Java 17 frente a 21:** Completamente establecido tanto en Java 17 como en 21.
- **Se conecta con:** I/O de Archivos, JDBC, Gestión de Recursos

### 5. Paso por Valor: Java Copia la Referencia, no el Objeto

Java pasa siempre los argumentos por valor. Con primitivos se copia el número; con objetos se copia la referencia. Por eso un método puede modificar el objeto al que apunta, pero no puede hacer que la variable del llamador apunte a otro objeto.

**Cómo funciona por dentro.**

Cuando llamas a un método, Java copia el valor de cada argumento en el parámetro. Para un `int`, ese valor es el número. Para un objeto, lo que guarda la variable no es el objeto, sino una referencia a él (algo parecido a su dirección en el heap), y lo que se copia es esa referencia. El método recibe su propia copia, que apunta al mismo objeto.

De ahí salen las dos reglas que lo explican todo. Si el método usa la referencia para modificar el objeto (`lista.add`, `tarea.setTitulo`), el llamador ve el cambio, porque solo hay un objeto. Si el método reasigna su parámetro (`lista = new ArrayList<>()`), solo cambia su copia de la referencia; la variable del llamador sigue apuntando al objeto original. En un lenguaje con paso por referencia real, como C++ con `&`, esa reasignación sí afectaría al llamador.

Consecuencias prácticas: los objetos mutables que pasas a otros métodos pueden volver cambiados, y por eso los constructores de las clases inmutables hacen copias defensivas (`List.copyOf`). Y si un método necesita «devolver» varios valores, no lo hace modificando parámetros: devuelve un record.

*Qué cambia y qué no cambia fuera del método*

```java
static void vaciar(List<String> lista) {
    lista.clear();                                 // modifica el objeto compartido: se ve fuera
}

static void reemplazar(List<String> lista) {
    lista = new ArrayList<>(List.of("nuevo"));     // cambia solo la copia local de la referencia
}

static void incrementar(int n) {
    n++;                                           // copia del número: no afecta al llamador
}

var compra = new ArrayList<>(List.of("pan", "leche"));
reemplazar(compra);
System.out.println(compra);                        // [pan, leche]
vaciar(compra);
System.out.println(compra);                        // []

int total = 5;
incrementar(total);
System.out.println(total);                         // 5

// Para "devolver" varios valores, un record en lugar de parámetros de salida
record Estadisticas(long total, double media) {}
```

- **En una línea:** `void vaciar(List<String> l) { l.clear(); }` vacía la lista del llamador; `void reemplazar(List<String> l) { l = new ArrayList<>(); }` no cambia nada fuera.
- **Por qué importa:** Es una pregunta trampa clásica de entrevista y explica muchos bugs: métodos que «cambian» una lista que luego aparece modificada, o asignaciones dentro de un método que no tienen ningún efecto fuera.
- **Error típico:** Escribir un método `intercambiar(a, b)` que asigna los parámetros entre sí y esperar que cambien las variables del llamador.
- **Java 17 frente a 21:** Idéntico en Java 17 y 21.
- **Se conecta con:** Stack vs Heap, Inmutabilidad, Copias defensivas

**Compruébalo:** ¿Puede un método `void intercambiar(Integer a, Integer b)` intercambiar los valores de dos variables del llamador?

No. Recibe copias de las referencias, y reasignarlas dentro no cambia las variables de fuera. Además `Integer` es inmutable, así que tampoco puede modificar los objetos.


## Sesión de 90 minutos

### Bloque 1: El Contrato de Identidad (0 - 25 min)

- **Qué estudiar:** Contrato de `equals()` y `hashCode()` en la documentación oficial de `java.lang.Object`.
- **Pregunta a responder:** ¿Por qué `Objects.hash()` es la forma segura de calcular el hash?
- **Entregable:** Clase `UsuarioId` con implementación correcta y test JUnit demostrando que no se pierde en un `HashSet`.

### Bloque 2: Simulador de Colisiones Hash (25 - 50 min)

- **Qué estudiar:** Estructura interna de `HashMap`: buckets, listas enlazadas y árboles rojo-negro.
- **Pregunta a responder:** ¿Cuándo convierte Java una lista de colisiones de un cubo en un árbol binario balanceado?
- **Entregable:** Demostración de 10 claves con el mismo hash funcionando dentro de un HashMap.

### Bloque 3: Manejo de Excepciones y Try-with-resources (50 - 80 min)

- **Qué estudiar:** Jerarquía de `Throwable`, `AutoCloseable` y excepciones suprimidas.
- **Pregunta a responder:** ¿Cómo implementar un recurso propio que se limpie con try-with-resources?
- **Entregable:** Clase `RecursoSeguro implements AutoCloseable` probada con JUnit.

### Cierre de Consolidación (80 - 90 min)

- **Qué estudiar:** Verificación de rúbrica del tutor y flash-recall de las trampas de memoria.
- **Pregunta a responder:** Explicar de memoria el ciclo de vida de un objeto desde que se hace `new` hasta que el GC lo reclama.
- **Entregable:** Anotación final en la base de datos de estudio con los conceptos dominados.


## Explícalo desde cero

### Por qué un HashMap pierde elementos si no sobrescribes hashCode()

- **Qué entender:** El mapa usa el hashCode() para calcular a qué cubo (bucket) va el objeto. Si buscas con un nuevo objeto con el mismo equals() pero distinto hashCode(), buscará en otro cubo y dirá que no existe.
- **Qué suele explicarse mal:** Decir que equals y hashCode hacen "lo mismo". Equals compara si son iguales; hashCode calcula la dirección del cajón donde guardarlo.
- **Ejemplo sencillo:** Un archivador de carpetas clasificadas de la A a la Z. El hashCode decide en qué cajón buscar. Si buscas a "Pérez" en el cajón de la Z, nunca lo encontrarás aunque dentro del cajón supieras reconocerlo.
- **Señal de alerta:** Si dices que HashMap recorre todos los elementos de uno en uno para buscarlos con equals.


## Preguntas

### Nivel 1 (Básico): Si dos objetos tienen el mismo `hashCode()`, ¿significa obligatoriamente que son iguales según `equals()`?

No; se trata de una colisión hash. Dos objetos distintos pueden tener el mismo hash entero y convivir en el mismo bucket mediante una lista enlazada o árbol.

**Respuesta razonada:** No. El contrato solo obliga en un sentido: objetos iguales deben tener el mismo hash. Dos objetos distintos pueden compartir hash, y es inevitable porque `hashCode` devuelve un `int` (unos 4.300 millones de valores) y el número de objetos posibles es infinito. `"Aa"` y `"BB"` tienen el mismo `hashCode` siendo cadenas distintas.

Por eso un `HashMap` usa el hash para elegir cubo y después confirma con `equals`. Un buen `hashCode` reparte bien los valores para que haya pocas colisiones; uno que devuelva siempre la misma constante sería correcto, pero convertiría el mapa en una lista lenta.

### Nivel 2 (Intermedio): ¿Por qué las APIs modernas de Java y Spring Boot prefieren excepciones que hereden de `RuntimeException` en lugar de `Exception` comprobada?

Porque las comprobadas acoplan las firmas de métodos a detalles de infraestructura y obligan a bloques try-catch repetitivos, mientras que las unchecked pueden propagarse limpiamente hasta un `@RestControllerAdvice` centralizado.

**Respuesta razonada:** Porque las excepciones comprobadas obligan a todas las capas intermedias a declararlas o capturarlas aunque no puedan hacer nada útil con ellas. Un `SQLException` del repositorio acabaría en la firma del servicio y del controlador, acoplando capas altas a detalles de infraestructura. Tampoco encajan con lambdas: `Function<T, R>` no declara `throws`.

En Spring la estrategia es dejar subir excepciones no comprobadas y traducirlas en un único punto (`@RestControllerAdvice`) a una respuesta HTTP. Spring además convierte las excepciones de acceso a datos en su jerarquía `DataAccessException`, no comprobada, y `@Transactional` hace rollback por defecto solo ante `RuntimeException` y `Error`.

### Nivel 3 (Entrevista Senior): ¿Qué sucede si dentro de un bloque try-with-resources tanto el código principal como el método `close()` lanzan una excepción?

La excepción del bloque principal es la que se propaga, y la excepción del método `close()` se adjunta como una **excepción suprimida** (`ex.getSuppressed()`), sin perder ninguna de las dos trazas de error.

**Respuesta razonada:** La excepción del cuerpo del `try` es la que se propaga. La de `close()` no se pierde: se añade a la principal como excepción suprimida y se puede consultar con `getSuppressed()`. Aparece en la traza como `Suppressed:`.

Si el cuerpo termina bien y solo falla `close()`, entonces se propaga la excepción de `close()`. Si hay varios recursos, se cierran todos en orden inverso aunque alguno falle, y cada fallo adicional se añade como suprimido.


## Reto del tutor

**Reto:** Un candidato a puesto Java dice: "Para evitar escribir código de más, en mi proyecto capturo `catch (Exception e) { log.error(e.getMessage()); }` en todos los métodos de mis servicios". Evalúa esta afirmación como evaluador senior.

- **Lo que está bien:** Reconoce la intención de registrar los errores para tener observabilidad.
- **Respuesta superficial:** Decir que "está mal porque es mejor capturar excepciones específicas".
- **Lo que falta de fondo:** No entender que atrapar la excepción y no relanzarla silencia el error ("tragar la excepción"), haciendo que el llamante crea que la operación fue exitosa, y loguear solo `e.getMessage()` omite el stacktrace completo impidiendo depurar.
- **Respuesta senior:** Es un anti-patrón crítico en Java corporativo: (1) Traga la excepción y evita que la transacción haga rollback si usa `@Transactional`; (2) `e.getMessage()` omite la causa raíz y las líneas de código del fallo; (3) Los servicios de negocio no deben capturar excepciones genéricas; deben dejar que suban o capturar excepciones específicas para envolverlas en excepciones de dominio con significado.


## Katas

### Kata 4.1: Test de fallo de HashMap sin hashCode

Crea una clase `ProductoId` con `equals()` implementado pero sin `hashCode()`. Escribe un test JUnit que demuestre que `map.get(new ProductoId("P1"))` devuelve `null` a pesar de haberlo insertado previamente.


## Reglas de oro

- Regla de oro: si sobrescribes `equals()`, sobrescribe SIEMPRE `hashCode()`.
- Todo recurso I/O (ficheros, sockets, JDBC) debe abrirse con `try-with-resources`.
- No tragues excepciones vacías; relánzalas o propágalas.


## Java 17 frente a Java 21

| Característica | Java 17 | Java 21 |
| --- | --- | --- |
| Garbage Collector por defecto | G1 GC | G1 GC optimizado / ZGC Generacional disponible |


## Proyecto guiado «Pedidos»: Errores de dominio e identidad

Que cada fallo de negocio tenga nombre propio y que dos pedidos se comparen por lo que son.

**Tareas:**

- Crea excepciones no comprobadas: `PedidoNoEncontradoException`, `TransicionInvalidaException` y `StockInsuficienteException`.
- Implementa `equals` y `hashCode` de `Pedido` por su id y explica en un comentario por qué no por todos los campos.
- Añade un método que exporte un pedido a texto en un fichero usando try-with-resources.

**Está listo cuando:**

- Las transiciones prohibidas lanzan `TransicionInvalidaException` con un mensaje que dice el estado de origen y de destino.
- Un `HashSet<Pedido>` no admite el mismo pedido dos veces aunque cambie de estado.


## Fuentes oficiales

- [Especificación de la JVM 21: áreas de datos en tiempo de ejecución](https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html)
- [Javadoc de Object: contrato de equals y hashCode](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html)
- [Tutorial de Java: excepciones](https://docs.oracle.com/javase/tutorial/essential/exceptions/index.html)
- [JEP 358: NullPointerException más útiles](https://openjdk.org/jeps/358)

