Cada búsqueda de BiblioTech recorre una lista entera. Catalogo.eliminar(referencia) compara con todos los materiales hasta encontrar el suyo. GestorPrestamos.devolver(referencia, dia) hace lo mismo. Agrupar préstamos por empleado exige bucles anidados: para cada empleado, recorrer todos los préstamos. Con cinco materiales no se nota; con cincuenta mil, cada búsqueda son cincuenta mil comparaciones, y el informe agrupado son dos mil quinientos millones de operaciones.

HashMap termina con eso. Es la estructura que encuentra un valor a partir de su clave en tiempo constante, sin importar si el mapa tiene diez elementos o diez millones. No es una mejora incremental sobre recorrer una lista: es un cambio de categoría, de O(n) a O(1), y por eso es —junto con ArrayList— la colección más usada de Java y una de las ideas más importantes de toda la informática.

Esta lección tiene dos mitades. La primera es práctica: la interfaz Map, su API completa y cómo aplicarla a BiblioTech. La segunda es la maquinaria interna: la función hash, las cubetas, las colisiones, el factor de carga y el rehash. Y ahí se cumple la promesa que se hizo explícitamente en 03-09: verás, con una demostración ejecutable, por qué equals y hashCode tenían que ir siempre juntos, y qué le ocurre a un objeto que entra en un mapa con uno de los dos mal implementado. Si alguna vez has oído "sobrescribe siempre los dos" sin entender el motivo, esta es la lección.

Contenido

  1. Qué es un mapa y por qué lo cambia todo
  2. La interfaz Map: operaciones básicas
  3. Recorrer un mapa correctamente
  4. Los métodos que evitan la mitad de tu código
  5. Cómo funciona un HashMap por dentro
  6. Colisiones, listas de colisión y árboles
  7. Factor de carga y rehash
  8. La promesa cumplida: por qué equals y hashCode van juntos
  9. El peligro de las claves mutables
  10. Requisitos de una buena clave
  11. HashMap, LinkedHashMap y TreeMap
  12. Una caché LRU en cinco líneas
  13. Hashtable: el legado
  14. Aplicación a BiblioTech
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. Qué es un mapa y por qué lo cambia todo

Un mapa (también llamado diccionario o tabla asociativa) guarda pares clave → valor. La clave identifica; el valor es lo identificado. Las claves son únicas: cada una lleva a exactamente un valor.

Map<String, Material> indice = new HashMap<>();
indice.put("978-0000000001", javaEfectivo);
indice.put("978-0000000002", patronesDiseno);

Material encontrado = indice.get("978-0000000001");   // instantaneo

Compara ese get con lo que hacías antes:

// Antes: O(n). Con 50.000 materiales, hasta 50.000 comparaciones de cadenas.
public Material buscarPorReferencia(String referencia) {
    for (Material m : materiales) {
        if (m.getReferencia().equals(referencia)) { return m; }
    }
    return null;
}

// Ahora: O(1). Una operacion, siempre, tenga el mapa 10 o 10.000.000 de entradas.
public Material buscarPorReferencia(String referencia) {
    return indice.get(referencia);
}

La diferencia práctica es brutal:

Elementos Búsqueda en List (O(n)) Búsqueda en HashMap (O(1))
10 5 comparaciones de media 1 operación
1 000 500 1
1 000 000 500 000 1
100 000 000 50 000 000 1

Que el coste no crezca en absoluto con el tamaño es lo que hace del mapa una herramienta estructuralmente distinta. Y no es magia: el precio es memoria adicional y el requisito de que las claves tengan un hashCode correcto. El apartado 5 explica cómo se consigue.

Recuerda de 05-02 que Map no es una Collection: guarda pares, no elementos sueltos, y add(x), contains(x) o iterator() serían ambiguos en él. Se conecta con el resto del Framework a través de sus tres vistas: keySet(), values() y entrySet().

  1. La interfaz Map: operaciones básicas

import java.util.HashMap;
import java.util.Map;

Map<String, Material> indice = new HashMap<>();

Los dos parámetros de tipo son, por este orden, el tipo de la clave y el tipo del valor. Map<String, Material> es "un mapa cuyas claves son cadenas y cuyos valores son materiales". Como en 05-02, léelo así y deja la teoría para 10-01.

put: insertar o sustituir

Material anterior = indice.put("978-0000000001", javaEfectivo);
System.out.println(anterior);      // null: no habia nada con esa clave

Material previo = indice.put("978-0000000001", otraEdicion);
System.out.println(previo);        // el javaEfectivo anterior: lo ha SUSTITUIDO

put devuelve el valor anterior asociado a esa clave, o null si no había ninguno. Es un detalle útil: permite saber si estabas insertando o reemplazando sin hacer una consulta previa.

Y la propiedad fundamental: si la clave ya existe, el valor se sustituye. Un mapa nunca tiene dos entradas con la misma clave.

get y getOrDefault

Material m = indice.get("978-0000000001");     // el valor, o null si no esta
Material x = indice.get("no-existe");          // null

// getOrDefault evita el null y toda una familia de comprobaciones
int plazo = plazosPorTipo.getOrDefault("Audiolibro", 15);   // 15 si no esta registrado

getOrDefault(clave, porDefecto) es de los métodos más útiles del Framework. Sustituye esto:

Integer valor = mapa.get(clave);
int resultado = (valor == null) ? 0 : valor;

por esto:

int resultado = mapa.getOrDefault(clave, 0);

containsKey, containsValue, remove

boolean estaCatalogado = indice.containsKey("978-0000000001");   // O(1)
boolean apareceElLibro = indice.containsValue(javaEfectivo);     // O(n): recorre TODO

Material quitado = indice.remove("978-0000000001");   // devuelve el valor eliminado, o null
boolean fueQuitado = indice.remove("978-0000000002", patronesDiseno);  // solo si coincide el valor

Fíjate en la asimetría: containsKey es O(1) y containsValue es O(n). El mapa está indexado por clave, no por valor; buscar un valor obliga a recorrer todas las entradas. Si necesitas buscar en ambas direcciones a menudo, mantén dos mapas o replantea el diseño.

size, isEmpty, clear

System.out.println(indice.size());        // numero de pares
System.out.println(indice.isEmpty());
indice.clear();

  1. Recorrer un mapa correctamente

Un mapa ofrece tres vistas, y elegir la correcta importa para el rendimiento.

Map<String, Material> indice = new HashMap<>();
// ... lleno ...

// 1. Solo las claves
for (String referencia : indice.keySet()) {
    System.out.println(referencia);
}

// 2. Solo los valores
for (Material m : indice.values()) {
    System.out.println(m.getTitulo());
}

// 3. Claves Y valores: SIEMPRE con entrySet
for (Map.Entry<String, Material> entrada : indice.entrySet()) {
    System.out.printf("%-16s -> %s%n", entrada.getKey(), entrada.getValue().getTitulo());
}

La tercera es la importante. El error frecuente es este:

// INEFICIENTE: dos operaciones por entrada
for (String referencia : indice.keySet()) {
    Material m = indice.get(referencia);       // busqueda extra, innecesaria
    System.out.println(referencia + " -> " + m.getTitulo());
}

Cada get es una búsqueda completa (calcular hash, localizar cubeta, comparar). Con entrySet, la clave y el valor llegan juntos en el mismo objeto Map.Entry, sin ninguna búsqueda adicional. Cuando necesites clave y valor, usa siempre entrySet.

Map.Entry es una interfaz anidada (04-03) que representa un par:

Método de Map.Entry Qué hace
getKey() La clave
getValue() El valor
setValue(v) Cambia el valor en el mapa: es una vista, no una copia

Ese setValue permite modificar durante el recorrido de forma segura:

for (Map.Entry<String, Integer> e : contadores.entrySet()) {
    e.setValue(e.getValue() + 1);      // legal y eficiente
}

Y forEach sobre un mapa recibe un BiConsumer (04-06), que acepta los dos argumentos:

indice.forEach((referencia, material) ->
    System.out.printf("%-16s -> %s%n", referencia, material.getTitulo()));

Las tres vistas son vistas vivas, no copias: eliminar de keySet() elimina la entrada del mapa.

indice.keySet().remove("978-0000000001");     // elimina la entrada COMPLETA
indice.values().removeIf(m -> !m.estaDisponible());   // elimina las entradas cuyos valores cumplan

Y, como cualquier colección, modificarlas durante un for-each provoca ConcurrentModificationException, con las mismas soluciones de 05-02.

Un aviso que se repetirá: el orden de recorrido de un HashMap no está garantizado y puede cambiar al insertar elementos. Nunca escribas código —ni tests— que dependa de él. Si necesitas orden, usa LinkedHashMap o TreeMap (apartado 11).

  1. Los métodos que evitan la mitad de tu código

Java 8 añadió a Map un grupo de métodos que resuelven patrones cotidianos y que mucha gente sigue sin usar, escribiendo cinco líneas donde bastaría una.

putIfAbsent

// En vez de:
if (!indice.containsKey(ref)) { indice.put(ref, material); }

// Escribe:
indice.putIfAbsent(ref, material);

Inserta solo si la clave no estaba (o su valor era null). Devuelve el valor existente, o null si insertó.

computeIfAbsent: el rey de los mapas de listas

Este es, probablemente, el método más útil de toda la interfaz. Resuelve el patrón "agrupar elementos por una clave":

