Nivel inicial13 min de lectura · Publicado el

Java Stream: guía práctica con ejemplos (Java 25)

Java Stream como cinta transportadora: cinco usuarios pasan por filter (edad 32), map (su nombre) y toList, que recoge Marta y Antonio. Sin operación final, no arrancarubentejera.com 3232343518filtermap3232toListMartaAntonio
Cada bola es un usuario con su edad, y el primero va delante. filter deja pasar los de 32 años, map cambia cada uno por su nombre y toList los recoge. Sin una operación final (toList, count…), la cinta no arranca.

Tienes una lista y quieres quedarte con unos elementos, transformarlos, ordenarlos o agruparlos. Con un bucle for se puede, pero el código acaba lleno de listas auxiliares y condiciones. La API Stream de Java lo resuelve en unas pocas líneas que se leen de arriba abajo.

Un Stream es como una cinta transportadora de una fábrica: los elementos salen de la lista, pasan por varias estaciones (una filtra, otra transforma, otra ordena) y al final alguien los recoge en una caja. Si nadie está esperando al final de la cinta, la cinta no arranca.

Todos los ejemplos de esta guía se han ejecutado con Java 25 (la versión LTS actual) y muestran su salida real.

Las tres partes de un Stream

List<String> nombres = users.stream()     // 1. Fuente: de dónde salen los datos
        .filter(user -> user.age() > 30)  // 2. Operaciones intermedias: devuelven otro Stream
        .map(User::name)
        .toList();                        // 3. Operación terminal: produce el resultado
Parte Qué es Ejemplos
Fuente Lo que crea el Stream lista.stream(), Stream.of(...), Arrays.stream(array)
Intermedia Transforma la cinta y devuelve otro Stream. Es perezosa: no hace nada hasta que llega la terminal filter, map, flatMap, sorted, distinct, skip, limit, gather
Terminal Pone la cinta en marcha y da el resultado toList, collect, forEach, reduce, count, anyMatch, findFirst

¿Qué hay que importar?

Stream no es una clase sino una interfaz del paquete java.util.stream. Para llamar a lista.stream() no necesitas importar nada; solo importas lo que escribes por su nombre:

import java.util.stream.Stream;      // si usas Stream.of(...) o declaras variables Stream<T>
import java.util.stream.Collectors;  // si usas collect(Collectors.groupingBy(...)), joining...
import java.util.stream.Gatherers;   // si usas gather(...) (Java 24 o superior)

Los datos de ejemplo

Todos los ejemplos usan esta lista de usuarios. En lugar de una clase con constructor y getters, se declara con un record (disponible desde Java 16), que los genera solo: user.age() devuelve la edad.

import java.util.*;
import java.util.stream.*;

public class StreamEjemplos {

    record User(int id, String name, int age, List<String> languages) {}

    static final List<User> USERS = List.of(
            new User(1, "Marta", 18, List.of("Java", "Kotlin")),
            new User(2, "Pepe", 35, List.of("JavaScript")),
            new User(3, "Juan", 34, List.of("Java", "Ruby")),
            new User(4, "Marta", 32, List.of("Java")),
            new User(5, "Antonio", 32, List.of("Python", "Java")));

    public static void main(String[] args) {
        // Pega aquí cualquiera de los ejemplos
    }
}

Guárdalo como StreamEjemplos.java y ejecútalo sin compilar antes con java StreamEjemplos.java.

filter: quedarte con algunos elementos

filter recibe una condición (un Predicate, una función que devuelve true o false) y deja pasar solo los elementos que la cumplen. El tipo no cambia: entra una lista de usuarios y sale una lista de usuarios.

List<User> de32 = USERS.stream()
        .filter(user -> user.age() == 32)
        .toList();
de32.forEach(System.out::println);
User[id=4, name=Marta, age=32, languages=[Java]]
User[id=5, name=Antonio, age=32, languages=[Python, Java]]

map: transformar cada elemento

map aplica una función a cada elemento y deja en la cinta lo que devuelve. Aquí sí puede cambiar el tipo: entran usuarios y salen números.

List<Integer> edades = USERS.stream()
        .map(User::age)
        .toList();
System.out.println(edades);
[18, 35, 34, 32, 32]

User::age es una referencia a método: una forma corta de escribir user -> user.age().

sorted y distinct: ordenar y quitar repetidos

Sin argumentos, sorted usa el orden natural (alfabético para textos, de menor a mayor para números):

List<String> nombres = USERS.stream()
        .map(User::name)
        .sorted()
        .toList();
System.out.println(nombres);
[Antonio, Juan, Marta, Marta, Pepe]

Para ordenar por un campo, pásale un Comparator. reversed() invierte el orden:

