Programación en Java
La API Streams de Java: pipelines, collectors y trampas habituales
Intermedio7 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Un stream es un pipeline de operaciones sobre una secuencia de elementos, que se evalúa de forma perezosa y se pone en marcha con una operación terminal. Esta guía explica el modelo, repasa las operaciones intermedias y terminales que usarás a diario, muestra cómo los collectors convierten un stream de nuevo en datos y enumera los errores —reutilizar streams, introducir efectos secundarios, paralelizar a la ligera— que causan bugs reales.
Lo que aprenderás
- Explicar el modelo de los streams: una fuente, operaciones intermedias perezosas y una operación terminal
- Elegir la operación adecuada para filtrar, transformar, aplanar, ordenar y reducir
- Usar Collectors para agrupar, particionar, concatenar y resumir datos
- Decidir cuándo un stream es más claro que un bucle y cuándo no lo es
- Reconocer los errores habituales, incluidos los casos en que los streams paralelos hacen el código más lento
Antes de Java 8, transformar una colección implicaba un bucle, una lista temporal y bastante ceremonia que ocultaba lo que el código hacía realmente. Los streams sustituyeron todo eso por un pipeline: describes qué quieres —filtrar estos, transformar aquellos, sumar el resultado— y dejas que la librería se encargue de la iteración. Bien usado, el código con streams se lee como una especificación. Usado sin cuidado, acaba siendo más lento y más difícil de depurar que el bucle al que sustituyó. Esta guía cubre ambas caras.
El modelo de los streams
Un stream no es una estructura de datos. No almacena elementos propios; es una vista sobre una fuente —una colección, un array, una función generadora, las líneas de un fichero— por la que los elementos fluyen de uno en uno. Un pipeline de stream tiene tres partes:
- Una fuente que produce los elementos:
list.stream(),Arrays.stream(array),Stream.of(a, b, c),IntStream.range(0, 10),Files.lines(path). - Cero o más operaciones intermedias que devuelven un stream nuevo:
filter,map,sorted,distinct,limit. Son perezosas: no se ejecuta nada cuando las llamas. - Una operación terminal que produce un resultado o un efecto secundario y pone en marcha todo el pipeline:
collect,forEach,count,findFirst,reduce,anyMatch.
List<String> names = List.of("Ada", "Grace", "Linus", "Barbara", "Ken");
List<String> result = names.stream() // source
.filter(n -> n.length() > 3) // intermediate, lazy
.map(String::toUpperCase) // intermediate, lazy
.sorted() // intermediate, lazy
.toList(); // terminal — runs everything
// [BARBARA, GRACE, LINUS]
La evaluación perezosa importa por dos motivos. Primero, el pipeline puede cortocircuitarse: un findFirst() después de un filter se detiene en cuanto un elemento pasa el filtro, sin examinar el resto. Segundo, las operaciones se fusionan: cada elemento pasa por filter, luego por map y después sigue adelante, en lugar de filtrar toda la lista en una lista temporal y transformarla después. Añade un peek para ver el orden:
Stream.of("a", "bb", "ccc")
.peek(s -> System.out.println("filter sees " + s))
.filter(s -> s.length() > 1)
.peek(s -> System.out.println("map sees " + s))
.map(String::length)
.findFirst();
// filter sees a
// filter sees bb
// map sees bb
// (ccc is never examined)
Un stream se puede consumir una sola vez. Tras ejecutarse la operación terminal, el stream queda cerrado; llamar a otra operación sobre él lanza IllegalStateException: stream has already been operated upon or closed. Si necesitas recorrer los datos dos veces, crea dos streams a partir de la fuente.
Las operaciones intermedias que de verdad vas a usar
La mayor parte del código con streams del día a día se apoya en un vocabulario reducido.
| Operación | Qué hace | Uso típico |
|---|---|---|
filter(Predicate) | Conserva los elementos que cumplen el predicado | Seleccionar un subconjunto |
map(Function) | Sustituye cada elemento por un elemento nuevo | Extraer un campo, convertir el tipo |
flatMap(Function) | Sustituye cada elemento por un stream y lo aplana | Colecciones anidadas |
mapMulti(BiConsumer) | Como flatMap, pero sin crear un stream interno (Java 16) | Expandir elementos con poco coste |
distinct() | Elimina duplicados según equals | Deduplicación |
sorted() / sorted(Comparator) | Ordena; obliga a almacenar todos los elementos en un búfer | Ordenar antes de la salida |
limit(n) / skip(n) | Trunca o descarta un prefijo | Paginación, «top N» |
takeWhile / dropWhile | Prefijo según un predicado (Java 9) | Datos ordenados |
peek(Consumer) | Ejecuta una acción sin modificar el elemento | Solo para depurar |
flatMap es la que más cuesta visualizar. Supón que cada Order tiene una lista de LineItem y quieres todas las líneas de todos los pedidos:
List<LineItem> allItems = orders.stream()
.flatMap(order -> order.items().stream())
.toList();
map te daría un Stream<List<LineItem>>, es decir, un stream de listas. flatMap te da un Stream<LineItem>.
Las especializaciones primitivas IntStream, LongStream y DoubleStream evitan el boxing y añaden operaciones numéricas como sum(), average() y summaryStatistics(). Para pasar de unas a otras se usan mapToInt, mapToObj y boxed():
int totalCents = orders.stream()
.flatMap(o -> o.items().stream())
.mapToInt(LineItem::priceCents)
.sum();
Operaciones terminales y reducción
Una operación terminal cierra el pipeline. Las habituales se agrupan en cuatro familias:
- Recoger en algo:
collect(Collector),toList()(Java 16),toArray(). - Reducir a un único valor:
count(),sum(),min(Comparator),max(Comparator),reduce(identity, accumulator). - Buscar:
findFirst(),findAny(),anyMatch(p),allMatch(p),noneMatch(p). Todas cortocircuitan. - Efectos secundarios:
forEach(action),forEachOrdered(action).
reduce merece un análisis más detallado porque es la forma general de las demás. Combina los elementos de dos en dos con una función asociativa, partiendo de un valor identidad:
int total = IntStream.of(3, 5, 7).reduce(0, Integer::sum); // 15
Optional<String> longest = words.stream()
.reduce((a, b) -> a.length() >= b.length() ? a : b); // no identity → Optional
El acumulador debe ser asociativo y la identidad debe serlo de verdad (0 para la suma, 1 para el producto, "" para la concatenación). La librería se basa en ello para repartir el trabajo en los streams paralelos; si te equivocas, una ejecución en paralelo dará un resultado distinto del de una secuencial.
Collectors: convertir un stream de nuevo en datos
Es en Collectors donde los streams demuestran lo que valen. groupingBy y partitioningBy sustituyen decenas de líneas de construcción manual de mapas.
record Employee(String name, String department, int salary) {}
// Employees per department
Map<String, List<Employee>> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department));
// Average salary per department
Map<String, Double> avgSalary = employees.stream()
.collect(Collectors.groupingBy(Employee::department,
Collectors.averagingInt(Employee::salary)));
// Names per department, as a single comma-separated string
Map<String, String> namesByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department,
TreeMap::new,
Collectors.mapping(Employee::name, Collectors.joining(", "))));
// Split into high and low earners
Map<Boolean, List<Employee>> highEarners = employees.stream()
.collect(Collectors.partitioningBy(e -> e.salary() > 100_000));
El patrón es siempre el mismo: una función clasificadora elige la clave y un collector descendente (downstream collector) opcional decide qué ocurre con los elementos que comparten clave. counting(), summingInt, averagingDouble, mapping, filtering, maxBy, toSet y joining son los collectors descendentes que más vas a usar. teeing (Java 12) ejecuta dos collectors y combina sus resultados, lo que resulta útil para calcular, por ejemplo, un mínimo y un máximo en una sola pasada.
Dos detalles de toMap provocan bugs en producción. El primero: toMap(keyFn, valueFn) lanza IllegalStateException ante una clave duplicada; pasa una función de fusión como tercer argumento si puede haber duplicados. El segundo: rechaza los valores null, así que conviértelos en un valor centinela o fíltralos antes.
¿Streams o bucles?
Un stream es la mejor opción cuando el código es una transformación: tomar unos datos, filtrarlos, darles otra forma, agregarlos. El pipeline expresa la intención y cada paso se entiende por separado. Un bucle es mejor opción cuando:
- Necesitas salir antes de tiempo según una condición compleja que no encaja en
takeWhileni enanyMatch. - El cuerpo tiene varios efectos secundarios o modifica varias variables: los streams te empujan hacia un
forEachcon estado mutable capturado, que reúne lo peor de ambos mundos. - Estás manejando excepciones comprobadas (checked exceptions). Las interfaces funcionales estándar no pueden lanzarlas, así que el código con streams termina envolviendo cada llamada en un try/catch dentro de la lambda, lo que queda mucho más recargado que un bucle.
- El rendimiento es crítico y la colección es pequeña: el coste adicional por elemento de un stream se nota en bucles ajustados.
Una regla práctica: si te encuentras escribiendo un stream con un forEach que añade elementos a una lista declarada fuera del stream, reescríbelo con collect, o como un bucle.
Streams paralelos
Llamar a parallelStream() o a .parallel() divide la fuente en fragmentos que se procesan en el ForkJoinPool común. Activarlo cuesta una línea, y arrepentirse, otra. Los streams paralelos ayudan cuando se cumplen todas estas condiciones:
- La fuente se divide de forma barata y equilibrada (
ArrayList, arrays,IntStream.range; no unaLinkedListni un iterador). - El trabajo por elemento es intensivo en CPU y considerable, no E/S ni un paso aritmético trivial.
- Hay suficientes elementos: decenas de miles, no unas pocas decenas.
- Las operaciones no tienen estado;
sorted,distinctylimitsobre un stream ordenado obligan a fusiones costosas.
En caso contrario, la sobrecarga de coordinación domina y la versión paralela es más lenta. Hay una segunda trampa: el pool común lo comparte toda la JVM. Un stream paralelo que hace E/S bloqueante deja sin recursos a todos los demás streams paralelos y a los CompletableFuture del proceso. Mantén el trabajo bloqueante completamente fuera de los streams paralelos.
// Reasonable candidate: large array, pure CPU work
long primes = IntStream.rangeClosed(2, 5_000_000).parallel()
.filter(Primes::isPrime)
.count();
// Bad candidate: tiny list, blocking call
customers.parallelStream().forEach(c -> emailService.send(c)); // don't
Errores habituales
Efectos secundarios en las lambdas. Modificar una colección o un contador compartidos desde map o filter provoca una condición de carrera con parallel() y resulta confuso incluso en secuencial. Usa collectors.
Lambdas con estado. Una lambda que depende de lo que ha visto antes (un total acumulado guardado en una posición de un array, por ejemplo) rompe la premisa de que los elementos son independientes. La herramienta adecuada es reduce o un bucle.
Olvidar la operación terminal. Un pipeline de operaciones intermedias sin llamada terminal no hace absolutamente nada. El código compila y se salta el trabajo sin avisar.
Reutilizar un stream. Guardar un stream en una variable y llamar a dos operaciones terminales sobre él lanza una excepción en tiempo de ejecución.
Ordenar cuando solo necesitas el primer elemento. sorted().findFirst() ordena todo; min(comparator) hace una sola pasada.
No cerrar los streams de Files.lines. Los streams respaldados por un fichero mantienen un descriptor abierto. Envuélvelos en un try-with-resources.
Tratar con get() el Optional que devuelve findFirst. Usa orElse, orElseThrow o ifPresent; un get() a secas sobre un resultado vacío lanza NoSuchElementException.
Hacia dónde va la API
Java 22 hizo definitivos los stream gatherers (JEP 461): Stream.gather(Gatherer) es a las operaciones intermedias lo que collect es a las terminales, una forma de escribir tus propios pasos de ventaneo, fold o scan que encajan en el pipeline. Los gatherers incluidos Gatherers.windowFixed, windowSliding, fold y scan cubren casos que antes obligaban a volver a un bucle. Si trabajas con una versión actual, apréndelos antes de recurrir a un iterador escrito a mano.
Puntos clave
- Un stream es un pipeline perezoso: fuente → operaciones intermedias → una operación terminal. No se ejecuta nada hasta la llamada terminal.
filter,map,flatMap,sorted,distinctylimitcubren la mayoría de las transformaciones; los streams primitivos evitan el boxing en el trabajo numérico.- Los collectors —sobre todo
groupingBycon un collector descendente— sustituyen la construcción manual de mapas. Recuerda las reglas detoMapsobre claves duplicadas y valores null. - Usa streams para las transformaciones y bucles para las salidas anticipadas, las excepciones comprobadas y la modificación de varias variables.
- Los streams paralelos son una herramienta de rendimiento solo para trabajo grande, divisible e intensivo en CPU; nunca metas E/S bloqueante en uno.
- Los streams son de un solo uso, las lambdas no deben tener efectos secundarios y el acumulador que pases a
reducedebe ser asociativo.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Son los streams más rápidos que los bucles for?
- Normalmente no, y con colecciones pequeñas suelen ser algo más lentos por los objetos del pipeline y las llamadas a lambdas. Los streams aportan claridad y composición, no velocidad bruta. Los streams paralelos pueden ser más rápidos con trabajo grande, intensivo en CPU y fácil de dividir, pero también pueden ser más lentos: mide antes de decidirte.
- ¿Puedo reutilizar un stream después de llamar a una operación terminal?
- No. Un stream solo se puede recorrer una vez. Llamar a cualquier operación sobre un stream ya consumido lanza IllegalStateException. Crea un stream nuevo a partir de la fuente cada vez, o recoge los resultados en una colección si necesitas iterar dos veces.
- ¿Qué diferencia hay entre map y flatMap?
- map transforma cada elemento en exactamente un elemento nuevo. flatMap transforma cada elemento en un stream de cero o más elementos y concatena esos streams, así que es la herramienta para aplanar colecciones anidadas; por ejemplo, para pasar de un stream de pedidos a un stream con todas sus líneas de pedido.
- ¿Cuándo usar Collectors.toList() y cuándo Stream.toList()?
- Stream.toList(), añadido en Java 16, devuelve una lista no modificable y es la opción más corta cuando no necesitas mutar el resultado. La especificación de Collectors.toList() no garantiza nada sobre la mutabilidad, aunque la implementación actual devuelve un ArrayList. Prefiere Stream.toList() salvo que necesites una lista mutable; en ese caso usa Collectors.toCollection(ArrayList::new) para dejarlo explícito.