// SIN computeIfAbsent: el patron clasico, verboso y facil de romper
Map<Empleado, List<Prestamo>> porEmpleado = new HashMap<>();
for (Prestamo p : prestamos) {
    List<Prestamo> lista = porEmpleado.get(p.getEmpleado());
    if (lista == null) {
        lista = new ArrayList<>();
        porEmpleado.put(p.getEmpleado(), lista);
    }
    lista.add(p);
}

// CON computeIfAbsent: una linea
for (Prestamo p : prestamos) {
    porEmpleado.computeIfAbsent(p.getEmpleado(), k -> new ArrayList<>()).add(p);
}

Se lee así: "dame la lista asociada a este empleado; si no existe, créala con esta función, guárdala y devuélvemela". El resultado siempre es una lista válida, así que puedes encadenar el .add(p) con total seguridad.

La función recibe la clave como argumento, lo que a veces resulta útil:

Map<String, List<Material>> porTipo = new HashMap<>();
for (Material m : catalogo) {
    porTipo.computeIfAbsent(m.getTipo(), tipo -> new ArrayList<>()).add(m);
}

Un aviso importante: la función solo se ejecuta si la clave no está. Eso lo hace eficiente (no crea listas inútiles) pero también significa que no debe tener efectos colaterales de los que dependas.

merge: el rey de los contadores

Resuelve el patrón "acumular por clave":

// SIN merge
Map<String, Integer> contadorPorTipo = new HashMap<>();
for (Material m : catalogo) {
    Integer actual = contadorPorTipo.get(m.getTipo());
    contadorPorTipo.put(m.getTipo(), (actual == null) ? 1 : actual + 1);
}

// CON merge
for (Material m : catalogo) {
    contadorPorTipo.merge(m.getTipo(), 1, Integer::sum);
}

merge(clave, valorInicial, funcionDeCombinacion) funciona así: si la clave no está, guarda valorInicial; si está, aplica la función al valor existente y al nuevo, y guarda el resultado. Integer::sum es la referencia a método (04-06) que suma dos enteros.

Sirve para cualquier acumulación, no solo contar:

Map<Empleado, Double> multaPorEmpleado = new HashMap<>();
for (Prestamo p : prestamos) {
    multaPorEmpleado.merge(p.getEmpleado(), p.calcularMulta(diaActual), Double::sum);
}

Map<String, String> titulosPorTipo = new HashMap<>();
for (Material m : catalogo) {
    titulosPorTipo.merge(m.getTipo(), m.getTitulo(), (a, b) -> a + ", " + b);
}

Una alternativa igual de legible para contar, con getOrDefault:

contadorPorTipo.put(tipo, contadorPorTipo.getOrDefault(tipo, 0) + 1);

compute y computeIfPresent

// compute: recalcula SIEMPRE, con el valor actual (que puede ser null)
contadores.compute("Libro", (k, v) -> (v == null) ? 1 : v + 1);

// computeIfPresent: solo actua si la clave YA existe
inventario.computeIfPresent("978-0000000001", (k, v) -> v - 1);

Un detalle importante de los tres: si la función devuelve null, la entrada se elimina del mapa. Es un idioma útil para limpiar sobre la marcha:

// Descuenta una unidad y elimina la entrada cuando llega a cero
inventario.computeIfPresent(isbn, (k, v) -> (v <= 1) ? null : v - 1);

replaceAll

tarifas.replaceAll((tipo, tarifa) -> tarifa * 1.10);    // subida del 10 % a todo

Tabla resumen

Método Cuándo usarlo
getOrDefault(k, def) Leer con valor por defecto, sin comprobar null
putIfAbsent(k, v) Insertar solo si no estaba
computeIfAbsent(k, f) Mapas de listas o conjuntos: agrupar por clave
computeIfPresent(k, f) Actualizar solo lo que ya existe
compute(k, f) Recalcular siempre, exista o no
merge(k, v, f) Contadores y acumuladores
replaceAll(f) Transformar todos los valores
forEach(bc) Recorrer clave y valor con un BiConsumer

Estos siete métodos eliminan una cantidad enorme de código repetitivo. En el módulo 10, los Streams añadirán Collectors.groupingBy y counting(), que expresan lo mismo de forma aún más declarativa; hasta entonces, computeIfAbsent y merge son tus herramientas.

  1. Cómo funciona un HashMap por dentro

Ahora la maquinaria. Entenderla es lo que te permitirá usar mapas sin sorpresas.

Un HashMap es, por dentro, un array de "cubetas" (buckets). Cada cubeta puede contener cero, una o varias entradas.

transient Node<K,V>[] table;          // el array de cubetas
transient int size;                   // numero de pares almacenados
int threshold;                        // capacidad * factor de carga
final float loadFactor;               // 0.75 por defecto

La idea, en tres pasos:

Paso 1: calcular el hash de la clave. Se llama a clave.hashCode(), que devuelve un int (unos 4300 millones de valores posibles).

"978-0000000001".hashCode();     // por ejemplo, -1234567890

Paso 2: convertir ese hash en un índice del array. Como el array tiene, por ejemplo, 16 cubetas, hay que reducir el hash a un número entre 0 y 15. HashMap usa una operación de bits equivalente a hash % 16, pero muchísimo más rápida:

int indice = (table.length - 1) & hash;    // equivale a hash % 16 cuando length es potencia de 2

Por eso la capacidad de un HashMap es siempre una potencia de dos: permite sustituir el módulo (una división, cara) por un AND de bits (una instrucción). Además, HashMap aplica antes una función de mezcla que combina los bits altos con los bajos, para que claves cuyos hashes solo difieran en los bits altos no acaben todas en la misma cubeta:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

Paso 3: guardar o buscar en esa cubeta.

flowchart TB
    K["clave: '978-0000000001'"] --> H["hashCode() → -1234567890"]
    H --> M["mezcla de bits: h XOR (h >>> 16)"]
    M --> I["indice = (16-1) AND hash → 7"]
    I --> T["table[7]"]

    subgraph tabla["Object[] table (capacidad 16)"]
        B0["[0] null"]
        B1["[1] → (ISBN-2, Patrones)"]
        B2["[2] null"]
        B7["[7] → (ISBN-1, Java Efectivo)"]
        B9["[9] → (REV-2024-03, Java Magazine)"]
        B15["[15] null"]
    end
    T --> B7

Y ahí está el truco: encontrar la cubeta correcta no requiere buscar nada. Se calcula. Da igual que el mapa tenga 10 entradas o 10 millones: calcular el hash y el índice cuesta lo mismo. Eso es O(1).

Buscar es exactamente el mismo proceso:

Material m = indice.get("978-0000000001");
// 1. hash de la clave -> 2. indice de cubeta -> 3. mirar en esa cubeta

  1. Colisiones, listas de colisión y árboles

Un int tiene unos 4300 millones de valores, pero el array solo tiene 16 cubetas. Es inevitable que dos claves distintas acaben en la misma: eso es una colisión.

HashMap las resuelve con encadenamiento: cada cubeta guarda una lista enlazada de entradas.

flowchart LR
    subgraph tabla["table"]
        B0["[0] null"]
        B3["[3] →"]
        B7["[7] →"]
    end
    B3 --> E1["(clave A, valor 1)"]
    E1 --> E2["(clave B, valor 2)"]
    E2 --> E3["(clave C, valor 3)"]
    B7 --> E4["(clave D, valor 4)"]

Las claves A, B y C tienen hashes distintos pero caen en la cubeta 3. Al buscar A:

  1. Se calcula su hash y el índice: cubeta 3.
  2. Se recorre la lista de esa cubeta comparando: primero por hash (rápido, es un int) y, si coincide, con equals (la comparación real).

Ahí está el papel de cada método, y es la clave de todo: hashCode decide en qué cubeta buscar; equals decide cuál de las entradas de esa cubeta es la correcta. Los dos son imprescindibles, y por eso deben ser coherentes.

El coste real

Situación Coste de get
Sin colisiones (caso normal) O(1)
Pocas colisiones O(1) con una constante algo mayor
Todas las claves en la misma cubeta O(n): degenera en una lista

El peor caso ocurre si hashCode está mal implementado. El ejemplo extremo:

@Override public int hashCode() { return 42; }    // legal, pero catastrofico

Es técnicamente correcto —el contrato solo exige que objetos iguales tengan el mismo hash—, pero todas las claves caerían en la misma cubeta y el mapa se comportaría como una lista enlazada: O(n) en cada operación.

La conversión a árbol (Java 8+)

Para limitar el daño, desde Java 8 HashMap vigila la longitud de cada cubeta. Si una cubeta acumula 8 o más entradas (y la tabla tiene al menos 64 cubetas), la lista de esa cubeta se convierte en un árbol rojo-negro, un árbol binario equilibrado:

flowchart TB
    subgraph antes["Cubeta con 8+ entradas: LISTA → O(n)"]
        L1["e1"] --> L2["e2"] --> L3["e3"] --> L4["e4"] --> L5["..."] --> L8["e8"]
    end
    subgraph despues["Convertida en ARBOL → O(log n)"]
        R["e4"] --> A1["e2"]
        R --> A2["e6"]
        A1 --> B1["e1"]
        A1 --> B2["e3"]
        A2 --> B3["e5"]
        A2 --> B4["e7"]
    end

