Colecciones en Java: List no es ArrayList

javacoleccionesfundamentosarraylisthashmapserie-colecciones

Parte 1 de 3 de la serie sobre colecciones en Java. Sigue con la parte 2: mutabilidad, orden e igualdad y la parte 3: streams.

Una línea que todos escribimos sin entenderla

Hay una línea que aparece en casi cualquier programa Java:

List<String> nombres = new ArrayList<>();

Muchos la escribimos durante años antes de poder explicar por qué a la izquierda dice List y a la derecha dice ArrayList. Si son lo mismo, ¿por qué dos nombres? Y si no son lo mismo, ¿cuál es cuál?

Haz esa pregunta en tu equipo y cuenta los silencios. No es culpa de quien no sabe responder: la mayoría de los tutoriales enseñan a usar colecciones, no a entenderlas. Este artículo es el que me hubiera gustado leer cuando empecé.

Vamos a ir desde cero. No voy a asumir que sabes qué es una interfaz, ni qué es un hash, ni qué significa "orden natural". Cada palabra nueva se explica en el momento en que aparece. Lo único que necesitas saber es programar en Java a nivel básico: variables, clases, métodos y bucles.

Y vamos a hacerlo con código que corre. Todas las salidas que verás las copié de ejecutar los ejemplos en Java 21; cuando la salida pueda cambiar en tu máquina, te lo digo.

Cómo leer los ejemplos. Todos van dentro de un programa como este:

import java.util.*;
 
public class Ejemplos {
    public static void main(String[] args) {
        // aquí va el código de cada ejemplo
    }
}

Para no repetir eso cada vez, de aquí en adelante muestro solo lo que va adentro de main. El import java.util.*; trae todas las clases de colecciones que vamos a usar.

1. Antes de las colecciones: el array

Qué es. Un array es una fila de casillas numeradas, todas del mismo tipo, con un tamaño que se fija al crearlo y no cambia nunca. Es la estructura más simple que tiene Java para guardar varios valores juntos.

Ejemplo. Guardemos los primeros cinco números primos (un primo es un número mayor que 1 que solo se divide entre 1 y él mismo: 2, 3, 5, 7, 11, 13...).

// 1. Creamos un array con espacio para 5 enteros
int[] primos = new int[5];
 
// 2. Llenamos cada casilla por su posición (la primera es la 0)
primos[0] = 2;
primos[1] = 3;
primos[2] = 5;
primos[3] = 7;
primos[4] = 11;
 
System.out.println(Arrays.toString(primos));
 
// 3. Intentamos guardar un sexto primo
primos[5] = 13;

Qué pasó.

[2, 3, 5, 7, 11]
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5

El array guardó los cinco primeros sin problema. Pero al intentar guardar el sexto, el programa se detuvo con una excepción: "índice 5 fuera de los límites para un tamaño de 5". El array tiene cinco casillas (de la 0 a la 4) y no hay forma de agregarle una sexta. Para tener seis, hay que crear otro array de seis y copiar todo.

Cuándo usarlo. Cuando sabes de antemano cuántos elementos vas a tener y ese número no cambia. En la práctica eso es poco frecuente: casi siempre los datos crecen (llegan más pedidos, más usuarios, más primos).

Lo que debes llevarte: un array es rápido y simple, pero su tamaño es fijo. Ese límite es la razón por la que existen las colecciones.

2. La colección que crece

Qué es. Una colección es un objeto que guarda otros objetos y crece o se achica según haga falta. No tienes que decirle cuántos elementos va a tener. ArrayList es la colección más usada en Java.

Ejemplo. El mismo problema, con ArrayList:

// 1. Creamos la lista vacía (sin decir tamaño)
List<Integer> primos = new ArrayList<>();
 
// 2. Agregamos los primos uno a uno
primos.add(2);
primos.add(3);
primos.add(5);
primos.add(7);
primos.add(11);
 
// 3. Agregamos el sexto, que antes fallaba
primos.add(13);
 
System.out.println(primos);
System.out.println(primos.size());

Qué pasó.

[2, 3, 5, 7, 11, 13]
6

El sexto primo entró sin problema. size() nos dice cuántos elementos hay ahora: seis. Por dentro, ArrayList usa un array, y cuando se le acaba el espacio crea uno más grande y copia todo él solo. Tú no lo ves.

Dos detalles de esa primera línea que conviene entender ya:

  • <Integer> se llama genérico: le dice a la lista qué tipo de cosa va a guardar. Así, si intentas meter un texto en una lista de enteros, el compilador te avisa antes de ejecutar. Las colecciones no aceptan tipos primitivos como int; por eso se usa Integer, que es la versión "objeto" de int. Java convierte entre los dos automáticamente.
  • <> a la derecha (el "diamante") le dice al compilador "usa el mismo tipo que puse a la izquierda". Es solo para no escribir Integer dos veces.

