Java Streams: qué cambió desde Java 21 hasta el JDK 25 y qué usar hoy
Un Stream en Java es una secuencia de elementos sobre la que se aplican operaciones de forma perezosa y, opcionalmente, en paralelo, sin almacenar los elementos ni modificar la fuente subyacente: no es una estructura de datos, es un pipeline de procesamiento que se construye con operaciones intermedias (filter, map) y se ejecuta solo cuando llega una operación terminal (collect, forEach, reduce). Esa definición no ha cambiado desde Java 8. Lo que sí ha cambiado, y bastante, es lo que la API ofrece más allá de esas tres operaciones básicas, y a agosto de 2026 el JDK vigente con soporte a largo plazo ya no es Java 21: es Java 25, publicado en septiembre de 2025 con soporte premier hasta 2030. Este artículo cubre qué trae la Stream API en esa versión que no traía en Java 21, con el añadido más relevante e infrautilizado en la introducción práctica de la siguiente sección.
El pipeline que ya conoces, completo y compilable
Esto es la base sobre la que se construye todo lo demás, antes de entrar en qué es nuevo. Esta clase compila y se ejecuta tal cual con cualquier JDK 17 o superior:
package dev.sergiomarquez.streams;
import java.util.List;
import java.util.stream.Collectors;
public class StreamBasicsDemo {
public static void main(String[] args) {
List<String> names = List.of("Alice", "Bob", "Charlie", "David", "Ana");
List<String> shortNamesUpper = names.stream()
.filter(name -> name.length() <= 5)
.map(String::toUpperCase)
.sorted()
.collect(Collectors.toList());
System.out.println(shortNamesUpper);
// [ALICE, ANA, BOB, DAVID]
}
}
Tres propiedades de este pipeline explican casi todos los errores habituales con streams: las operaciones intermedias (filter, map, sorted) son lazy, no ejecutan nada hasta que collect las dispara; el stream se consume una sola vez: en cuanto una operación terminal se ejecuta sobre él, aplicar otra operación —intermedia o terminal— a esa misma variable lanza IllegalStateException (un Stream no tiene método .stream(); lo que se reutiliza indebidamente es la variable ya consumida, no una llamada a ese método); y el orden de filter antes de map no es solo estilo, evita mapear elementos que luego se van a descartar.
Qué cambió realmente en la Stream API entre Java 8 y el JDK 25
La demanda real detrás de este artículo (búsquedas como "streams java", "java 21 stream" o "stream api java") mezcla dos preguntas distintas: qué es la API y qué hay de nuevo. Esta tabla responde la segunda con precisión de versión, porque es la parte que ningún tutorial de 2023 puede cubrir:
| Versión | Novedad en Stream API | Qué resuelve |
|---|---|---|
| Java 8 | Stream, map/filter/reduce/collect | Procesamiento declarativo de colecciones |
| Java 9 | takeWhile, dropWhile, Stream.ofNullable, iterate con predicado | Cortar un stream por condición sin recorrerlo entero |
| Java 12 | Collectors.teeing | Aplicar dos collectors a la vez sobre el mismo stream y combinar sus resultados en una sola pasada |
| Java 16 | Stream.toList(), Stream.mapMulti() | Lista inmutable sin Collectors.toList(); expandir un elemento en varios sin flatMap |
| Java 17 / 21 | Sin cambios directos en java.util.stream | Las novedades de esas LTS (hilos virtuales, SequencedCollection) son adyacentes, no de la Stream API en sí |
| Java 22-23 | Stream Gatherers en vista previa (JEP 461 y JEP 473) | Operaciones intermedias personalizadas, todavía no estables |
| Java 24 | Stream Gatherers finalizado (JEP 485) | Primera extensión real del conjunto fijo de operaciones intermedias desde 2014 |
| Java 25 (LTS actual) | Gatherers estable, sin cambios de API respecto a Java 24 | La superficie estable con la que se escribe código de producción hoy |
Si tu proyecto sigue en Java 21, todo lo de la fila de Java 16 hacia arriba ya está disponible; lo único que falta al no estar en 24 o 25 son los Gatherers, que es la pieza que realmente cambia lo que se puede expresar dentro de un pipeline sin salir a un bucle manual.
Gatherers: la operación intermedia que casi nadie usa todavía
Antes de Gatherers, el conjunto de operaciones intermedias de un stream era fijo: map, filter, flatMap y poco más. Cualquier cosa con estado (ventanas deslizantes, sumas acumuladas, agrupar de N en N) exigía salir del pipeline a un Collector forzado o a un bucle imperativo. Stream.gather(Gatherer) es a las operaciones intermedias lo que collect(Collector) es a las terminales: un punto de extensión de primera clase.
El JDK trae gatherers ya construidos en java.util.stream.Gatherers para los casos más comunes. Este ejemplo compila con JDK 24 o 25 y usa tres de ellos sobre el mismo stream de entrada:
package dev.sergiomarquez.streams;
import java.util.List;
import java.util.stream.Gatherers;
import java.util.stream.Stream;
public class GatherersDemo {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8);
// Ventanas deslizantes de tamaño 3: [1,2,3], [2,3,4], ... [6,7,8]
List<List<Integer>> sliding = numbers.stream()
.gather(Gatherers.windowSliding(3))
.toList();
System.out.println("windowSliding: " + sliding);
// Lotes fijos de tamaño 3: [1,2,3], [4,5,6], [7,8]
List<List<Integer>> fixed = numbers.stream()
.gather(Gatherers.windowFixed(3))
.toList();
System.out.println("windowFixed: " + fixed);
// Suma acumulada (scan): 1, 3, 6, 10, 15, 21, 28, 36
List<Integer> runningTotal = numbers.stream()
.gather(Gatherers.scan(() -> 0, Integer::sum))
.toList();
System.out.println("scan: " + runningTotal);
}
}
El cuarto built-in, Gatherers.mapConcurrent(maxConcurrency, mapper), resuelve un problema distinto: llamadas de I/O (una API externa, una consulta por elemento) que quieres paralelizar con un límite explícito de concurrencia, sin convertir todo el stream en parallelStream() y sin gestionar un ExecutorService a mano. Mantiene el orden de salida igual que el de entrada por defecto —un parallelStream() sobre una fuente ordenada también conserva ese orden en operaciones como collect o toList, solo lo pierde si usas explícitamente forEach en vez de forEachOrdered, o si llamas a unordered()—, pero lo hace paralelizando I/O con un límite de concurrencia explícito, no repartiendo cómputo entre los hilos del ForkJoinPool común.
Casos borde que rompen pipelines en producción
La documentación de referencia rara vez cubre estos cuatro puntos, y son los que generan incidencias reales:
- Un stream es de un solo uso. Guardar un
Stream<T>en una variable y reutilizarlo tras una operación terminal lanzaIllegalStateException: stream has already been operated upon or closed. Si necesitas iterar dos veces, guarda laCollectionresultante detoList(), no el stream. - Las excepciones checked no compilan dentro de una lambda de stream. Las interfaces funcionales de
java.util.function(Function,Consumer) no declaranthrows. Si el cuerpo de unmaplanzaIOException, hay que capturarla dentro de la lambda y relanzarla como excepción no comprobada, o extraer un método auxiliar que haga esa conversión explícita; no hay forma de que el compilador lo acepte tal cual. - El estado mutable compartido dentro de un stream paralelo produce resultados no deterministas. Escribir en una lista o un contador externo desde dentro de
parallelStream().forEach(...)es una condición de carrera, no una optimización. Las operaciones de un stream deben ser stateless y sin efectos secundarios sobre datos compartidos; el resultado correcto se construye concollect, no acumulando a mano. parallelStream()usa por defecto elForkJoinPoolcomún de toda la JVM. Ese pool también lo usanCompletableFuturey cualquier otra librería que dependa deForkJoinPool.commonPool(). Un uso ingenuo de streams paralelos en una aplicación con mucha concurrencia de I/O puede saturar hilos que otra parte del sistema necesita, con un efecto que no aparece en local con datasets pequeños y sí en producción bajo carga.
Ninguno de estos cuatro casos se soluciona leyendo más sobre map y filter: son consecuencia directa de cómo está diseñada la ejecución perezosa y compartida de un stream, y conviene tenerlos presentes antes de que un pipeline nuevo llegue a un endpoint en producción.
La superficie útil de la Stream API en 2026 no es mayor que hace tres años, pero es más precisa: cuatro Gatherers built-in cubren justo los casos con estado que antes obligaban a salir del pipeline a un bucle imperativo, y el resto del comportamiento —perezoso, de un solo uso, sin estado compartido en paralelo— sigue siendo idéntico al de Java 8. Para quien ya usa streams a diario y todavía no ha tocado gather(), migrar el primer bucle con estado (una ventana deslizante, un contador acumulado) a un Gatherer built-in suele ser el cambio con mejor retorno de todo este listado.