Streams en Java

📅 Actualizado en febrero 2026 ✍️ Ángel López ⏱️ 20 min de lectura ✓ Nivel intermedio

🌊 ¿Qué son los Streams en Java?

La API Stream, introducida en Java 8 (2014), representa uno de los cambios más significativos en la historia del lenguaje. Un Stream es una secuencia de elementos que soporta operaciones de procesamiento secuencial y paralelo. A diferencia de las colecciones, un Stream no almacena datos: define un pipeline de transformaciones que se aplican sobre una fuente de datos (una lista, un array, un archivo, o incluso un generador infinito).

Antes de Java 8, procesar una lista de objetos requería bucles for o while explícitos con variables auxiliares. Con Streams, el mismo procesamiento se expresa de forma declarativa: describes qué quieres obtener, no cómo recorrer la estructura. Este enfoque, heredado de la programación funcional, produce código más legible, más conciso y menos propenso a errores.

💡
Concepto clave: Los Streams siguen el patrón pipeline: una fuente de datos → cero o más operaciones intermedias → una operación terminal que produce un resultado o un efecto secundario.

Los tres pilares fundamentales de la API Stream son:

No almacenan datos: procesan elementos bajo demanda desde una fuente.

Son inmutables: cada operación genera un nuevo Stream sin modificar el anterior.

Evaluación lazy: las operaciones intermedias no se ejecutan hasta que una operación terminal lo requiere.

🔧 Cómo crear un Stream

Java ofrece múltiples formas de crear un Stream dependiendo del tipo de fuente de datos. A continuación se muestran las más habituales.

▶️ Desde una colección

Cualquier clase que implemente Collection (como List, Set o Queue) proporciona el método stream():

Java
List<String> nombres = List.of("Ana", "Luis", "María", "Carlos");
Stream<String> stream = nombres.stream();

▶️ Desde un array

Java
int[] numeros = {1, 2, 3, 4, 5};
IntStream stream = Arrays.stream(numeros);

▶️ Con Stream.of() y Stream.generate()

Java
// Stream con elementos explícitos
Stream<String> stream1 = Stream.of("Java", "Python", "C++");

// Stream infinito con generador
Stream<Double> aleatorios = Stream.generate(Math::random);

// Stream infinito con iteración
Stream<Integer> pares = Stream.iterate(0, n -> n + 2);

▶️ Desde un archivo de texto

Java
// Cada línea del archivo se convierte en un elemento del Stream
try (Stream<String> lineas = Files.lines(Path.of("datos.txt"))) {
    lineas.filter(l -> !l.isEmpty())
          .forEach(System.out::println);
}
Buena práctica: Cuando uses Streams sobre recursos de E/S (archivos, conexiones), utiliza try-with-resources para garantizar que el recurso se cierre correctamente.

⚙️ Operaciones intermedias y terminales

Las operaciones de un Stream se clasifican en dos categorías fundamentales. Comprender esta distinción es esencial para usar la API correctamente.

Característica Operaciones intermedias Operaciones terminales
Retorno Un nuevo Stream Un valor, una colección o void
Evaluación Lazy (diferida) Eager (inmediata, dispara el pipeline)
Encadenamiento Se pueden encadenar múltiples Solo una por pipeline
Ejemplos filter, map, sorted, distinct, limit, flatMap collect, forEach, reduce, count, findFirst, anyMatch

Un pipeline típico sigue esta estructura:

Java
List<String> resultado = nombres.stream()   // fuente
    .filter(n -> n.length() > 3)              // intermedia
    .map(String::toUpperCase)                   // intermedia
    .sorted()                                   // intermedia
    .collect(Collectors.toList());              // terminal
💡
Evaluación lazy en acción: Si la lista tiene 1.000 elementos pero solo necesitas los 5 primeros que cumplan una condición, Java puede detenerse tras encontrarlos sin procesar los 995 restantes (gracias a operaciones como limit() o findFirst()).