Lo que debes llevarte: una colección crece sola. size() te dice cuántos elementos tiene en este momento, no cuántos caben.

3. Interfaz e implementación: la pregunta del principio

Volvamos a la línea del inicio. Ahora sí podemos responderla.

List<Integer> primos = new ArrayList<>();

Qué es una interfaz. Una interfaz es un contrato: una lista de métodos que algo promete tener, sin decir cómo los hace. List es una interfaz. Dice: "lo que sea que me implemente va a tener add, get, size, remove y unos cuantos más, y va a mantener los elementos en el orden en que se agregaron". No dice nada de cómo se guardan por dentro.

Qué es una implementación. Una implementación es una clase concreta que cumple ese contrato con una forma específica de guardar los datos. ArrayList es una implementación de List: cumple el contrato usando un array por dentro. LinkedList es otra implementación del mismo contrato, que por dentro usa nodos enlazados entre sí (lo vemos en un momento).

Una analogía que funciona porque conserva la estructura: la interfaz es el enchufe de la pared y la implementación es el aparato que conectas. El enchufe promete qué va a salir (corriente a cierto voltaje), no cómo se genera. Puedes cambiar un aparato por otro mientras respete el enchufe. Donde la analogía deja de servir: una interfaz puede tener muchas implementaciones a la vez en el mismo programa, cosa que un enchufe no.

Ejemplo de lo que no se puede hacer. Como List es solo un contrato, no puedes crear "un List":

List<Integer> primos = new List<>();

Qué pasó. Esto no compila. El compilador dice:

error: List is abstract; cannot be instantiated

"List es abstracta; no se puede instanciar". Instanciar es crear un objeto real en memoria con new. Un contrato no se instancia; se instancia algo que lo cumple.

Entonces, ¿por qué no escribir ArrayList en los dos lados? Se puede, y compila. Pero declarar la variable con la interfaz (List) tiene una ventaja real: el resto de tu código solo depende del contrato, no de la implementación. Si mañana descubres que LinkedList te conviene más, cambias una palabra en una línea y todo lo demás sigue igual, porque todo lo demás solo usaba métodos de List.

List<Integer> primos = new LinkedList<>();   // cambié solo esto
primos.add(2);                                // esto sigue igual
primos.get(0);                                // y esto también

Hay otro efecto, menos obvio, que vamos a ver al final del artículo: la interfaz con la que declaras decide qué métodos puedes llamar. Si declaras como List, solo ves los métodos de List, aunque el objeto real sea un ArrayList con más métodos.

Cuándo usar cada cosa. Declara con la interfaz (List, Set, Map...) en variables, parámetros y valores de retorno. Elige la implementación (ArrayList, HashSet, HashMap...) solo en el new.

Cómo están organizadas las interfaces

Jerarquía de las colecciones de Java: Iterable, Collection, List, Set y Queue, con Map aparte

Las tres familias (List, Set, Queue) descienden de Collection. Map es aparte: guarda pares clave-valor, no elementos sueltos, así que no es una Collection. Debajo de cada interfaz van sus implementaciones más usadas; son las que vamos a ver en este artículo.

  • Iterable: lo mínimo. Promete que se puede recorrer con un for.
  • Collection: un grupo de elementos. Promete add, remove, size, contains...
  • List: colección con orden de inserción y posiciones (índice 0, 1, 2...). Permite repetidos.
  • Set: colección sin repetidos. El orden depende de la implementación.
  • Queue: colección pensada para sacar elementos en cierto orden (fila o pila).
  • Map: no es una colección de elementos; es una tabla de pares clave → valor. Está en la figura porque se usa con las demás todo el tiempo.

Lo que debes llevarte: List es el contrato; ArrayList es una forma de cumplirlo. Declaras con el contrato y eliges la implementación en el new.

4. List: todas las formas de cargar una lista

Ya sabemos qué promete List: orden de inserción, posiciones numeradas, repetidos permitidos. Ahora veamos las formas reales de crear y llenar una, porque en código ajeno vas a encontrarte con todas.

Ejemplo. Siete formas, de la más básica a la más cómoda:

// a) Vacía y agregar uno por uno
List<Integer> a = new ArrayList<>();
a.add(2);
a.add(3);
a.add(5);
System.out.println("a = " + a);
 
// b) Vacía, pero reservando espacio para 100 (sigue vacía)
List<Integer> b = new ArrayList<>(100);
System.out.println("b = " + b + " size=" + b.size());
 
// c) Desde valores sueltos, con Arrays.asList
List<Integer> c = Arrays.asList(2, 3, 5);
System.out.println("c = " + c);
 
// d) Desde valores sueltos, con List.of (Java 9 en adelante)
List<Integer> d = List.of(2, 3, 5);
System.out.println("d = " + d);
 