Con esto, el peor caso pasa de O(n) a O(log n). Es una red de seguridad, no una excusa: si tus cubetas se convierten en árboles, tu hashCode está mal repartido. (Si la cubeta baja de 6 entradas, vuelve a ser una lista.)

Este cambio se introdujo por motivos de seguridad: existía un ataque de denegación de servicio que enviaba a un servidor miles de claves con el mismo hash para degradar sus mapas a O(n).

  1. Factor de carga y rehash

Cuantas más entradas haya para el mismo número de cubetas, más colisiones. El factor de carga (loadFactor) es el umbral de ocupación a partir del cual HashMap decide crecer. Su valor por defecto es 0,75.

umbral = capacidad × factorDeCarga

Con la capacidad inicial de 16: umbral = 16 × 0,75 = 12. Al insertar la entrada número 13, se dispara un rehash:

  1. Se crea un array nuevo del doble de cubetas (32).
  2. Se recoloca cada entrada, recalculando su índice con la nueva capacidad.
  3. Se descarta el array antiguo.
flowchart LR
    A["16 cubetas<br/>12 entradas<br/>umbral alcanzado"] --> B["se inserta la 13"]
    B --> C["new Node[32]"]
    C --> D["recolocar las 13 entradas<br/>indice = (32-1) AND hash"]
    D --> E["32 cubetas<br/>nuevo umbral: 24"]

El rehash es O(n) y es la razón de que put sea O(1) amortizado y no O(1) puro, exactamente igual que el redimensionado de ArrayList (05-03). Como la capacidad se duplica, el coste medio por inserción sigue siendo constante.

Por qué 0,75

Es un compromiso medido:

Factor de carga Colisiones Memoria desperdiciada Rehashes
0,5 Muy pocas Mucha (mitad del array vacío) Frecuentes
0,75 Pocas Razonable Razonables
1,0 Bastantes Ninguna Raros
2,0 Muchas: se degrada a listas Ninguna Muy raros

Con 0,75 y un hashCode decente, la probabilidad de que una cubeta tenga más de un elemento es baja, y el desperdicio de memoria es de un 25 %. Cámbialo solo si tienes una medición que lo justifique.

Dimensionar bien un mapa grande

Si sabes cuántas entradas vas a meter, dimensiona el mapa al construirlo y ahorra todos los rehashes:

// Mal: 50.000 entradas provocan unos 12 rehashes, con recolocaciones O(n) cada vez
Map<String, Material> indice = new HashMap<>();

// Bien: capacidad suficiente desde el principio
Map<String, Material> indice = new HashMap<>((int) (50_000 / 0.75f) + 1);

La fórmula entradasPrevistas / 0.75 + 1 garantiza que no haya ningún rehash. Ojo con el error clásico de escribir new HashMap<>(50_000): eso indica capacidad, no número de entradas, y con factor 0,75 el rehash saltaría al llegar a 37 500.

Como siempre: esto importa con decenas de miles de entradas. Con doscientas, new HashMap<>() es perfecto.

  1. La promesa cumplida: por qué equals y hashCode van juntos

Ha llegado el momento. En 03-09 aprendiste el contrato:

Si a.equals(b) es true, entonces a.hashCode() == b.hashCode() debe ser true.

(Lo contrario no se exige: dos objetos distintos pueden compartir hash.)

Y se prometió que en el módulo 5 verías por qué. Aquí está, con código ejecutable.

Experimento 1: equals sí, hashCode no

Una clase que sobrescribe equals correctamente pero olvida hashCode:

package com.nexussoftware.bibliotech.dominio;

import java.util.Objects;

/** Clave compuesta de un ejemplar. equals correcto, hashCode AUSENTE. */
public class ClaveEjemplarRota {

    private final String isbn;
    private final int    numeroEjemplar;

    public ClaveEjemplarRota(String isbn, int numeroEjemplar) {
        this.isbn = isbn;
        this.numeroEjemplar = numeroEjemplar;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) { return true; }
        if (!(o instanceof ClaveEjemplarRota otra)) { return false; }
        return numeroEjemplar == otra.numeroEjemplar && Objects.equals(isbn, otra.isbn);
    }

    // FALTA hashCode: se hereda el de Object, basado en la direccion de memoria
}

Y el resultado:

ClaveEjemplarRota c1 = new ClaveEjemplarRota("978-0000000001", 1);
ClaveEjemplarRota c2 = new ClaveEjemplarRota("978-0000000001", 1);

System.out.println("c1.equals(c2): " + c1.equals(c2));            // true: son "iguales"
System.out.println("hash c1: " + c1.hashCode());                  // 1735600054  (por ejemplo)
System.out.println("hash c2: " + c2.hashCode());                  // 21685669    DISTINTO

Map<ClaveEjemplarRota, String> ubicaciones = new HashMap<>();
ubicaciones.put(c1, "Estanteria A-3");

System.out.println("get(c1): " + ubicaciones.get(c1));            // Estanteria A-3
System.out.println("get(c2): " + ubicaciones.get(c2));            // null   <-- EL PROBLEMA
System.out.println("containsKey(c2): " + ubicaciones.containsKey(c2));  // false
System.out.println("size: " + ubicaciones.size());                // 1

ubicaciones.put(c2, "Estanteria B-1");
System.out.println("size tras poner c2: " + ubicaciones.size());  // 2  <-- CLAVE DUPLICADA

El objeto ha desaparecido del mapa. Y peor: ahora hay dos claves iguales en un mapa que garantiza claves únicas.

Sigue el rastro paso a paso:

flowchart TB
    P["put(c1, 'A-3')"] --> P1["c1.hashCode() = 1735600054"]
    P1 --> P2["indice = 6 → guardado en table[6]"]

    G["get(c2)"] --> G1["c2.hashCode() = 21685669"]
    G1 --> G2["indice = 5 → mira en table[5]"]
    G2 --> G3["table[5] esta VACIA"]
    G3 --> G4["devuelve null: equals NUNCA se llega a llamar"]

Ahí está la explicación completa: get busca en la cubeta que indica hashCode. Si el hash es distinto, mira en otra cubeta, y equals no se ejecuta jamás. Que c1.equals(c2) sea true es irrelevante si nunca llegan a compararse.

Y put(c2, ...) añade una segunda entrada porque, desde el punto de vista del mapa, c2 va a otra cubeta y no hay nada con qué compararla.

Experimento 2: la versión correcta

package com.nexussoftware.bibliotech.dominio;

import java.util.Objects;

/** La misma clave, con equals y hashCode COHERENTES. */
public class ClaveEjemplar {

    private final String isbn;
    private final int    numeroEjemplar;

    public ClaveEjemplar(String isbn, int numeroEjemplar) {
        this.isbn = Objects.requireNonNullElse(isbn, "");
        this.numeroEjemplar = numeroEjemplar;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) { return true; }
        if (!(o instanceof ClaveEjemplar otra)) { return false; }
        return numeroEjemplar == otra.numeroEjemplar && isbn.equals(otra.isbn);
    }

    /** Los MISMOS campos que usa equals, ni uno mas ni uno menos. */
    @Override
    public int hashCode() {
        return Objects.hash(isbn, numeroEjemplar);
    }

    @Override
    public String toString() {
        return isbn + "#" + numeroEjemplar;
    }
}
ClaveEjemplar c1 = new ClaveEjemplar("978-0000000001", 1);
ClaveEjemplar c2 = new ClaveEjemplar("978-0000000001", 1);

System.out.println("hash c1: " + c1.hashCode());     // 1635958654
System.out.println("hash c2: " + c2.hashCode());     // 1635958654   IGUAL

Map<ClaveEjemplar, String> ubicaciones = new HashMap<>();
ubicaciones.put(c1, "Estanteria A-3");

System.out.println("get(c2): " + ubicaciones.get(c2));   // Estanteria A-3   <-- FUNCIONA
ubicaciones.put(c2, "Estanteria B-1");
System.out.println("size: " + ubicaciones.size());       // 1: c2 SUSTITUYO a c1

Ahora sí. Mismo hash → misma cubeta → equals se ejecuta → el mapa reconoce que son la misma clave.

Y por eso los record de 04-07 son claves perfectas: generan automáticamente equals y hashCode a partir de todos sus componentes, siempre coherentes entre sí.

public record ClaveEjemplarRecord(String isbn, int numeroEjemplar) { }   // ya es una clave valida

La regla, en tres líneas

Situación Consecuencia
equals sí, hashCode no El objeto desaparece de mapas y conjuntos; aparecen duplicados
hashCode sí, equals no Van a la misma cubeta, pero equals compara referencias: tampoco se encuentran
Ambos, incoherentes entre sí Comportamiento impredecible según los campos que use cada uno
Ambos, sobre los mismos campos Correcto

Y la regla operativa definitiva: si sobrescribes uno, sobrescribe el otro, y hazlo sobre exactamente los mismos campos. Todos los IDE generan ambos a la vez precisamente por esto.

  1. El peligro de las claves mutables

Hay un segundo modo de romper un mapa, más sutil y más difícil de depurar: mutar una clave después de haberla insertado.

package com.nexussoftware.bibliotech.dominio;

import java.util.Objects;

/** Clave MUTABLE: el campo isbn puede cambiar. Mala idea. */
public class ClaveMutable {
    private String isbn;                       // NO es final

    public ClaveMutable(String isbn) { this.isbn = isbn; }
    public void setIsbn(String isbn) { this.isbn = isbn; }    // el veneno