List<String> porEdad = USERS.stream()
        .sorted(Comparator.comparingInt(User::age).reversed())
        .map(user -> user.name() + " (" + user.age() + ")")
        .toList();
System.out.println(porEdad);
[Pepe (35), Juan (34), Marta (32), Antonio (32), Marta (18)]

Y distinct elimina los repetidos:

System.out.println(USERS.stream().map(User::name).distinct().toList());
[Marta, Pepe, Juan, Antonio]

anyMatch, allMatch y noneMatch: preguntas de sí o no

Método Pregunta ¿Algún nombre empieza por «M»?
anyMatch ¿Lo cumple alguno? true
allMatch ¿Lo cumplen todos? false
noneMatch ¿No lo cumple ninguno? false
System.out.println(USERS.stream().anyMatch(user -> user.name().startsWith("M")));
System.out.println(USERS.stream().allMatch(user -> user.name().startsWith("M")));
System.out.println(USERS.stream().noneMatch(user -> user.name().startsWith("M")));
true
false
false

Los tres se detienen en cuanto conocen la respuesta: anyMatch en el primer elemento que cumple, allMatch en el primero que no cumple y noneMatch en el primero que cumple. Ninguno recorre la lista entera si no hace falta.

Si solo quieres el primer elemento que cumpla algo, usa findFirst. Devuelve un Optional, una caja que puede venir vacía; orElse da el valor por defecto. Por qué Optional es mejor que devolver null lo tienes en devolver null o lanzar una excepción:

System.out.println(USERS.stream()
        .filter(user -> user.age() > 30)
        .map(User::name)
        .findFirst()
        .orElse("nadie"));
Pepe

skip y limit: saltar, recortar y paginar

skip(n) se salta los n primeros y limit(n) se queda con los n primeros. Juntos sirven para paginar:

System.out.println(USERS.stream().skip(2).map(User::id).toList());
System.out.println(USERS.stream().limit(2).map(User::id).toList());

int pagina = 1; // la primera página es la 0
int tamano = 2;
System.out.println(USERS.stream()
        .skip((long) pagina * tamano)
        .limit(tamano)
        .map(User::id)
        .toList());
[3, 4, 5]
[1, 2]
[3, 4]

Paginar así sirve para listas que ya tienes en memoria. Si los datos vienen de una base de datos, pagina en la consulta: traer todo para quedarte con dos filas es muy caro.

flatMap: aplanar listas de listas

Cada usuario tiene una lista de lenguajes. Con map obtendrías una lista de listas; flatMap las vacía todas en una sola cinta:

List<String> lenguajes = USERS.stream()
        .flatMap(user -> user.languages().stream())
        .distinct()
        .sorted()
        .toList();
System.out.println(lenguajes);
[Java, JavaScript, Kotlin, Python, Ruby]

toList() o collect(Collectors.toList())

Hasta Java 15, para pasar un Stream a una lista se escribía .collect(Collectors.toList()). Desde Java 16 basta con .toList(), más corto. Hay una diferencia importante:

stream.toList()

  • Disponible desde Java 16
  • Devuelve una lista que no se puede modificar
  • Si llamas a add, lanza UnsupportedOperationException

collect(Collectors.toList())

  • Funciona en cualquier versión desde Java 8
  • La documentación no garantiza si se puede modificar ni de qué tipo es
  • Si necesitas añadir elementos, usa Collectors.toCollection(ArrayList::new)
List<String> lista = Stream.of("Marta", "Pepe").toList();
try {
    lista.add("Juan");
} catch (UnsupportedOperationException e) {
    System.out.println("No se puede añadir: " + e.getClass().getSimpleName());
}

List<String> modificable = Stream.of("Marta", "Pepe")
        .collect(Collectors.toCollection(ArrayList::new));
modificable.add("Juan");
System.out.println(modificable);
No se puede añadir: UnsupportedOperationException
[Marta, Pepe, Juan]

Collectors: joining, groupingBy, counting y compañía

collect es la operación terminal para resultados más elaborados que una lista. La clase Collectors trae los más habituales.

joining: unir textos

System.out.println(USERS.stream()
        .map(User::name)
        .distinct()
        .collect(Collectors.joining(", ", "[", "]")));
[Marta, Pepe, Juan, Antonio]

Los tres argumentos son el separador, el prefijo y el sufijo. Con solo joining(", ") no hay corchetes.

groupingBy: agrupar por un campo

Es como repartir el correo en casilleros: cada usuario va al casillero de su edad.

Map<Integer, List<String>> nombresPorEdad = USERS.stream()
        .collect(Collectors.groupingBy(
                User::age,                                        // clave: la edad
                TreeMap::new,                                     // mapa ordenado por clave
                Collectors.mapping(User::name, Collectors.toList()))); // valor: los nombres