// e) Copiando otra lista en una ArrayList nueva
List<Integer> e = new ArrayList<>(List.of(2, 3, 5));
e.add(7);
System.out.println("e = " + e);
 
// f) Agregando varios de golpe a una lista existente
List<Integer> f = new ArrayList<>();
Collections.addAll(f, 2, 3, 5, 7);
System.out.println("f = " + f);
 
// g) Calculando los elementos en un bucle
List<Integer> g = new ArrayList<>();
for (int n = 2; n <= 30; n++) {
    if (esPrimo(n)) {
        g.add(n);
    }
}
System.out.println("g = " + g);

esPrimo es un método que escribimos aparte, fuera de main:

static boolean esPrimo(int n) {
    if (n < 2) return false;
    for (int d = 2; d * d <= n; d++) {
        if (n % d == 0) return false;
    }
    return true;
}

Qué pasó.

a = [2, 3, 5]
b = [] size=0
c = [2, 3, 5]
d = [2, 3, 5]
e = [2, 3, 5, 7]
f = [2, 3, 5, 7]
g = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

Las siete producen listas. Pero no todas son listas iguales, y aquí está la primera trampa seria.

El error típico: creer que Arrays.asList y List.of dan una lista normal

Sigamos con c (de Arrays.asList) y d (de List.of), e intentemos modificarlas:

c.add(7);        // ¿agrega?
Exception in thread "main" java.lang.UnsupportedOperationException

"Operación no soportada". La lista de Arrays.asList tiene tamaño fijo: puedes cambiar un elemento por otro con set, pero no agregar ni quitar.

c.set(0, 11);
System.out.println(c);
[11, 3, 5]

Y la de List.of es todavía más estricta: no se puede ni cambiar un elemento.

d.set(0, 11);
Exception in thread "main" java.lang.UnsupportedOperationException

¿Por qué existen entonces? Porque muchas veces quieres una lista que nadie modifique: una lista de constantes, un valor de retorno que no quieres que el que lo recibe altere. Eso tiene nombre (inmutabilidad) y es el tema central de la parte 2. Por ahora, la regla práctica:

  • Si vas a agregar o quitar elementos después: new ArrayList<>(), o new ArrayList<>(List.of(...)) si ya sabes los primeros.
  • Si la lista no va a cambiar nunca: List.of(...).
  • Arrays.asList es la forma antigua (Java 1.2); hoy casi siempre quieres una de las otras dos.

Trabajar por posición

Lo que distingue a List de las demás colecciones es que cada elemento tiene una posición, empezando en 0.

System.out.println(g.get(0));       // el primero
System.out.println(g.get(3));       // el cuarto
System.out.println(g.contains(7));  // ¿está el 7?
System.out.println(g.indexOf(7));   // ¿en qué posición?
 
g.remove(Integer.valueOf(29));      // quita el ELEMENTO 29
System.out.println(g);
g.remove(0);                        // quita el elemento en la POSICIÓN 0
System.out.println(g);
2
7
true
3
[2, 3, 5, 7, 11, 13, 17, 19, 23]
[3, 5, 7, 11, 13, 17, 19, 23]

Fíjate en los dos remove. Con una lista de Integer, remove(29) se entendería como "quita la posición 29" y fallaría; por eso se escribe Integer.valueOf(29) para decir "quita el elemento cuyo valor es 29". Con listas de texto u otros objetos no hay esa confusión.

Una lista de objetos propios

Hasta ahora guardamos números. Una lista guarda cualquier objeto, incluidos los tuyos. Vamos a usar una Persona desde ahora, porque los ejemplos que siguen la necesitan. Es un record, una forma corta de declarar una clase que solo guarda datos (Java 16 en adelante):

record Persona(String nombre, int edad, String ciudad) {}

Esa línea crea la clase con sus tres campos, su constructor y un método de lectura por campo (nombre(), edad(), ciudad()). Va fuera de main, al lado de esPrimo.

List<Persona> personas = new ArrayList<>();
personas.add(new Persona("Ana", 31, "San José"));
personas.add(new Persona("Luis", 45, "Heredia"));
personas.add(new Persona("Marta", 28, "Alajuela"));
 
System.out.println(personas);
System.out.println(personas.get(1).nombre());
[Persona[nombre=Ana, edad=31, ciudad=San José], Persona[nombre=Luis, edad=45, ciudad=Heredia], Persona[nombre=Marta, edad=28, ciudad=Alajuela]]
Luis

ArrayList o LinkedList

Las dos cumplen el contrato List. La diferencia está en cómo guardan los elementos por dentro, y eso cambia qué operaciones son rápidas.

ArrayList guarda los elementos en casillas contiguas; LinkedList en nodos enlazados, cada uno apuntando al siguiente