🔍 Filtrado con filter()

La operación filter() recibe un Predicate<T> (una función que devuelve boolean) y retiene solo los elementos que cumplen la condición. Es probablemente la operación más utilizada de toda la API.

Java
List<Integer> numeros = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// Filtrar números pares
List<Integer> pares = numeros.stream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());

System.out.println(pares); // [2, 4, 6, 8, 10]

🔹 Filtros encadenados

Se pueden encadenar múltiples llamadas a filter() para condiciones complejas, aunque también se puede usar un solo filter() con operadores lógicos && y ||:

Java
// Empleados con salario > 30000 y del departamento de Ingeniería
List<Empleado> seleccion = empleados.stream()
    .filter(e -> e.getSalario() > 30000)
    .filter(e -> e.getDepartamento().equals("Ingeniería"))
    .collect(Collectors.toList());

// Equivalente con un solo filter:
List<Empleado> seleccion2 = empleados.stream()
    .filter(e -> e.getSalario() > 30000
             && e.getDepartamento().equals("Ingeniería"))
    .collect(Collectors.toList());

🔄 Transformación con map()

La operación map() aplica una función a cada elemento del Stream, transformándolo en otro tipo o valor. Recibe un Function<T, R> y devuelve un Stream<R>.

Java
List<String> nombres = List.of("ana", "luis", "maría");

// Convertir a mayúsculas
List<String> mayusculas = nombres.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());
// Resultado: [ANA, LUIS, MARÍA]

// Extraer la longitud de cada nombre
List<Integer> longitudes = nombres.stream()
    .map(String::length)
    .collect(Collectors.toList());
// Resultado: [3, 4, 5]

🔹 map() con objetos complejos

Un caso de uso muy habitual es extraer un campo de un objeto:

Java
// Obtener los nombres de todos los empleados
List<String> nombresEmpleados = empleados.stream()
    .map(Empleado::getNombre)
    .collect(Collectors.toList());

// Calcular el salario neto (aplicando retención del 15%)
List<Double> salariosNetos = empleados.stream()
    .map(e -> e.getSalario() * 0.85)
    .collect(Collectors.toList());

📊 Ordenación con sorted()

La operación sorted() ordena los elementos del Stream. Sin argumentos, utiliza el orden natural (requiere que los elementos implementen Comparable). También acepta un Comparator personalizado.

Java
List<String> ciudades = List.of("Madrid", "Barcelona", "Sevilla", "Bilbao");

// Orden natural (alfabético)
List<String> ordenadas = ciudades.stream()
    .sorted()
    .collect(Collectors.toList());
// [Barcelona, Bilbao, Madrid, Sevilla]

// Ordenar empleados por salario descendente
List<Empleado> porSalario = empleados.stream()
    .sorted(Comparator.comparing(Empleado::getSalario).reversed())
    .collect(Collectors.toList());

// Ordenar por departamento, y dentro de cada departamento por nombre
List<Empleado> multiOrden = empleados.stream()
    .sorted(Comparator.comparing(Empleado::getDepartamento)
                      .thenComparing(Empleado::getNombre))
    .collect(Collectors.toList());

🧮 Reducción con reduce()

La operación reduce() combina todos los elementos del Stream en un único resultado aplicando una función asociativa de forma acumulativa. Es la operación terminal más versátil y poderosa de la API.

▶️ reduce() con valor inicial

Java
List<Integer> numeros = List.of(1, 2, 3, 4, 5);

// Sumar todos los elementos (identidad = 0)
int suma = numeros.stream()
    .reduce(0, Integer::sum);
// Resultado: 15

// Encontrar el producto (identidad = 1)
int producto = numeros.stream()
    .reduce(1, (a, b) -> a * b);
// Resultado: 120

// Concatenar strings
String concatenado = List.of("Java", " ", "Streams").stream()
    .reduce("", String::concat);