    @Override public boolean equals(Object o) {
        return (o instanceof ClaveMutable c) && Objects.equals(isbn, c.isbn);
    }
    @Override public int hashCode() { return Objects.hash(isbn); }
    @Override public String toString() { return "Clave[" + isbn + "]"; }
}
ClaveMutable clave = new ClaveMutable("978-0000000001");
Map<ClaveMutable, String> mapa = new HashMap<>();
mapa.put(clave, "Java Efectivo");

System.out.println(mapa.get(clave));        // Java Efectivo    correcto

clave.setIsbn("978-0000000002");            // MUTAMOS la clave YA INSERTADA

System.out.println(mapa.get(clave));        // null   <-- el objeto se ha perdido
System.out.println(mapa.size());            // 1      <-- pero sigue ahi dentro
System.out.println(mapa.containsKey(clave));// false
System.out.println(mapa);                   // {Clave[978-0000000002]=Java Efectivo}

La entrada sigue existiendo, se ve al imprimir el mapa, cuenta para el size... y es inalcanzable.

flowchart TB
    A["put(clave, valor)<br/>hashCode = 1234 → cubeta 2"] --> B["la entrada queda en table[2]"]
    B --> C["clave.setIsbn(...)<br/>ahora hashCode = 9876"]
    C --> D["get(clave): indice = 9876 mod 16 → cubeta 4"]
    D --> E["table[4] no tiene esa entrada"]
    E --> F["null: la entrada de table[2] es INALCANZABLE"]

El mapa colocó la entrada en la cubeta correspondiente al hash que la clave tenía en el momento de insertarla. Al cambiar el hash, nadie recoloca nada: el mapa no vigila sus claves. La entrada queda huérfana, ocupando memoria y sin poder recuperarse ni siquiera con remove.

Este problema es especialmente traicionero con colecciones como clave:

List<String> lista = new ArrayList<>(List.of("a", "b"));
Map<List<String>, String> mapa = new HashMap<>();
mapa.put(lista, "valor");

lista.add("c");                         // el hashCode de la lista DEPENDE de su contenido (05-03)
System.out.println(mapa.get(lista));    // null

Y con objetos de dominio mutables:

// Si Empleado tuviera equals/hashCode basados en el nombre y el nombre pudiera cambiar:
Map<Empleado, List<Prestamo>> porEmpleado = new HashMap<>();
porEmpleado.put(marta, prestamosDeMarta);
marta.setNombre("Marta Ruiz Garcia");   // catastrofe silenciosa

La solución es siempre la misma: las claves deben ser inmutables, o al menos deben serlo los campos que intervienen en equals y hashCode.

  1. Requisitos de una buena clave

Requisito Por qué Cómo conseguirlo
Inmutable Si cambia su hash, la entrada se pierde Campos final, sin setters (03-07)
equals correcto Distingue entradas dentro de la cubeta Sobre los campos que definen la identidad
hashCode correcto Localiza la cubeta Sobre los mismos campos que equals
Bien repartido Evita que todo caiga en una cubeta Objects.hash(...) lo hace bien
Barato de calcular Se llama en cada operación Campos simples, o cachear si es caro
No null en general HashMap lo admite, otros mapas no Prefiere claves reales

Los mejores candidatos a clave, por orden de preferencia:

  1. String: inmutable, con equals/hashCode correctos y hash cacheado internamente. Es la clave más común con diferencia.
  2. Envoltorios numéricos (Integer, Long): inmutables y con hash trivial.
  3. enum: inmutables por construcción, con identidad única. Y si tu clave es un enum, considera EnumMap, una implementación especializada extremadamente eficiente.
  4. record (04-07): equals y hashCode generados, coherentes y sobre campos inmutables. El candidato ideal para claves compuestas.
  5. Clases propias inmutables con ambos métodos bien escritos.

Los peores candidatos:

  • Objetos mutables de dominio (Empleado, Prestamo) si sus campos de identidad pueden cambiar.
  • Colecciones mutables (ArrayList, HashSet): su hash depende del contenido.
  • Arrays: su hashCode es el heredado de Object, basado en identidad. mapa.get(new int[]{1,2}) nunca encontrará la entrada guardada con new int[]{1,2}. Usa List.of(1, 2) en su lugar.
  • Objetos con hashCode caro que no se cachea.

Un ejemplo de clave compuesta bien hecha en BiblioTech:

/** Identifica un ejemplar concreto: mismo ISBN, distinto numero de copia. */
public record ClaveEjemplar(String isbn, int numeroEjemplar) {
    public ClaveEjemplar {
        if (isbn == null || isbn.isBlank()) { isbn = "SIN-ISBN"; }
        if (numeroEjemplar < 1) { numeroEjemplar = 1; }
    }
}

Map<ClaveEjemplar, String> ubicaciones = new HashMap<>();
ubicaciones.put(new ClaveEjemplar("978-0000000001", 1), "Estanteria A-3");
ubicaciones.put(new ClaveEjemplar("978-0000000001", 2), "Estanteria A-4");

System.out.println(ubicaciones.get(new ClaveEjemplar("978-0000000001", 2)));  // Estanteria A-4

Una línea de declaración y ya es una clave perfecta.

  1. HashMap, LinkedHashMap y TreeMap

Las tres implementaciones principales de Map, comparadas:

Aspecto HashMap LinkedHashMap TreeMap
Estructura interna Array de cubetas Cubetas + lista doblemente enlazada Árbol rojo-negro
Orden de recorrido Ninguno garantizado Inserción (o acceso) Claves ordenadas
get / put / remove O(1) O(1) O(log n)
containsKey O(1) O(1) O(log n)
Memoria por entrada Menor +2 referencias por entrada Mayor (nodos de árbol)
Clave null Una permitida Una permitida NO permitida
Valores null
Requisito de la clave equals + hashCode equals + hashCode Comparable o Comparator
Cuándo usarlo Por defecto Orden reproducible; caché LRU Rangos, firstKey, orden permanente

LinkedHashMap

Mantiene una lista enlazada que atraviesa todas las entradas en el orden en que se insertaron. El coste es pequeño (dos referencias por entrada) y a cambio el recorrido es determinista:

Map<String, Integer> plazos = new LinkedHashMap<>();
plazos.put("Libro", 15);
plazos.put("Revista", 7);
plazos.put("DVD", 3);

plazos.forEach((tipo, dias) -> System.out.println(tipo + ": " + dias));
// SIEMPRE en este orden: Libro, Revista, DVD

Úsalo cuando el orden importe para la presentación, para los tests o para la reproducibilidad. Curiosamente, recorrerlo es incluso algo más rápido que recorrer un HashMap, porque sigue la lista enlazada en lugar de examinar todas las cubetas (incluidas las vacías).

TreeMap

Mantiene las claves ordenadas en un árbol equilibrado. Todo cuesta O(log n) en lugar de O(1), pero a cambio ofrece operaciones que ningún mapa de hash puede dar:

TreeMap<String, Material> porReferencia = new TreeMap<>();
porReferencia.put("978-0000000002", patronesDiseno);
porReferencia.put("978-0000000001", javaEfectivo);
porReferencia.put("978-0000000003", refactorizacion);

// Siempre ordenado, sin ordenar nada a mano
porReferencia.forEach((k, v) -> System.out.println(k + " -> " + v.getTitulo()));

System.out.println(porReferencia.firstKey());        // 978-0000000001
System.out.println(porReferencia.lastKey());         // 978-0000000003
System.out.println(porReferencia.firstEntry());      // par completo

// Navegacion: NavigableMap
System.out.println(porReferencia.floorKey("978-0000000002x"));   // la mayor clave <= dada
System.out.println(porReferencia.ceilingKey("978-0000000000"));  // la menor clave >= dada
System.out.println(porReferencia.higherKey("978-0000000001"));   // estrictamente mayor
System.out.println(porReferencia.lowerKey("978-0000000003"));    // estrictamente menor

// Vistas por rango, que son VISTAS VIVAS del mapa original
SortedMap<String, Material> primeros = porReferencia.headMap("978-0000000003");  // < dada
SortedMap<String, Material> ultimos  = porReferencia.tailMap("978-0000000002");  // >= dada
NavigableMap<String, Material> tramo = porReferencia.subMap("978-0000000001", true,
                                                            "978-0000000002", true);

System.out.println(porReferencia.descendingMap().firstKey());    // recorrido inverso

Y admite un Comparator propio, conectando con todo lo de 04-06:

// Ordenado por longitud de la clave y, a igualdad, alfabeticamente
Map<String, Integer> aMedida = new TreeMap<>(
    Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));

Cuándo elegir TreeMap: cuando necesites recorrer en orden con frecuencia, consultar rangos (headMap, subMap), o preguntar "cuál es la clave inmediatamente anterior a esta". Si solo necesitas orden una vez al final, sale más barato usar un HashMap y ordenar las claves en ese momento.

Nulos, resumidos

Clave null Valores null
HashMap , una (va a la cubeta 0) Sí, tantos como quieras
LinkedHashMap Sí, una
TreeMap No: NullPointerException
Hashtable No No
Map.of(...) No No

Que HashMap acepte una clave null genera una ambigüedad conocida: mapa.get(clave) devuelve null tanto si la clave no está como si está asociada a null. Para distinguir, containsKey. En general, evita las claves y valores null: complican el código sin aportar nada. Y Optional (10-04) es la respuesta moderna a "puede que no haya valor".

  1. Una caché LRU en cinco líneas