ArrayList es un array que se agranda solo: ir a la posición 3 es un salto directo. LinkedList es una cadena de nodos: para llegar al tercero hay que pasar por los dos anteriores.

  • ArrayList: get(i) es inmediato, agregar al final es inmediato casi siempre. Insertar o quitar al principio o en el medio obliga a mover todo lo que viene después.
  • LinkedList: agregar o quitar en los extremos es inmediato (addFirst, addLast, removeFirst). Pero get(i) tiene que recorrer la cadena desde el principio.
List<Integer> h = new LinkedList<>(List.of(3, 5, 7));
h.addFirst(2);
h.addLast(11);
System.out.println("h = " + h);
h = [2, 3, 5, 7, 11]

Cuándo usar cada una. ArrayList por defecto, siempre. LinkedList solo si tu programa agrega y quita por el principio todo el tiempo y casi nunca accede por posición; y aun en ese caso, ArrayDeque (sección 6) suele ser mejor. En dieciséis años he necesitado LinkedList muy pocas veces.

Lo que debes llevarte: hay muchas formas de cargar una lista, pero no todas dan una lista modificable. ArrayList es la opción por defecto.

5. Set: la colección que no admite repetidos

Qué es. Un Set (conjunto) es una colección que rechaza elementos repetidos. Si agregas algo que ya está, no pasa nada: el conjunto sigue igual. A cambio, un Set no tiene posiciones: no existe get(0).

Hay tres implementaciones que vas a usar, y la diferencia entre ellas es en qué orden salen los elementos. Para verlo, cargamos los mismos nombres, con repetidos, en las tres.

Ejemplo.

List<String> nombres = List.of("María", "Juan", "Ana", "Juan", "Pedro", "Ana", "Luis");
 
// 1. Las tres se cargan igual: con la lista en el constructor
Set<String> conHash   = new HashSet<>(nombres);
Set<String> conOrden  = new LinkedHashSet<>(nombres);
Set<String> ordenado  = new TreeSet<>(nombres);
 
System.out.println("lista   = " + nombres);
System.out.println("HashSet = " + conHash);
System.out.println("Linked  = " + conOrden);
System.out.println("TreeSet = " + ordenado);

Qué pasó.

lista   = [María, Juan, Ana, Juan, Pedro, Ana, Luis]
HashSet = [Ana, Luis, María, Pedro, Juan]
Linked  = [María, Juan, Ana, Pedro, Luis]
TreeSet = [Ana, Juan, Luis, María, Pedro]

Las tres quitaron los repetidos: de siete nombres quedaron cinco. Pero mira el orden:

  • HashSet los devolvió en un orden que no es el de inserción ni el alfabético. En mi máquina salió así; en la tuya puede salir distinto, y ese es justo el punto: HashSet no promete ningún orden. Si tu código depende del orden de un HashSet, tienes un error esperando a aparecer.
  • LinkedHashSet conservó el orden en que se agregaron (la primera vez que apareció cada uno).
  • TreeSet los devolvió ordenados: alfabéticamente, porque son textos. Si fueran números, de menor a mayor. A ese orden "propio" de cada tipo se le llama orden natural, y en la parte 2 veremos cómo definirlo para tus propias clases.

Por qué HashSet no tiene orden. Un HashSet calcula para cada elemento un número llamado hash (un resumen numérico del contenido: textos iguales dan el mismo número) y usa ese número para decidir en qué casilla interna guardarlo. Por eso saber si un elemento ya está es inmediato: calcula el hash y mira esa casilla. El precio es que las casillas no siguen el orden en que agregaste las cosas. LinkedHashSet hace lo mismo pero además enlaza los elementos en orden de llegada; TreeSet no usa hash, usa un árbol que mantiene todo ordenado.

Agregar y preguntar.

System.out.println(conHash.add("Juan"));     // ya estaba
System.out.println(conHash.add("Rosa"));     // es nuevo
System.out.println(conHash.contains("Ana"));
false
true
true

add devuelve false cuando el elemento ya estaba. Es una forma muy útil de detectar repetidos sin preguntar antes.

Cuándo usar cada uno. HashSet cuando solo te importa "¿está o no está?" y los repetidos deben desaparecer. LinkedHashSet cuando además necesitas que el orden de llegada se conserve (por ejemplo, para mostrarlos como el usuario los escribió). TreeSet cuando los quieres siempre ordenados.

Lo que debes llevarte: Set quita repetidos. HashSet no promete orden, LinkedHashSet conserva el de llegada y TreeSet ordena.

6. Queue y Deque: sacar en cierto orden

Qué es. Una Queue (cola) es una colección pensada para meter por un lado y sacar por el otro, como una fila: el primero que llegó es el primero que sale. Una Deque (cola de dos extremos, se pronuncia "dec") permite meter y sacar por los dos lados, y por eso también sirve como pila: el último que entra es el primero que sale, como una pila de platos.