System.out.println(nombresPorEdad);
{18=[Marta], 32=[Marta, Antonio], 34=[Juan], 35=[Pepe]}

Con solo groupingBy(User::age) obtienes Map<Integer, List<User>>, pero el orden de las claves no está garantizado. Si vas a mostrar el resultado o a escribir un test sobre él, pasa TreeMap::new como en el ejemplo.

counting y summingInt: contar y sumar por grupo

El tercer argumento de groupingBy decide qué se guarda en cada casillero:

Map<Integer, Long> cuantosPorEdad = USERS.stream()
        .collect(Collectors.groupingBy(User::age, TreeMap::new, Collectors.counting()));
System.out.println(cuantosPorEdad);

Map<String, Integer> sumaPorNombre = USERS.stream()
        .collect(Collectors.groupingBy(User::name, TreeMap::new, Collectors.summingInt(User::age)));
System.out.println(sumaPorNombre);
{18=1, 32=2, 34=1, 35=1}
{Antonio=32, Juan=34, Marta=50, Pepe=35}

partitioningBy: separar en dos grupos

Si la clave es un sí o un no, partitioningBy siempre devuelve las dos claves, false y true:

System.out.println(USERS.stream()
        .collect(Collectors.partitioningBy(
                user -> user.age() >= 33,
                Collectors.mapping(User::name, Collectors.toList()))));
{false=[Marta, Marta, Antonio], true=[Pepe, Juan]}

toMap: de lista a mapa

Map<Integer, String> nombrePorId = USERS.stream()
        .collect(Collectors.toMap(User::id, User::name));
System.out.println(nombrePorId);
{1=Marta, 2=Pepe, 3=Juan, 4=Marta, 5=Antonio}

summarizingInt: estadísticas de golpe

Cuenta, suma, mínimo, media y máximo en una sola pasada. Como la edad es un int, se usa summarizingInt:

IntSummaryStatistics stats = USERS.stream()
        .collect(Collectors.summarizingInt(User::age));
System.out.println(stats);
System.out.println(stats.getAverage());
IntSummaryStatistics{count=5, sum=151, min=18, average=30.200000, max=35}
30.2

reduce: combinar todo en un único valor

reduce va acumulando los elementos de dos en dos, como quien suma una lista de la compra: empieza en 0 y va añadiendo cada precio.

System.out.println(USERS.stream().map(User::age).reduce(0, Integer::sum));
System.out.println(USERS.stream().mapToInt(User::age).sum());
151
151

El primer argumento (0) es el valor de partida; gracias a él, reduce devuelve directamente un número y no un Optional. Para sumar números, mapToInt(...).sum() es más claro y evita convertir cada int en Integer. Guarda reduce para combinaciones que no tengan ya un método propio.

Novedad: Stream Gatherers (Java 24 y 25)

Hasta Java 23, si querías una operación intermedia que no existiera (agrupar de tres en tres, comparar cada elemento con el anterior…), tenías que salir del Stream. Desde Java 24 existe gather, que acepta operaciones intermedias a medida (gatherers). La clase Gatherers trae cinco listas para usar: windowFixed, windowSliding, scan, fold y mapConcurrent. Como Java 25 es la versión LTS actual, ya puedes usarlos en proyectos que actualicen a ella.

windowFixed: en grupos de tamaño fijo

USERS.stream()
        .map(User::name)
        .gather(Gatherers.windowFixed(2))
        .forEach(System.out::println);
[Marta, Pepe]
[Juan, Marta]
[Antonio]

Útil para procesar por lotes, por ejemplo enviar registros de cien en cien.

windowSliding: ventanas que se solapan

Cada ventana avanza un elemento. Aquí se calcula la diferencia entre cada edad y la siguiente:

System.out.println(Stream.of(18, 35, 34, 32, 32)
        .gather(Gatherers.windowSliding(2))
        .map(par -> par.get(1) - par.get(0))
        .toList());
[17, -1, -2, 0]

scan: totales acumulados

Como reduce, pero emite cada resultado intermedio:

System.out.println(USERS.stream()
        .map(User::age)
        .gather(Gatherers.scan(() -> 0, Integer::sum))
        .toList());
[18, 53, 87, 119, 151]

Errores habituales

Reutilizar un Stream

Un Stream solo se recorre una vez, como una cinta que no se puede rebobinar. Si lo necesitas otra vez, crea uno nuevo desde la lista.

Stream<String> nombres = Stream.of("Marta", "Pepe");
nombres.forEach(System.out::println);
nombres.forEach(System.out::println); // segunda vez: error
Marta
Pepe
java.lang.IllegalStateException: stream has already been operated upon or closed