// Resultado: "Java Streams"

▶️ reduce() sin valor inicial

Cuando no se proporciona un valor inicial, reduce() devuelve un Optional, ya que el Stream podría estar vacío:

Java
Optional<Integer> maximo = numeros.stream()
    .reduce(Integer::max);

maximo.ifPresent(m -> System.out.println("Máximo: " + m));
// Máximo: 5
⚠️
Cuidado: La función pasada a reduce() debe ser asociativa: (a op b) op c == a op (b op c). Si no lo es, los resultados serán incorrectos, especialmente en Streams paralelos.

📦 Recolección con Collectors

La clase Collectors proporciona implementaciones predefinidas de la interfaz Collector para acumular los elementos de un Stream en diferentes estructuras de datos. Es el compañero inseparable de la operación terminal collect().

Collector Descripción Ejemplo de resultado
toList() Recolecta en un List [a, b, c]
toSet() Recolecta en un Set (sin duplicados) {a, b, c}
toMap() Recolecta en un Map clave-valor {k1=v1, k2=v2}
joining() Concatena Strings con separador "a, b, c"
groupingBy() Agrupa en un Map<K, List<V>> {dept=[e1,e2]}
partitioningBy() Divide en dos grupos (true/false) {true=[...], false=[...]}
counting() Cuenta elementos 5
summarizingInt() Estadísticas (count, sum, min, avg, max) Objeto IntSummaryStatistics

🔹 Ejemplos prácticos con Collectors

Java
// Agrupar empleados por departamento
Map<String, List<Empleado>> porDepto = empleados.stream()
    .collect(Collectors.groupingBy(Empleado::getDepartamento));

// Contar empleados por departamento
Map<String, Long> conteo = empleados.stream()
    .collect(Collectors.groupingBy(
        Empleado::getDepartamento,
        Collectors.counting()
    ));

// Concatenar nombres separados por coma
String listaNombres = empleados.stream()
    .map(Empleado::getNombre)
    .collect(Collectors.joining(", "));
// "Ana, Luis, María, Carlos"

// Particionar: salario alto (> 40000) vs bajo
Map<Boolean, List<Empleado>> particion = empleados.stream()
    .collect(Collectors.partitioningBy(
        e -> e.getSalario() > 40000
    ));

// Estadísticas de salarios
IntSummaryStatistics stats = empleados.stream()
    .collect(Collectors.summarizingInt(Empleado::getSalario));
System.out.println("Media: " + stats.getAverage());
System.out.println("Máximo: " + stats.getMax());
Desde Java 16: puedes usar stream.toList() directamente en lugar de stream.collect(Collectors.toList()). Sin embargo, la lista resultante es inmutable.

🗂️ Aplanar estructuras con flatMap()

Cuando cada elemento del Stream genera a su vez otro Stream (o colección), flatMap() «aplana» el resultado en un único Stream. Es indispensable para trabajar con estructuras anidadas.

Java
// Sin flatMap: Stream de listas de Strings → Stream<List<String>>
// Con flatMap: Stream de listas de Strings → Stream<String>

List<List<String>> listas = List.of(
    List.of("Java", "Python"),
    List.of("C++", "Rust"),
    List.of("Go", "Kotlin")
);

// Aplanar a un único Stream de lenguajes
List<String> todosLenguajes = listas.stream()
    .flatMap(Collection::stream)
    .collect(Collectors.toList());
// [Java, Python, C++, Rust, Go, Kotlin]

🔹 flatMap() con objetos de dominio

Java
// Cada pedido tiene una lista de líneas de pedido
// Obtener TODOS los productos de TODOS los pedidos
List<Producto> todosProductos = pedidos.stream()
    .flatMap(pedido -> pedido.getLineas().stream())
    .map(LineaPedido::getProducto)
    .distinct()
    .collect(Collectors.toList());

🔢 Streams de tipos primitivos