La analogía de la fila conserva la estructura, así que la uso: en una fila de banco, offer es ponerte al final, peek es mirar quién está primero sin moverlo y poll es atender al primero y sacarlo de la fila.

Ejemplo como fila.

Queue<String> fila = new ArrayDeque<>();
fila.offer("Ana");     // 1. llegan en este orden
fila.offer("Luis");
fila.offer("Marta");
System.out.println(fila);
 
System.out.println(fila.peek());   // 2. ¿quién sigue? (no lo saca)
System.out.println(fila.poll());   // 3. atendemos al primero (lo saca)
System.out.println(fila);

Qué pasó.

[Ana, Luis, Marta]
Ana
Ana
[Luis, Marta]

Ejemplo como pila. El historial de "atrás" de un navegador es una pila: la última página que visitaste es la primera a la que vuelves.

Deque<String> pila = new ArrayDeque<>();
pila.push("pagina1");   // 1. visitamos tres páginas
pila.push("pagina2");
pila.push("pagina3");
System.out.println(pila);
 
System.out.println(pila.pop());   // 2. "atrás" devuelve la última
System.out.println(pila);
[pagina3, pagina2, pagina1]
pagina3
[pagina2, pagina1]

ArrayDeque sirve para las dos cosas. Si alguna vez ves la clase Stack en código viejo, es la versión antigua de esto; en la parte 2 explico por qué ya no se usa.

PriorityQueue: sale primero el más importante. Es una cola donde poll no devuelve el que llegó primero, sino el menor según el orden natural (o el que tú definas). Si cargamos primos desordenados:

PriorityQueue<Integer> porPrioridad = new PriorityQueue<>(List.of(13, 2, 11, 3, 7, 5));
System.out.println(porPrioridad);
 
while (!porPrioridad.isEmpty()) {
    System.out.print(porPrioridad.poll() + " ");
}
[2, 3, 5, 13, 7, 11]
2 3 5 7 11 13

Fíjate: al imprimir la cola entera, el orden interno no está ordenado del todo ([2, 3, 5, 13, 7, 11]). PriorityQueue solo garantiza que el que sale con poll es el menor en ese momento; no promete nada del resto. Es otro caso de "no dependas de un orden que la colección no promete".

Cuándo usarlo. ArrayDeque para colas de tareas y para pilas (deshacer, historial, recorrer árboles). PriorityQueue cuando lo siguiente a procesar es "el más urgente", no "el más antiguo".

Lo que debes llevarte: Queue saca en orden de llegada, Deque también sirve de pila, PriorityQueue saca el menor. Para las dos primeras, ArrayDeque.

7. Map: buscar por clave

Qué es. Un Map guarda pares: una clave y un valor asociado a ella. La clave no se repite; el valor sí puede. La pregunta que responde un Map no es "¿está?" sino "¿cuál es el valor de esta clave?".

La analogía que conserva la estructura: casilleros con etiqueta única. La etiqueta es la clave; lo que hay dentro es el valor. Dos casilleros no pueden tener la misma etiqueta, pero sí el mismo contenido. Donde la analogía falla: en un Map la etiqueta puede ser cualquier objeto, no solo un nombre.

Ejemplo. Edad por nombre:

Map<String, Integer> edades = new HashMap<>();
edades.put("Ana", 31);        // 1. guardamos clave -> valor
edades.put("Luis", 45);
edades.put("Marta", 28);
System.out.println(edades);
 
System.out.println(edades.get("Luis"));             // 2. buscamos por clave
System.out.println(edades.get("Pedro"));            // 3. una clave que no existe
System.out.println(edades.getOrDefault("Pedro", 0)); // 4. lo mismo, con valor por defecto
 
edades.put("Ana", 32);                              // 5. misma clave: reemplaza
System.out.println(edades);

Qué pasó.

{Marta=28, Ana=31, Luis=45}
45
null
0
{Marta=28, Ana=32, Luis=45}

Tres cosas que ver:

  • get de una clave que no existe devuelve null, no lanza error. Si no quieres lidiar con null, getOrDefault te da un valor de reemplazo.
  • put con una clave que ya existe reemplaza el valor. No se duplica la clave.
  • El orden en que se imprimió (Marta, Ana, Luis) no es el de inserción. HashMap funciona como HashSet: usa el hash de la clave para ubicarla, y por eso no promete orden.

El ejemplo que hace clic: contar

El uso más común de un Map es contar cosas. Contemos cuántos nombres empiezan con cada letra:

List<String> nombres = List.of("María", "Juan", "Ana", "Juan", "Pedro", "Ana", "Luis");
 