LinkedHashMap tiene una capacidad poco conocida y muy elegante. Su tercer constructor acepta un modo de ordenación:

new LinkedHashMap<>(capacidadInicial, factorDeCarga, accessOrder);

Con accessOrder = true, la lista interna se reordena en cada acceso: cada vez que consultas una entrada con get, esa entrada pasa al final. Es decir, el principio de la lista es siempre la entrada usada hace más tiempo.

Combinado con el método removeEldestEntry, que LinkedHashMap invoca tras cada inserción para preguntar si debe expulsar la entrada más antigua, tienes una caché LRU (Least Recently Used) completa:

package com.nexussoftware.bibliotech.servicio;

import java.util.LinkedHashMap;
import java.util.Map;

/** Cache LRU: conserva las N fichas consultadas mas recientemente. */
public class CacheFichas<K, V> extends LinkedHashMap<K, V> {

    private final int maximo;

    public CacheFichas(int maximo) {
        super(16, 0.75f, true);      // accessOrder = true: reordena en cada get
        this.maximo = maximo;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> masAntigua) {
        return size() > maximo;      // si devuelve true, LinkedHashMap la expulsa
    }
}

Cinco líneas útiles. En acción:

CacheFichas<String, String> cache = new CacheFichas<>(3);

cache.put("978-0000000001", "Java Efectivo");
cache.put("978-0000000002", "Patrones de Diseno");
cache.put("978-0000000003", "Refactorizacion");
System.out.println(cache.keySet());      // [...001, ...002, ...003]

cache.get("978-0000000001");             // consultamos el PRIMERO: pasa al final
System.out.println(cache.keySet());      // [...002, ...003, ...001]

cache.put("978-0000000004", "Codigo Limpio");   // supera el maximo
System.out.println(cache.keySet());      // [...003, ...001, ...004]
// Ha desaparecido ...002: era la menos usada recientemente

Fíjate en el detalle clave: ...001 sobrevivió porque lo consultamos, aunque fuera el más antiguo por inserción. Eso es exactamente la política LRU, y es la que usan las cachés de bases de datos, navegadores y sistemas operativos.

En BiblioTech serviría para cachear las fichas de los materiales más consultados sin que la memoria crezca sin límite.

  1. Hashtable: el legado

Hashtable es la clase original de Java 1.0, anterior al Framework de Colecciones. No la uses en código nuevo.

Hashtable HashMap
Sincronizada , todos los métodos No
Rendimiento Peor (bloqueo en cada operación) Mejor
Claves/valores null Prohibidos Permitidos
Antigüedad Java 1.0 Java 1.2
Iteración Enumeration (obsoleta) Iterator

Su única ventaja aparente —estar sincronizada— resulta ser insuficiente para el uso concurrente real, porque bloquea toda la tabla en cada operación y aun así no hace atómicas las secuencias compuestas del tipo "comprobar y luego insertar".

La respuesta correcta para concurrencia es ConcurrentHashMap, que permite acceso concurrente sin bloquear el mapa entero y ofrece operaciones atómicas. Es tema del módulo 8.

Lo mismo vale para Vector (el Hashtable de las listas) y para Stack, que extiende Vector y verás desaconsejada en 05-08. Todas comparten la misma historia: sincronización global heredada de Java 1.0.

  1. Aplicación a BiblioTech

Ahora, el refactor grande. Dos aplicaciones que cambian el proyecto de raíz.

Índice por referencia en el Catalogo

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import com.nexussoftware.bibliotech.dominio.Material;

/** Catalogo con indice por referencia: busquedas en O(1). */
public class Catalogo {

    private final List<Material>          materiales = new ArrayList<>();
    private final Map<String, Material>   porReferencia = new HashMap<>();
    private final Map<String, List<Material>> porTipo   = new HashMap<>();

    /** Alta: mantiene los tres contenedores sincronizados. */
    public boolean anadir(Material m) {
        if (m == null) { return false; }
        // putIfAbsent devuelve el valor existente, o null si inserto
        if (porReferencia.putIfAbsent(m.getReferencia(), m) != null) {
            return false;                       // referencia duplicada: alta rechazada
        }
        materiales.add(m);
        porTipo.computeIfAbsent(m.getTipo(), tipo -> new ArrayList<>()).add(m);
        return true;
    }

    /** Busqueda por referencia: O(1). Antes era O(n). */
    public Material buscarPorReferencia(String referencia) {
        return porReferencia.get(referencia);
    }

    public boolean estaCatalogado(String referencia) {
        return porReferencia.containsKey(referencia);
    }

    /** Baja: O(1) para localizarlo, O(n) para quitarlo de la lista. */
    public boolean eliminar(String referencia) {
        Material m = porReferencia.remove(referencia);
        if (m == null) { return false; }
        materiales.remove(m);
        // Si el grupo se queda vacio, la entrada se elimina del mapa (funcion que devuelve null)
        porTipo.computeIfPresent(m.getTipo(),
                (tipo, lista) -> { lista.remove(m); return lista.isEmpty() ? null : lista; });
        return true;
    }

    /** Todos los materiales de un tipo: O(1). Antes era un recorrido completo. */
    public List<Material> porTipo(String tipo) {
        return List.copyOf(porTipo.getOrDefault(tipo, List.of()));
    }

    /** Informe por tipo: antes eran 20 lineas con bucles anidados O(n^2). */
    public Map<String, Integer> contarPorTipo() {
        Map<String, Integer> resumen = new HashMap<>();
        for (Material m : materiales) {
            resumen.merge(m.getTipo(), 1, Integer::sum);
        }
        return resumen;
    }

    /** Suma de tarifas diarias agrupada por tipo, tambien con merge. */
    public Map<String, Double> tarifaTotalPorTipo() {
        Map<String, Double> resumen = new HashMap<>();
        for (Material m : materiales) {
            resumen.merge(m.getTipo(), m.getTarifaDiaria(), Double::sum);
        }
        return resumen;
    }

    public List<Material> buscar(Predicate<Material> criterio) {
        List<Material> resultado = new ArrayList<>();
        for (Material m : materiales) {
            if (criterio.test(m)) { resultado.add(m); }
        }
        return resultado;
    }

    public void ordenar(Comparator<Material> criterio) { materiales.sort(criterio); }
    public List<Material> listar() { return List.copyOf(materiales); }
    public int tamano() { return materiales.size(); }
}

Compara el informePorTipo prometido en el cierre del módulo 4:

// ANTES: bucles anidados, O(n * tipos), 20 lineas
public void informePorTipo(Material[] catalogo) {
    String[] tipos = { "Libro", "Revista", "DVD", "Audiolibro" };
    for (int t = 0; t < tipos.length; t++) {
        int cuantos = 0;
        double tarifa = 0.0;
        for (int i = 0; i < catalogo.length; i++) {
            if (catalogo[i] != null && catalogo[i].getTipo().equals(tipos[t])) {
                cuantos++;
                tarifa += catalogo[i].getTarifaDiaria();
            }
        }
        if (cuantos > 0) {
            System.out.printf("%-12s %3d materiales, tarifa media %.2f%n",
                              tipos[t], cuantos, tarifa / cuantos);
        }
    }
}

// AHORA: una pasada, O(n), y sin lista de tipos codificada a mano
public void informePorTipo() {
    Map<String, Integer> cuantos = contarPorTipo();
    Map<String, Double>  tarifas = tarifaTotalPorTipo();
    cuantos.forEach((tipo, n) ->
        System.out.printf("%-12s %3d materiales, tarifa media %.2f%n",
                          tipo, n, tarifas.get(tipo) / n));
}

Tres líneas de cuerpo, una sola pasada por el catálogo, y los tipos ya no están codificados a mano: si mañana aparece el audiolibro, el informe lo incluye solo.

Préstamos por empleado

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.nexussoftware.bibliotech.dominio.Empleado;
import com.nexussoftware.bibliotech.dominio.Prestamo;

public class RegistroPrestamos {

    private final Map<String, Prestamo>          porReferencia = new HashMap<>();
    private final Map<Empleado, List<Prestamo>>  porEmpleado   = new HashMap<>();

    /**
     * Empleado como CLAVE: exige equals/hashCode correctos y basados en un campo
     * INMUTABLE. En BiblioTech se basan en 'identificador', que es final.
     */
    public void registrar(Prestamo p) {
        if (p == null) { return; }
        porReferencia.put(p.getReferencia(), p);
        porEmpleado.computeIfAbsent(p.getEmpleado(), e -> new ArrayList<>()).add(p);
    }

    /** Devolucion en O(1): antes recorria toda la lista de prestamos. */
    public boolean devolver(String referenciaPrestamo, int dia) {
        Prestamo p = porReferencia.get(referenciaPrestamo);
        if (p == null || p.estaDevuelto()) { return false; }
        p.registrarDevolucion(dia);
        return true;
    }

    /** Prestamos de un empleado en O(1): antes era un recorrido completo. */
    public List<Prestamo> de(Empleado empleado) {
        return List.copyOf(porEmpleado.getOrDefault(empleado, List.of()));
    }