Pensar que cada operación recorre toda la lista

Las operaciones intermedias son perezosas: con operaciones sin estado como filter y map, cada elemento recorre la cinta entera antes de que salga el siguiente, y solo se procesa lo necesario. Una operación con estado como sorted es distinta: necesita ver todos los elementos antes de seguir. Con println dentro se ve claro:

Stream.of("Marta", "Pepe", "Juan")
        .filter(n -> { System.out.println("filtro " + n); return n.length() == 4; })
        .map(n -> { System.out.println("map " + n); return n.toUpperCase(); })
        .findFirst()
        .ifPresent(System.out::println);
filtro Marta
filtro Pepe
map Pepe
PEPE

«Juan» nunca llega a la cinta: findFirst ya tenía su respuesta. Los println están solo para enseñarlo; en código real, las funciones que pasas a filter y map no deberían tener efectos secundarios.

Las operaciones intermedias son perezosas: con Stream.of("Marta", "Pepe", "Juan"), filter, map y findFirst, Marta pasa por el filtro (1) y no pasa; Pepe pasa el filtro (2), llega a map (3) y sale PEPE como resultado (4); Juan nunca llega a la cinta, porque findFirst ya tenía su respuesta. Con filter y map, sin estado, cada elemento recorre la cinta entera antes de que salga el siguiente.
Cada columna es un elemento y los números siguen el orden de la salida: Marta se queda en el filtro, Pepe baja por toda la cinta y, como findFirst ya tiene su resultado, Juan no se procesa.
Hasta que no pides el resultado, la cinta no arranca.

Encadenar demasiado

Un Stream de quince líneas con lambdas largas es tan difícil de leer como el bucle que sustituía. Extrae las condiciones a métodos con nombre (.filter(this::esMayorDeEdad)): es la misma idea que en las funciones en código limpio, funciones pequeñas que dicen lo que hacen.

Tu chuleta: quiero… → uso…

Quiero… Uso…
Quedarme con los que cumplen algo filter(x -> condición)
Transformar cada elemento map(x -> nuevoValor)
Aplanar listas de listas flatMap(x -> x.lista().stream())
Ordenar sorted() o sorted(Comparator.comparing(...))
Quitar repetidos distinct()
Saltar o recortar skip(n), limit(n)
Saber si alguno, todos o ninguno cumple algo anyMatch, allMatch, noneMatch
El primero que cumpla algo filter(...).findFirst()
Contar elementos count()
Una lista (sin modificarla después) toList()
Una lista que pueda modificar collect(Collectors.toCollection(ArrayList::new))
Un texto separado por comas collect(Collectors.joining(", "))
Agrupar por un campo collect(Collectors.groupingBy(...))
Contar o sumar por grupo groupingBy(clave, Collectors.counting()), summingInt(...)
Separar en dos grupos collect(Collectors.partitioningBy(...))
Un mapa clave → valor collect(Collectors.toMap(...))
Suma, media, mínimo y máximo mapToInt(...).sum(), collect(Collectors.summarizingInt(...))
Combinar todo en un valor reduce(inicial, operación)
Lotes de tamaño fijo (Java 24+) gather(Gatherers.windowFixed(n))
Totales acumulados (Java 24+) gather(Gatherers.scan(...))

Si también programas en Kotlin, sus colecciones traen casi todas estas operaciones sin necesidad de stream(). Las diferencias entre los dos lenguajes están en Kotlin vs Java.

Siguiente paso

Coge un bucle for de tu proyecto que filtre o transforme una lista y reescríbelo con un Stream. Antes, escribe un test que fije lo que devuelve el bucle: si el test sigue en verde después del cambio, sabes que el comportamiento no ha cambiado. Es el mismo ciclo que se explica en TDD: desarrollo dirigido por tests.

Fuentes

Comprobado en noviembre de 2026 en la documentación oficial de Java SE 25: interfaz Stream (toList() desde Java 16 y no modificable; gather desde Java 24; anyMatch, allMatch y noneMatch son operaciones que se detienen antes de tiempo; reutilizar un Stream puede lanzar IllegalStateException), clase Collectors (sin garantías de tipo ni de mutabilidad en toList() y groupingBy; toMap lanza IllegalStateException con claves repetidas) y JEP 485: Stream Gatherers (definitivo en JDK 24).

Todos los ejemplos se han ejecutado con java Fichero.java en la imagen oficial de Docker eclipse-temurin:25 (OpenJDK 25.0.5, Temurin-25.0.5+8, LTS) y las salidas son las reales. Tras cambiar los nombres de ejemplo, se volvieron a ejecutar todos el 8 de noviembre de 2026, incluido el error de toMap con claves repetidas.