Para evitar el coste del autoboxing/unboxing con tipos primitivos, Java proporciona tres variantes especializadas: IntStream, LongStream y DoubleStream. Estas versiones incluyen operaciones aritméticas directas.

Clase Tipo primitivo Operaciones adicionales
IntStream int sum(), average(), range(), rangeClosed()
LongStream long sum(), average(), range(), rangeClosed()
DoubleStream double sum(), average()
Java
// Sumar números del 1 al 100
int sumaTotal = IntStream.rangeClosed(1, 100).sum();
// Resultado: 5050

// Media de salarios (evitando autoboxing)
OptionalDouble mediaSalarios = empleados.stream()
    .mapToInt(Empleado::getSalario)
    .average();

// Conversión entre Stream<Integer> e IntStream
IntStream intStream = numeros.stream().mapToInt(Integer::intValue);
Stream<Integer> boxed = intStream.boxed();
💡
Rendimiento: Usar mapToInt() en lugar de map() para cálculos numéricos evita miles de operaciones de boxing/unboxing, lo cual marca una diferencia significativa en colecciones grandes.

⚡ Streams paralelos

Un Stream paralelo divide automáticamente el procesamiento entre múltiples hilos del ForkJoinPool común. Se crea con parallelStream() o convirtiendo un Stream secuencial con parallel().

Java
// Crear directamente un Stream paralelo
long cuenta = lista.parallelStream()
    .filter(e -> e.getSalario() > 30000)
    .count();

// Convertir un Stream secuencial en paralelo
long suma = numeros.stream()
    .parallel()
    .mapToLong(Integer::longValue)
    .sum();
⚠️
No siempre es más rápido: Los Streams paralelos tienen un overhead por la gestión de hilos, la división de trabajo y la combinación de resultados. Solo merecen la pena con grandes volúmenes de datos (miles de elementos) y operaciones costosas. Para colecciones pequeñas, el Stream secuencial suele ser más rápido.

Requisitos para que un Stream paralelo sea seguro y eficiente:

✅ Las operaciones deben ser sin estado (stateless): no depender de variables externas mutables.

✅ Las funciones deben ser asociativas: el orden de ejecución no debe afectar al resultado.

❌ Nunca usar forEach() paralelo para modificar una estructura de datos compartida.

🏪 Ejemplo completo: sistema de gestión de pedidos

El siguiente ejemplo integra todas las operaciones vistas en un escenario realista: un sistema de gestión de pedidos para una tienda online. Cada pedido contiene una lista de líneas con producto, cantidad y precio unitario.

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

public class GestionPedidos {

    record Producto(String nombre, String categoria, double precio) {}

    record LineaPedido(Producto producto, int cantidad) {
        double subtotal() { return producto.precio() * cantidad; }
    }

    record Pedido(String cliente, List<LineaPedido> lineas, String estado) {
        double total() {
            return lineas.stream()
                .mapToDouble(LineaPedido::subtotal)
                .sum();
        }
    }