    /** Multa acumulada por empleado, con merge. */
    public Map<Empleado, Double> multasPorEmpleado(int diaActual) {
        Map<Empleado, Double> multas = new HashMap<>();
        for (List<Prestamo> lista : porEmpleado.values()) {
            for (Prestamo p : lista) {
                if (!p.estaDevuelto() && p.estaVencido(diaActual)) {
                    multas.merge(p.getEmpleado(), p.calcularMulta(diaActual), Double::sum);
                }
            }
        }
        return multas;
    }

    /** Cuantos prestamos activos tiene cada empleado. */
    public Map<Empleado, Integer> activosPorEmpleado() {
        Map<Empleado, Integer> activos = new HashMap<>();
        for (Map.Entry<Empleado, List<Prestamo>> e : porEmpleado.entrySet()) {
            int n = 0;
            for (Prestamo p : e.getValue()) {
                if (!p.estaDevuelto()) { n++; }
            }
            if (n > 0) { activos.put(e.getKey(), n); }
        }
        return activos;
    }
}

Uso:

Empleado marta = new Empleado("Marta Ruiz",   "EMP-001");
Empleado diego = new Empleado("Diego Alonso", "EMP-002");

Catalogo catalogo = new Catalogo();
catalogo.anadir(new Libro("Java Efectivo",      "Joshua Bloch",  "978-0000000001", 2018));
catalogo.anadir(new Libro("Patrones de Diseno", "Erich Gamma",   "978-0000000002", 1994));
catalogo.anadir(new Libro("Refactorizacion",    "Martin Fowler", "978-0000000003", 1999));
catalogo.anadir(new Revista("Java Magazine",    "REV-2024-03",   42, "Mensual"));
catalogo.anadir(new Dvd("Refactorizacion en vivo", "DVD-0007",   95));

// Busqueda instantanea, sin recorrer nada
Material m = catalogo.buscarPorReferencia("978-0000000002");
System.out.println("Encontrado: " + m.getTitulo());

System.out.println("--- Informe por tipo ---");
catalogo.contarPorTipo().forEach((tipo, n) -> System.out.printf("  %-8s %d%n", tipo, n));

RegistroPrestamos registro = new RegistroPrestamos();
registro.registrar(new Prestamo(catalogo.buscarPorReferencia("978-0000000001"), marta, 100));
registro.registrar(new Prestamo(catalogo.buscarPorReferencia("978-0000000003"), marta, 102));
registro.registrar(new Prestamo(catalogo.buscarPorReferencia("DVD-0007"),       diego, 105));

System.out.println("Prestamos de Marta: " + registro.de(marta).size());
System.out.println("--- Multas en el dia 130 ---");
registro.multasPorEmpleado(130).forEach((e, multa) ->
    System.out.printf("  %-14s %6.2f EUR%n", e.getNombre(), multa));
Encontrado: Patrones de Diseno
--- Informe por tipo ---
  Libro    3
  Revista  1
  DVD      1
Prestamos de Marta: 2
--- Multas en el dia 130 ---
  Marta Ruiz      20,00 EUR
  Diego Alonso    12,50 EUR

Errores Comunes y Consejos

Sobrescribir equals sin hashCode. El error número uno. El objeto se guarda pero no se encuentra jamás, y se pueden crear claves duplicadas. Si sobrescribes uno, sobrescribe el otro, sobre los mismos campos. Deja que el IDE genere ambos, o usa un record.

Usar claves mutables. Si el hash de la clave cambia después de insertarla, la entrada queda inalcanzable para siempre. Claves inmutables: String, Integer, enum, record.

Usar una colección mutable como clave. El hashCode de una lista depende de su contenido: añadir un elemento pierde la entrada. Si necesitas una lista como clave, List.copyOf(...).

Usar un array como clave. Su hashCode es el de Object, basado en identidad: nunca encontrarás la entrada. Usa List.of(...).

Depender del orden de recorrido de un HashMap. No está garantizado y puede cambiar al insertar o entre versiones de Java. Si necesitas orden, LinkedHashMap o TreeMap. Un test que dependa del orden de un HashMap fallará algún día.

Recorrer con keySet() y hacer get dentro. Duplica el trabajo. Usa entrySet() cuando necesites clave y valor.

Confundir containsKey con containsValue. El primero es O(1); el segundo, O(n). No pongas un containsValue dentro de un bucle.

Modificar el mapa durante el recorrido. ConcurrentModificationException, igual que con cualquier colección. Usa el Iterator de entrySet(), values().removeIf(...) o keySet().removeIf(...).

Confundir get que devuelve null con "la clave no existe". Puede existir con valor null. Distingue con containsKey, o mejor, no guardes valores null.

new HashMap<>(n) creyendo que n son las entradas previstas. Es la capacidad: con factor 0,75, el rehash salta a las 0,75 × n. La fórmula correcta es new HashMap<>((int)(previstas / 0.75f) + 1).

Usar Hashtable o Vector en código nuevo. Son legado de Java 1.0. Para uso normal, HashMap; para concurrencia, ConcurrentHashMap (módulo 8).

Consejo: computeIfAbsent y merge son tus mejores amigos. El mapa de listas y el contador son los dos patrones más frecuentes del mundo real, y cada uno se resuelve en una línea. Interiorízalos.

Consejo: los record son claves perfectas. Cuando necesites una clave compuesta, declara un record de una línea (04-07) y tendrás equals y hashCode correctos gratis.

Ejercicios

Ejercicio 1: índice invertido de búsqueda

Escribe IndiceBusqueda que permita buscar materiales por palabras de su título:

  • void indexar(Material m): descompone el título en palabras (minúsculas, separadas por espacios) y guarda cada palabra en un Map<String, List<Material>> con computeIfAbsent.
  • List<Material> buscar(String palabra): los materiales que contengan esa palabra, o lista vacía.
  • Map<String, Integer> frecuencias(): cuántas veces aparece cada palabra, con merge.
  • List<String> palabrasMasComunes(int cuantas): las N palabras más frecuentes.
  • void eliminar(Material m): quita el material de todas sus palabras, borrando la entrada cuando la lista quede vacía.

Ejercicio 2: la demostración de equals y hashCode

Escribe una clase ejecutable DemostracionHashCode con un main que demuestre, imprimiendo resultados y explicándolos:

  1. Una clase ClaveRota con equals pero sin hashCode: mete un objeto en un HashMap y demuestra que un objeto igual no lo encuentra, y que se pueden crear claves duplicadas.
  2. Una clase ClaveCorrecta con ambos: demuestra que funciona.
  3. Una clase ClaveMutable: mete un objeto, muta la clave y demuestra que la entrada queda inalcanzable pero sigue contando para size().
  4. Un record ClaveRecord: demuestra que funciona sin escribir ningún método.
  5. Una clase ClaveTonta con hashCode() que siempre devuelve 42: mide el tiempo de 50 000 inserciones y búsquedas, y compáralo con ClaveCorrecta.

Ejercicio 3: estadísticas de la biblioteca

Escribe EstadisticasBiblioteca que reciba un List<Prestamo> y calcule, usando exclusivamente métodos de Map (nada de Streams):

  • Map<Empleado, Integer> prestamosPorEmpleado().
  • Map<String, Double> multaTotalPorTipo(int diaActual).
  • Map<Gravedad, List<Prestamo>> agrupadosPorGravedad(int diaActual).
  • Empleado empleadoMasActivo(): el que más préstamos tiene, null si no hay ninguno.
  • Map<String, Integer> topMateriales(int cuantos): los N materiales más prestados, en un LinkedHashMap que conserve el orden de mayor a menor.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.nexussoftware.bibliotech.dominio.Material;

/** Indice invertido: de cada palabra a los materiales que la contienen en el titulo. */
public class IndiceBusqueda {

    private final Map<String, List<Material>> porPalabra = new HashMap<>();
    private final Map<String, Integer>        frecuencia = new HashMap<>();

    private static List<String> palabrasDe(Material m) {
        if (m == null || m.getTitulo() == null) { return List.of(); }
        List<String> palabras = new ArrayList<>();
        for (String p : m.getTitulo().toLowerCase().split("\\s+")) {
            if (p.length() > 2) { palabras.add(p); }   // descartamos "de", "el", "la"...
        }
        return palabras;
    }

    public void indexar(Material m) {
        for (String palabra : palabrasDe(m)) {
            // computeIfAbsent: crea la lista SOLO si la palabra es nueva,
            // y devuelve siempre una lista valida sobre la que encadenar add
            porPalabra.computeIfAbsent(palabra, p -> new ArrayList<>()).add(m);
            // merge: si la palabra es nueva guarda 1; si existe, suma
            frecuencia.merge(palabra, 1, Integer::sum);
        }
    }

    /** getOrDefault evita devolver null y las comprobaciones de quien llama. */
    public List<Material> buscar(String palabra) {
        if (palabra == null) { return List.of(); }
        return List.copyOf(porPalabra.getOrDefault(palabra.toLowerCase(), List.of()));
    }

    public Map<String, Integer> frecuencias() {
        return Map.copyOf(frecuencia);        // copia inmutable
    }

    public List<String> palabrasMasComunes(int cuantas) {
        // Volcamos las claves a una lista y la ordenamos por frecuencia descendente.
        // Un TreeMap no serviria: ordena por CLAVE, no por valor.
        List<String> palabras = new ArrayList<>(frecuencia.keySet());
        palabras.sort(Comparator.comparingInt((String p) -> frecuencia.get(p)).reversed()
                                .thenComparing(Comparator.naturalOrder()));
        return palabras.subList(0, Math.min(cuantas, palabras.size()));
    }