Map<Character, Integer> conteo = new HashMap<>();
for (String nombre : nombres) {
    char inicial = nombre.charAt(0);
    // 1. leemos cuánto llevamos (0 si es la primera vez)
    int actual = conteo.getOrDefault(inicial, 0);
    // 2. guardamos uno más
    conteo.put(inicial, actual + 1);
}
System.out.println(conteo);
{P=1, A=2, J=2, L=1, M=1}

Eso funciona, pero Java tiene un método que hace los dos pasos de una vez: merge(clave, valorSiEsNueva, cómoCombinar).

Map<Character, Integer> conteo2 = new HashMap<>();
for (String nombre : nombres) {
    conteo2.merge(nombre.charAt(0), 1, Integer::sum);
}
System.out.println(conteo2);
{P=1, A=2, J=2, L=1, M=1}

Léelo así: "para esta clave, si no existe guarda 1; si existe, combina lo que había con 1 usando la suma". Integer::sum es una referencia al método sum de Integer; es lo mismo que escribir (viejo, nuevo) -> viejo + nuevo.

Y si en vez de contar quieres agrupar (todos los nombres por su inicial), el método es computeIfAbsent: "si la clave no tiene valor, créalo con esta función; luego devuélvemelo".

Map<Character, List<String>> porInicial = new HashMap<>();
for (String nombre : nombres) {
    porInicial.computeIfAbsent(nombre.charAt(0), k -> new ArrayList<>()).add(nombre);
}
System.out.println(porInicial);
{P=[Pedro], A=[Ana, Ana], J=[Juan, Juan], L=[Luis], M=[María]}

Sin computeIfAbsent tendrías que escribir "si no está la clave, crea la lista, guárdala; luego búscala y agrega". Son cuatro líneas que se vuelven una.

Las tres implementaciones de Map y su orden

Igual que con Set, hay tres y se diferencian en el orden. Cargamos ocho nombres en las tres:

List<String> claves = List.of("Marta", "Ana", "Luis", "Carlos", "Beatriz", "Diego", "Elena", "Zoe");
 
Map<String, Integer> conHash  = new HashMap<>();
Map<String, Integer> conOrden = new LinkedHashMap<>();
Map<String, Integer> ordenado = new TreeMap<>();
 
for (String clave : claves) {
    conHash.put(clave, clave.length());
    conOrden.put(clave, clave.length());
    ordenado.put(clave, clave.length());
}
System.out.println("HashMap       = " + conHash);
System.out.println("LinkedHashMap = " + conOrden);
System.out.println("TreeMap       = " + ordenado);
HashMap       = {Marta=5, Zoe=3, Ana=3, Luis=4, Diego=5, Carlos=6, Beatriz=7, Elena=5}
LinkedHashMap = {Marta=5, Ana=3, Luis=4, Carlos=6, Beatriz=7, Diego=5, Elena=5, Zoe=3}
TreeMap       = {Ana=3, Beatriz=7, Carlos=6, Diego=5, Elena=5, Luis=4, Marta=5, Zoe=3}

Mismo patrón que los Set: HashMap en un orden que no se promete (y que en tu máquina puede cambiar), LinkedHashMap en orden de inserción, TreeMap ordenado por clave.

Recorrer un Map y crear uno fijo

Un Map no se recorre directo con for, porque no es una Collection. Se recorre por sus entradas (cada par clave-valor es un Map.Entry):

for (Map.Entry<String, Integer> entrada : ordenado.entrySet()) {
    System.out.println(entrada.getKey() + " tiene " + entrada.getValue() + " letras");
}
Ana tiene 3 letras
Beatriz tiene 7 letras
Carlos tiene 6 letras
...

También puedes recorrer solo las claves (keySet()) o solo los valores (values()).

Y como List.of, existe Map.of para un mapa que no cambia:

Map<String, Integer> fijo = Map.of("Ana", 31, "Luis", 45);
System.out.println(fijo.get("Ana"));
fijo.put("Marta", 28);
31
Exception in thread "main" java.lang.UnsupportedOperationException

Map con objetos propios como valor

Lo más frecuente en un programa real es un Map donde el valor es un objeto tuyo, para buscarlo rápido por su identificador:

Map<String, Persona> porNombre = new HashMap<>();
for (Persona p : personas) {
    porNombre.put(p.nombre(), p);
}
System.out.println(porNombre.get("Marta").ciudad());
Alajuela

Cuándo usar cada uno. HashMap por defecto. LinkedHashMap si vas a mostrar las entradas y quieres el orden de llegada. TreeMap si las quieres ordenadas por clave, o si vas a necesitar preguntas como "la clave más cercana a esta" (parte 2).

Lo que debes llevarte: Map responde "¿cuál es el valor de esta clave?". merge cuenta, computeIfAbsent agrupa, y el orden sigue la misma regla que en Set.