    public static void main(String[] args) {
        // --- Datos de ejemplo ---
        Producto laptop   = new Producto("Laptop Pro", "Electrónica", 1200.0);
        Producto raton    = new Producto("Ratón Ergonómico", "Periféricos", 45.0);
        Producto teclado  = new Producto("Teclado Mecánico", "Periféricos", 89.0);
        Producto monitor  = new Producto("Monitor 27\"", "Electrónica", 350.0);
        Producto cable    = new Producto("Cable HDMI", "Accesorios", 12.0);

        List<Pedido> pedidos = List.of(
            new Pedido("Ana",   List.of(new LineaPedido(laptop, 1),
                                        new LineaPedido(raton, 2)), "COMPLETADO"),
            new Pedido("Luis",  List.of(new LineaPedido(monitor, 2),
                                        new LineaPedido(cable, 3)), "PENDIENTE"),
            new Pedido("María", List.of(new LineaPedido(teclado, 1),
                                        new LineaPedido(laptop, 1),
                                        new LineaPedido(cable, 1)), "COMPLETADO"),
            new Pedido("Carlos",List.of(new LineaPedido(raton, 5)), "CANCELADO")
        );

        // 1. Total de ingresos de pedidos completados
        double ingresos = pedidos.stream()
            .filter(p -> p.estado().equals("COMPLETADO"))
            .mapToDouble(Pedido::total)
            .sum();
        System.out.println("Ingresos completados: " + ingresos + " €");
        // Ingresos completados: 2491.0 €

        // 2. Productos únicos vendidos (de pedidos completados)
        List<String> productosVendidos = pedidos.stream()
            .filter(p -> p.estado().equals("COMPLETADO"))
            .flatMap(p -> p.lineas().stream())
            .map(lp -> lp.producto().nombre())
            .distinct()
            .sorted()
            .collect(Collectors.toList());
        System.out.println("Productos vendidos: " + productosVendidos);
        // [Cable HDMI, Laptop Pro, Ratón Ergonómico, Teclado Mecánico]

        // 3. Gasto medio por cliente (solo completados)
        Map<String, Double> gastoPorCliente = pedidos.stream()
            .filter(p -> p.estado().equals("COMPLETADO"))
            .collect(Collectors.toMap(
                Pedido::cliente,
                Pedido::total
            ));
        System.out.println("Gasto por cliente: " + gastoPorCliente);
        // {Ana=1290.0, María=1301.0}

        // 4. Ventas agrupadas por categoría de producto
        Map<String, Double> ventasPorCategoria = pedidos.stream()
            .filter(p -> !p.estado().equals("CANCELADO"))
            .flatMap(p -> p.lineas().stream())
            .collect(Collectors.groupingBy(
                lp -> lp.producto().categoria(),
                Collectors.summingDouble(LineaPedido::subtotal)
            ));
        System.out.println("Ventas por categoría: " + ventasPorCategoria);
        // {Electrónica=3100.0, Periféricos=179.0, Accesorios=48.0}

        // 5. Cliente con mayor gasto
        Optional<Map.Entry<String, Double>> mejorCliente =
            gastoPorCliente.entrySet().stream()
                .max(Map.Entry.comparingByValue());
        mejorCliente.ifPresent(e ->
            System.out.println("Mejor cliente: " + e.getKey()
                             + " (" + e.getValue() + " €)"));
        // Mejor cliente: María (1301.0 €)

        // 6. Resumen estadístico de totales de pedidos
        DoubleSummaryStatistics estadisticas = pedidos.stream()
            .filter(p -> !p.estado().equals("CANCELADO"))
            .mapToDouble(Pedido::total)
            .summaryStatistics();
        System.out.printf("Pedidos: %d | Media: %.2f € | Max: %.2f €%n",
            estadisticas.getCount(),
            estadisticas.getAverage(),
            estadisticas.getMax());
        // Pedidos: 3 | Media: 1263.67 € | Max: 1301.00 €
    }
}

🚫 Errores frecuentes

❌ Error 1: Reutilizar un Stream consumido

Java
Stream<String> stream = nombres.stream().filter(n -> n.length() > 3);
long count = stream.count();         // OK: primera operación terminal
List<String> lista = stream.collect(Collectors.toList());
// ❌ IllegalStateException: stream has already been operated upon

Corrección: Crea un nuevo Stream para cada operación terminal, o usa un Supplier<Stream>:

Java
Supplier<Stream<String>> supplier = () -> nombres.stream().filter(n -> n.length() > 3);
long count = supplier.get().count();
List<String> lista = supplier.get().collect(Collectors.toList());

❌ Error 2: Modificar la fuente durante el procesamiento

Java
List<String> lista = new ArrayList<>(List.of("a", "b", "c"));
lista.stream()
    .filter(s -> s.equals("b"))
    .forEach(s -> lista.remove(s));  // ❌ ConcurrentModificationException