    public void eliminar(Material m) {
        for (String palabra : palabrasDe(m)) {
            // Si la funcion devuelve null, computeIfPresent ELIMINA la entrada del mapa.
            porPalabra.computeIfPresent(palabra, (p, lista) -> {
                lista.remove(m);
                return lista.isEmpty() ? null : lista;
            });
            frecuencia.computeIfPresent(palabra, (p, n) -> (n <= 1) ? null : n - 1);
        }
    }

    public int palabrasIndexadas() { return porPalabra.size(); }
}

Prueba:

IndiceBusqueda indice = new IndiceBusqueda();
indice.indexar(new Libro("Java Efectivo",         "Joshua Bloch",  "978-0000000001", 2018));
indice.indexar(new Libro("Patrones de Diseno",    "Erich Gamma",   "978-0000000002", 1994));
indice.indexar(new Libro("Refactorizacion",       "Martin Fowler", "978-0000000003", 1999));
indice.indexar(new Revista("Java Magazine",       "REV-2024-03",   42, "Mensual"));
indice.indexar(new Dvd("Refactorizacion en vivo", "DVD-0007",      95));

System.out.println("Con 'java': " + indice.buscar("java").size());               // 2
System.out.println("Con 'refactorizacion': " + indice.buscar("refactorizacion").size()); // 2
System.out.println("Mas comunes: " + indice.palabrasMasComunes(3));

La clave del ejercicio es el trío computeIfAbsent / merge / computeIfPresent devolviendo null. Los tres juntos permiten mantener un mapa de listas y un contador sin una sola comprobación de null explícita. Escrito con get y put a mano, el mismo código ocuparía el triple y tendría al menos un NullPointerException esperando su momento.

Una observación importante: palabrasMasComunes tiene que volcar y ordenar, porque un mapa ordena por clave, nunca por valor. Es una limitación estructural de los mapas que conviene tener presente.

Solución 2

package com.nexussoftware.bibliotech.presentacion;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class DemostracionHashCode {

    // --- 1. equals SIN hashCode ---
    static class ClaveRota {
        final String isbn;
        ClaveRota(String isbn) { this.isbn = isbn; }
        @Override public boolean equals(Object o) {
            return (o instanceof ClaveRota c) && Objects.equals(isbn, c.isbn);
        }
        // sin hashCode: se hereda el de Object (basado en identidad)
        @Override public String toString() { return "Rota[" + isbn + "]"; }
    }

    // --- 2. equals Y hashCode coherentes ---
    static class ClaveCorrecta {
        final String isbn;
        ClaveCorrecta(String isbn) { this.isbn = isbn; }
        @Override public boolean equals(Object o) {
            return (o instanceof ClaveCorrecta c) && Objects.equals(isbn, c.isbn);
        }
        @Override public int hashCode() { return Objects.hash(isbn); }
        @Override public String toString() { return "Correcta[" + isbn + "]"; }
    }

    // --- 3. clave MUTABLE ---
    static class ClaveMutable {
        String isbn;                        // no final: el problema
        ClaveMutable(String isbn) { this.isbn = isbn; }
        @Override public boolean equals(Object o) {
            return (o instanceof ClaveMutable c) && Objects.equals(isbn, c.isbn);
        }
        @Override public int hashCode() { return Objects.hash(isbn); }
        @Override public String toString() { return "Mutable[" + isbn + "]"; }
    }

    // --- 4. record: equals y hashCode GRATIS ---
    record ClaveRecord(String isbn, int ejemplar) { }

    // --- 5. hashCode constante: legal pero catastrofico ---
    static class ClaveTonta {
        final String isbn;
        ClaveTonta(String isbn) { this.isbn = isbn; }
        @Override public boolean equals(Object o) {
            return (o instanceof ClaveTonta c) && Objects.equals(isbn, c.isbn);
        }
        @Override public int hashCode() { return 42; }   // TODAS en la misma cubeta
    }

    public static void main(String[] args) {
        demostrarRota();
        demostrarCorrecta();
        demostrarMutable();
        demostrarRecord();
        demostrarTonta();
    }

    static void demostrarRota() {
        System.out.println("=== 1. equals SIN hashCode ===");
        ClaveRota a = new ClaveRota("978-0000000001");
        ClaveRota b = new ClaveRota("978-0000000001");

        System.out.println("a.equals(b): " + a.equals(b));            // true
        System.out.println("hash a: " + a.hashCode());
        System.out.println("hash b: " + b.hashCode() + "   <- DISTINTO");

        Map<ClaveRota, String> mapa = new HashMap<>();
        mapa.put(a, "Estanteria A-3");
        System.out.println("get(a): " + mapa.get(a));                 // Estanteria A-3
        System.out.println("get(b): " + mapa.get(b) + "   <- se ha PERDIDO");

        mapa.put(b, "Estanteria B-1");
        System.out.println("size tras poner b: " + mapa.size() + "   <- CLAVE DUPLICADA");
        System.out.println("Causa: get(b) calcula el hash de b, va a OTRA cubeta,");
        System.out.println("       la encuentra vacia y devuelve null. equals NUNCA se llama.\n");
    }

    static void demostrarCorrecta() {
        System.out.println("=== 2. equals Y hashCode ===");
        ClaveCorrecta a = new ClaveCorrecta("978-0000000001");
        ClaveCorrecta b = new ClaveCorrecta("978-0000000001");

        System.out.println("hash a == hash b: " + (a.hashCode() == b.hashCode()));  // true

        Map<ClaveCorrecta, String> mapa = new HashMap<>();
        mapa.put(a, "Estanteria A-3");
        System.out.println("get(b): " + mapa.get(b) + "   <- FUNCIONA");
        mapa.put(b, "Estanteria B-1");
        System.out.println("size: " + mapa.size() + "   <- b SUSTITUYO a a\n");
    }

    static void demostrarMutable() {
        System.out.println("=== 3. clave MUTABLE ===");
        ClaveMutable clave = new ClaveMutable("978-0000000001");
        Map<ClaveMutable, String> mapa = new HashMap<>();
        mapa.put(clave, "Java Efectivo");
        System.out.println("get antes de mutar: " + mapa.get(clave));

        clave.isbn = "978-0000000002";        // mutamos la clave YA insertada

        System.out.println("get tras mutar:     " + mapa.get(clave) + "   <- PERDIDA");
        System.out.println("containsKey:        " + mapa.containsKey(clave));
        System.out.println("size:               " + mapa.size() + "   <- sigue ahi dentro");
        System.out.println("contenido:          " + mapa);
        System.out.println("La entrada esta en la cubeta del hash ANTIGUO. Nadie la recoloca:");
        System.out.println("es memoria ocupada e irrecuperable, ni siquiera con remove.\n");
    }

    static void demostrarRecord() {
        System.out.println("=== 4. record ===");
        Map<ClaveRecord, String> mapa = new HashMap<>();
        mapa.put(new ClaveRecord("978-0000000001", 1), "Estanteria A-3");
        mapa.put(new ClaveRecord("978-0000000001", 2), "Estanteria A-4");

        System.out.println("get(nueva instancia igual): "
                           + mapa.get(new ClaveRecord("978-0000000001", 2)));
        System.out.println("size: " + mapa.size());
        System.out.println("Cero metodos escritos: el record los genera coherentes.\n");
    }

    static void demostrarTonta() {
        System.out.println("=== 5. hashCode constante ===");
        final int N = 50_000;

        Map<ClaveCorrecta, Integer> buena = new HashMap<>();
        long t1 = System.nanoTime();
        for (int i = 0; i < N; i++) { buena.put(new ClaveCorrecta("ref-" + i), i); }
        for (int i = 0; i < N; i++) { buena.get(new ClaveCorrecta("ref-" + i)); }
        long msBuena = (System.nanoTime() - t1) / 1_000_000;

        Map<ClaveTonta, Integer> tonta = new HashMap<>();
        long t2 = System.nanoTime();
        for (int i = 0; i < N; i++) { tonta.put(new ClaveTonta("ref-" + i), i); }
        for (int i = 0; i < N; i++) { tonta.get(new ClaveTonta("ref-" + i)); }
        long msTonta = (System.nanoTime() - t2) / 1_000_000;

        System.out.printf("hashCode bien repartido: %5d ms%n", msBuena);
        System.out.printf("hashCode constante (42): %5d ms%n", msTonta);
        System.out.println("Todas las claves caen en la MISMA cubeta. Desde Java 8 esa");
        System.out.println("cubeta se convierte en arbol, asi que degrada a O(log n) en");
        System.out.println("vez de O(n); sin esa red de seguridad seria catastrofico.");
    }
}

Salida típica:

=== 1. equals SIN hashCode ===
a.equals(b): true
hash a: 1735600054
hash b: 21685669   <- DISTINTO
get(a): Estanteria A-3
get(b): null   <- se ha PERDIDO
size tras poner b: 2   <- CLAVE DUPLICADA
...
=== 5. hashCode constante ===
hashCode bien repartido:    38 ms
hashCode constante (42):  1420 ms