8. EnumSet y EnumMap: cuando la clave es un enum

Qué es. Un enum es un tipo con un conjunto fijo de valores posibles (los días de la semana, los estados de un pedido). Cuando los elementos de un Set o las claves de un Map son de un enum, Java tiene dos implementaciones especiales: EnumSet y EnumMap. Hacen lo mismo que HashSet y HashMap, pero más rápido y con los elementos siempre en el orden en que el enum los declara.

Ejemplo. Los estados de un pedido:

enum EstadoPedido { NUEVO, PAGADO, ENVIADO, ENTREGADO, CANCELADO }

(Va fuera de main, como Persona.)

// 1. Un conjunto de estados "activos"
Set<EstadoPedido> activos = EnumSet.of(EstadoPedido.NUEVO, EstadoPedido.PAGADO, EstadoPedido.ENVIADO);
System.out.println(activos);
System.out.println(activos.contains(EstadoPedido.CANCELADO));
 
// 2. Su complemento: todos los que NO están
Set<EstadoPedido> finales = EnumSet.complementOf(EnumSet.copyOf(activos));
System.out.println(finales);
 
// 3. Un mapa de cuántos pedidos hay por estado
Map<EstadoPedido, Integer> pedidosPorEstado = new EnumMap<>(EstadoPedido.class);
pedidosPorEstado.put(EstadoPedido.ENVIADO, 4);
pedidosPorEstado.put(EstadoPedido.NUEVO, 10);
pedidosPorEstado.put(EstadoPedido.CANCELADO, 1);
System.out.println(pedidosPorEstado);

Qué pasó.

[NUEVO, PAGADO, ENVIADO]
false
[ENTREGADO, CANCELADO]
{NUEVO=10, ENVIADO=4, CANCELADO=1}

Las claves del EnumMap salieron en el orden del enum (NUEVO antes que ENVIADO), no en el orden en que las insertamos. EnumMap necesita que le pases la clase del enum en el constructor (EstadoPedido.class) porque reserva internamente una casilla por cada valor posible; por eso es tan rápido.

Cuándo usarlos. Siempre que el tipo del elemento o de la clave sea un enum. No hay razón para usar HashSet<EstadoPedido> en vez de EnumSet.

Lo que debes llevarte: si la clave es un enum, EnumSet/EnumMap: más rápidos, y siempre en el orden del enum.

9. Recorrer: for-each, Iterator y forEach

Ya recorrimos varias colecciones con for. Vale la pena detenerse, porque hay un error que todos cometemos exactamente una vez.

Las tres formas de recorrer una lista.

List<Integer> primos = new ArrayList<>(List.of(3, 5, 7, 11, 13, 17, 19, 23));
 
// a) for-each: la forma normal
for (Integer p : primos) {
    System.out.print(p + " ");
}
System.out.println();
 
// b) por índice: cuando necesitas la posición
for (int i = 0; i < primos.size(); i++) {
    System.out.print(i + ":" + primos.get(i) + " ");
}
System.out.println();
 
// c) forEach con una lambda: cuando la acción cabe en una línea
primos.forEach(p -> System.out.print(p + " "));
System.out.println();
3 5 7 11 13 17 19 23
0:3 1:5 2:7 3:11 4:13 5:17 6:19 7:23
3 5 7 11 13 17 19 23

Una lambda (p -> ...) es una función sin nombre escrita en el lugar: "dado p, haz esto". Para un Map, forEach recibe dos parámetros, clave y valor:

edades.forEach((nombre, edad) -> System.out.println(nombre + " -> " + edad));

El error típico: quitar elementos dentro de un for-each

Queremos quitar los primos mayores que 10:

for (Integer p : primos) {
    if (p > 10) {
        primos.remove(p);
    }
}
Exception in thread "main" java.util.ConcurrentModificationException

El for-each usa por dentro un objeto llamado Iterator (iterador), que va apuntando al elemento actual. Si modificas la lista por fuera de ese iterador mientras él la recorre, el iterador detecta que la lista cambió bajo sus pies y lanza esta excepción. El nombre asusta ("modificación concurrente"), pero aquí no hay hilos: es un solo bucle modificando lo que recorre.

Las dos soluciones correctas. La clásica es usar el iterador tú mismo y quitar a través de él:

Iterator<Integer> it = primos.iterator();
while (it.hasNext()) {
    Integer p = it.next();
    if (p > 10) {
        it.remove();     // quita el último que devolvió next()
    }
}
System.out.println(primos);
[3, 5, 7]

Y la moderna, que es la que uso siempre: removeIf.

primos.removeIf(p -> p > 10);
System.out.println(primos);
[3, 5, 7]

Lo que debes llevarte: nunca modifiques una colección dentro de un for-each que la recorre. Para quitar, removeIf.