Corrección: Recolecta primero los elementos a eliminar y luego modifica la lista:

Java
List<String> aEliminar = lista.stream()
    .filter(s -> s.equals("b"))
    .collect(Collectors.toList());
lista.removeAll(aEliminar);

❌ Error 3: forEach() para construir resultados

Java
// ❌ Antipatrón: usar forEach para acumular
List<String> resultado = new ArrayList<>();
nombres.stream()
    .filter(n -> n.length() > 3)
    .forEach(resultado::add);  // Efecto secundario, inseguro en paralelo

Corrección: Usa collect() siempre que necesites construir una colección:

Java
List<String> resultado = nombres.stream()
    .filter(n -> n.length() > 3)
    .collect(Collectors.toList());  // ✅ Sin efectos secundarios

❌ Error 4: Olvidar la operación terminal

Java
// ❌ Esto NO hace nada — falta la operación terminal
nombres.stream()
    .filter(n -> n.length() > 3)
    .map(String::toUpperCase);
// El Stream se creó pero nunca se ejecutó (evaluación lazy)

Corrección: Añade siempre una operación terminal (collect, forEach, count, etc.) para que el pipeline se ejecute.

✏️ Ejercicios prácticos

Ejercicio 1: Filtrar y transformar (Básico)

Dada una lista de números enteros del 1 al 20, usa Streams para obtener una lista con los cuadrados de los números impares mayores que 5. El resultado esperado es: [49, 81, 121, 169, 225, 289, 361].

Ejercicio 2: Agrupación y estadísticas (Intermedio)

Tienes una lista de objetos Producto con campos nombre, categoria y precio. Usa Streams para: (a) agrupar los productos por categoría, (b) calcular el precio medio por categoría, y (c) encontrar el producto más caro de toda la lista.

Ejercicio 3: Pipeline complejo con flatMap (Avanzado)

Tienes una lista de Departamento, cada uno con una lista de Empleado (nombre y salario). Escribe un pipeline que: (1) aplana todos los empleados, (2) filtra los que ganan más de 35.000, (3) los ordena por salario descendente, (4) extrae sus nombres, y (5) los une en un String separado por " | ".

❓ Preguntas frecuentes sobre Streams en Java

Las dudas más comunes respondidas de forma clara y directa.

Una Collection almacena datos en memoria y permite acceso directo a sus elementos. Un Stream no almacena datos: define un pipeline de operaciones que se ejecutan de forma lazy sobre una fuente de datos. Los Streams se consumen una sola vez y no modifican la colección original.
No. Un Stream solo puede recorrerse una vez. Tras invocar una operación terminal (como collect() o forEach()), el Stream queda cerrado. Si necesitas procesarlo de nuevo, debes crear un nuevo Stream desde la fuente original.
Los Streams paralelos son útiles cuando se procesan grandes volúmenes de datos (miles o millones de elementos) con operaciones costosas y sin estado compartido. Para colecciones pequeñas o con operaciones ligeras, el overhead de paralelización puede hacer que el Stream secuencial sea más rápido.
Los Streams usan expresiones lambda como argumentos de sus operaciones (filter, map, reduce, etc.). Las lambdas permiten pasar comportamiento de forma concisa. Sin lambdas, habría que usar clases anónimas, lo que haría el código mucho más verboso.
No. Los Streams son inmutables por diseño. Todas las operaciones generan nuevos Streams o resultados sin alterar la fuente de datos original. Esta característica los hace seguros para programación concurrente y facilita el razonamiento sobre el código.
La evaluación lazy (perezosa) significa que las operaciones intermedias no se ejecutan hasta que se invoca una operación terminal. Java optimiza el pipeline fusionando operaciones y procesando solo los elementos necesarios, lo que puede mejorar significativamente el rendimiento.
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Streams en Java? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!