Esta es la demostración que 03-09 prometió. Los cinco casos juntos cuentan la historia completa: hashCode elige la cubeta y equals elige la entrada dentro de ella. Si el primero falla, el segundo nunca llega a ejecutarse; si el segundo falla, la entrada correcta nunca se reconoce; si la clave muta, la entrada se queda en una cubeta que ya nadie va a visitar; y si el hash está mal repartido, el mapa deja de ser O(1) aunque siga funcionando.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.nexussoftware.bibliotech.dominio.Empleado;
import com.nexussoftware.bibliotech.dominio.Gravedad;
import com.nexussoftware.bibliotech.dominio.Prestamo;

public class EstadisticasBiblioteca {

    private final List<Prestamo> prestamos;

    public EstadisticasBiblioteca(List<Prestamo> prestamos) {
        this.prestamos = (prestamos == null) ? List.of() : List.copyOf(prestamos);
    }

    /** merge: el patron contador en una linea. */
    public Map<Empleado, Integer> prestamosPorEmpleado() {
        Map<Empleado, Integer> resultado = new HashMap<>();
        for (Prestamo p : prestamos) {
            resultado.merge(p.getEmpleado(), 1, Integer::sum);
        }
        return resultado;
    }

    /** merge con Double::sum: acumulador, mismo patron. */
    public Map<String, Double> multaTotalPorTipo(int diaActual) {
        Map<String, Double> resultado = new HashMap<>();
        for (Prestamo p : prestamos) {
            double multa = p.calcularMulta(diaActual);
            if (multa > 0) {
                resultado.merge(p.getMaterial().getTipo(), multa, Double::sum);
            }
        }
        return resultado;
    }

    /** computeIfAbsent: el patron mapa de listas. */
    public Map<Gravedad, List<Prestamo>> agrupadosPorGravedad(int diaActual) {
        Map<Gravedad, List<Prestamo>> resultado = new HashMap<>();
        for (Prestamo p : prestamos) {
            Gravedad g = p.getMaterial().clasificarGravedad(diaActual);
            resultado.computeIfAbsent(g, clave -> new ArrayList<>()).add(p);
        }
        return resultado;
        // Nota: con un enum como clave, EnumMap seria aun mas eficiente.
        // En 10-04 esto sera Collectors.groupingBy en una sola expresion.
    }

    /** Recorrido de entrySet para quedarnos con el maximo. */
    public Empleado empleadoMasActivo() {
        Empleado mejor = null;
        int maximo = 0;
        for (Map.Entry<Empleado, Integer> e : prestamosPorEmpleado().entrySet()) {
            if (e.getValue() > maximo) {
                maximo = e.getValue();
                mejor  = e.getKey();
            }
        }
        return mejor;      // null si no hay prestamos
    }

    /**
     * Los N materiales mas prestados, EN ORDEN.
     * Un HashMap no conserva orden, asi que hay que volcar, ordenar y
     * reconstruir en un LinkedHashMap, que si lo conserva.
     */
    public Map<String, Integer> topMateriales(int cuantos) {
        Map<String, Integer> conteo = new HashMap<>();
        for (Prestamo p : prestamos) {
            conteo.merge(p.getMaterial().getTitulo(), 1, Integer::sum);
        }

        List<Map.Entry<String, Integer>> entradas = new ArrayList<>(conteo.entrySet());
        entradas.sort(Map.Entry.<String, Integer>comparingByValue().reversed()
                               .thenComparing(Map.Entry.comparingByKey()));

        Map<String, Integer> top = new LinkedHashMap<>();     // conserva el orden de insercion
        int n = 0;
        for (Map.Entry<String, Integer> e : entradas) {
            if (n++ >= cuantos) { break; }
            top.put(e.getKey(), e.getValue());
        }
        return top;
    }

    public void informe(int diaActual) {
        System.out.println("=== Estadisticas de BiblioTech (dia " + diaActual + ") ===");

        System.out.println("Prestamos por empleado:");
        prestamosPorEmpleado().forEach((e, n) ->
            System.out.printf("  %-16s %d%n", e.getNombre(), n));

        System.out.println("Multas por tipo de material:");
        multaTotalPorTipo(diaActual).forEach((tipo, multa) ->
            System.out.printf("  %-10s %6.2f EUR%n", tipo, multa));

        System.out.println("Prestamos por gravedad:");
        agrupadosPorGravedad(diaActual).forEach((g, lista) ->
            System.out.printf("  %-12s %d prestamos%n", g, lista.size()));

        Empleado activo = empleadoMasActivo();
        System.out.println("Empleado mas activo: "
                           + (activo == null ? "(ninguno)" : activo.getNombre()));

        System.out.println("Materiales mas prestados:");
        topMateriales(3).forEach((titulo, n) ->
            System.out.printf("  %-26s %d veces%n", titulo, n));
    }
}

Tres patrones se repiten en toda la clase, y son los tres que resuelven la mayoría de los informes que escribirás en tu vida profesional:

  1. Contador: mapa.merge(clave, 1, Integer::sum).
  2. Acumulador: mapa.merge(clave, valor, Double::sum).
  3. Agrupador: mapa.computeIfAbsent(clave, k -> new ArrayList<>()).add(elemento).

Y una limitación importante que el ejercicio deja ver: un mapa no se puede ordenar por valor. TreeMap ordena por clave. Para un "top N" hay que volcar entrySet() a una lista, ordenarla con Map.Entry.comparingByValue() y reconstruir en un LinkedHashMap si quieres conservar el orden. Es un idioma que conviene tener memorizado.

Conclusión

Has cruzado la frontera más importante de este módulo. Sabes que un mapa asocia claves únicas a valores y encuentra un valor por su clave en O(1): un coste que no crece con el tamaño, frente a los O(n) de recorrer una lista. Con un millón de elementos, la diferencia es entre una operación y quinientas mil.

Dominas la interfaz Map completa: put con su valor anterior devuelto, get y getOrDefault, containsKey en O(1) frente a containsValue en O(n), remove en sus dos formas, y las tres vistas keySet/values/entrySet —vivas, no copias—, sabiendo que cuando necesitas clave y valor la respuesta es siempre entrySet. Y manejas los siete métodos de Java 8 que eliminan la mitad del código repetitivo, con los tres patrones que resuelven la mayoría de los informes reales: computeIfAbsent para mapas de listas, merge para contadores y acumuladores, y una función que devuelve null para eliminar entradas sobre la marcha.

Entiendes la maquinaria: hashCode reduce la clave a un índice de cubeta mediante una mezcla de bits y un AND con la capacidad —siempre potencia de dos—, y equals distingue las entradas dentro de esa cubeta. Sabes qué es una colisión, cómo se encadenan las entradas, cuándo una cubeta se convierte en árbol rojo-negro (8 elementos, tabla de al menos 64) y por qué esa red de seguridad se introdujo por motivos de seguridad. Conoces el factor de carga 0,75, el rehash que duplica las cubetas y recoloca todo en O(n), y por eso sabes que put es O(1) amortizado, igual que el add de ArrayList. Y sabes dimensionar un mapa grande con previstas / 0.75 + 1.

Y sobre todo, se ha cumplido la promesa del módulo 3. Has visto con código ejecutable qué pasa cuando sobrescribes equals sin hashCode: el objeto desaparece, porque get busca en otra cubeta y equals nunca llega a ejecutarse; y encima se pueden crear claves duplicadas en un mapa que garantiza claves únicas. Has visto qué ocurre al mutar una clave ya insertada: la entrada se queda huérfana en la cubeta del hash antiguo, cuenta para size(), se ve al imprimir el mapa y es irrecuperable. Y has visto qué pasa con un hashCode constante: legal, y cuarenta veces más lento. De ahí salen los requisitos de una buena clave —inmutable, con equals y hashCode sobre los mismos campos, bien repartida y barata— y los mejores candidatos: String, envoltorios, enum y, para claves compuestas, un record de una línea.

Conoces las tres implementaciones y cuándo usar cada una: HashMap por defecto, LinkedHashMap cuando el orden deba ser reproducible —y con accessOrder más removeEldestEntry, una caché LRU completa en cinco líneas—, y TreeMap cuando necesites claves ordenadas, rangos con headMap/subMap o navegación con floorKey/ceilingKey. Y sabes que Hashtable es legado de Java 1.0 y que la respuesta a la concurrencia es ConcurrentHashMap, en el módulo 8.

BiblioTech ha cambiado de categoría. El Catalogo mantiene un Map<String, Material> por referencia y un Map<String, List<Material>> por tipo, ambos actualizados con putIfAbsent y computeIfAbsent: buscar un material es instantáneo y aquel informePorTipo de veinte líneas con bucles anidados se ha quedado, literalmente, en tres —y ya no lleva los tipos codificados a mano—. El RegistroPrestamos indexa por referencia de préstamo y por empleado, con las multas acumuladas mediante merge.

En la lección siguiente, HashSet, verás la otra cara de la misma moneda. Un HashSet es literalmente un HashMap con valores ficticios, así que hereda toda la maquinaria que acabas de entender —y todos sus requisitos sobre equals y hashCode—. Aprenderás la interfaz Set, las operaciones de conjunto (unión, intersección, diferencia, subconjunto) con addAll, retainAll, removeAll y containsAll, cómo el boolean que devuelve add resuelve la detección de duplicados en una línea, la comparación entre HashSet, LinkedHashSet y TreeSet con su navegación por rangos, el peligro de mutar un elemento ya guardado, y por qué contains en un Set es incomparablemente más rápido que en una List. En BiblioTech, el Set<String> de ISBN catalogados dejará de ser un apaño y se convertirá en una pieza de diseño.

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