10. Cómo elegir: la guía de decisión

Después de todo esto, elegir una colección se reduce a tres o cuatro preguntas. Hazlas en este orden:

Árbol de decisión para elegir una colección: ¿clave-valor? ¿repetidos? ¿posiciones? ¿orden? ¿sacar en orden?

Las preguntas, en orden: ¿busco por clave? → Map. ¿Prohíbo repetidos? → Set. ¿Saco elementos por un extremo? → Queue/Deque. Si no, List. Y en cada familia, el orden decide la implementación.

  1. ¿Necesito buscar un valor a partir de una clave? Sí → Map. Si no, sigue.
  2. ¿Debo impedir repetidos? Sí → Set. Si no, sigue.
  3. ¿Lo que hago es meter por un lado y sacar por otro (fila, pila, el más urgente)? Sí → Queue/Deque. Si no, List.
  4. Dentro de la familia, ¿qué orden necesito? Ninguno → Hash...; el de llegada → Linked...; ordenado → Tree.... Para List, ArrayList. Para Queue/Deque, ArrayDeque (o PriorityQueue si es por prioridad). Si la clave es un enum, Enum....

Nota de Java 21: SequencedCollection

Desde Java 21, todas las colecciones que tienen un orden definido (List, LinkedHashSet, TreeSet, ArrayDeque...) comparten una interfaz nueva, SequencedCollection, con métodos para los extremos: getFirst(), getLast(), addFirst(), removeLast() y reversed(). Para mapas con orden (LinkedHashMap, TreeMap) está SequencedMap, con firstEntry(), lastEntry() y reversed().

System.out.println(primos.getFirst() + " " + primos.getLast());
System.out.println(primos.reversed());
3 23
[23, 19, 17, 13, 11, 7, 5, 3]

Y aquí vuelve la lección de la sección 3, con un ejemplo real que me pasó escribiendo este artículo. Intenté esto:

Set<String> conOrden = new LinkedHashSet<>(nombres);
conOrden.getFirst();
error: cannot find symbol
  symbol:   method getFirst()
  location: variable conOrden of type Set<String>

El objeto es un LinkedHashSet, que sí tiene getFirst(). Pero la variable está declarada como Set, y Set no promete orden, así que no tiene ese método. La interfaz con la que declaras decide qué puedes llamar. La solución es declarar con la interfaz que sí lo promete:

SequencedSet<String> conOrden = new LinkedHashSet<>(nombres);
System.out.println(conOrden.getFirst() + " " + conOrden.getLast());
María Luis

Si trabajas con Java 17 o anterior, nada de esta sección existe: getFirst() se escribe get(0), y para el último, get(size() - 1).

Lo que debes llevarte: clave → Map; sin repetidos → Set; sacar por un extremo → Queue; el resto → List. Y la interfaz que declaras es la que te da los métodos.

Existen, pero no las necesitas todavía

Para que no te sorprendan cuando las veas:

  • WeakHashMap: un mapa cuyas claves pueden ser liberadas por el recolector de basura. Para cachés especiales.
  • IdentityHashMap: compara claves por identidad (==) en vez de por contenido. Casos muy raros.
  • BitSet: un conjunto de bits, para marcar "sí/no" de forma muy compacta.
  • ConcurrentSkipListMap / ConcurrentSkipListSet: TreeMap/TreeSet seguros para varios hilos.
  • Vector, Stack, Hashtable: las versiones antiguas de ArrayList, ArrayDeque y HashMap. Las explico en la parte 2, en la sección de concurrencia, porque ahí se entiende por qué dejaron de usarse.

Lo que debes llevarte de todo el artículo

  1. Un array tiene tamaño fijo; una colección crece sola.
  2. List es un contrato y ArrayList una forma de cumplirlo. Declaras con el contrato y eliges la implementación en el new.
  3. No todas las formas de cargar una lista dan una lista modificable: List.of y Arrays.asList no.
  4. List tiene posiciones y repetidos; Set no tiene repetidos; Queue saca por un extremo; Map busca por clave.
  5. En Set y Map, Hash... no promete orden, Linked... conserva el de llegada y Tree... ordena.
  6. Nunca modifiques una colección dentro del for-each que la recorre.
  7. La interfaz con la que declaras decide qué métodos puedes llamar.

En la parte 2 entramos en lo que aquí dejé señalado: por qué List.of se niega a cambiar y cuándo eso es lo que quieres, qué hace falta para que un HashSet de Persona reconozca repetidos, cómo definir el orden natural de tus clases, y qué pasa con todo esto cuando hay varios hilos.

¿Cuál de las siete formas de cargar una lista usas más en tu código, y sabías cuáles de ellas no se pueden modificar después? Me interesa leerlo en los comentarios.

Referencias

Ver versión en inglés