🌊 ¿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.
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():
List<String> nombres = List.of("Ana", "Luis", "María", "Carlos");
Stream<String> stream = nombres.stream();
▶️ Desde un array
int[] numeros = {1, 2, 3, 4, 5};
IntStream stream = Arrays.stream(numeros);
▶️ Con Stream.of() y Stream.generate()
// 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
// 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);
}
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:
List<String> resultado = nombres.stream() // fuente
.filter(n -> n.length() > 3) // intermedia
.map(String::toUpperCase) // intermedia
.sorted() // intermedia
.collect(Collectors.toList()); // terminal
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.
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 ||:
// 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>.
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:
// 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.
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
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:
Optional<Integer> maximo = numeros.stream()
.reduce(Integer::max);
maximo.ifPresent(m -> System.out.println("Máximo: " + m));
// Máximo: 5
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
// 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());
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.
// 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
// 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() |
// 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();
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().
// 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();
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.
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
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>:
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
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:
List<String> aEliminar = lista.stream()
.filter(s -> s.equals("b"))
.collect(Collectors.toList());
lista.removeAll(aEliminar);
❌ Error 3: forEach() para construir resultados
// ❌ 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:
List<String> resultado = nombres.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList()); // ✅ Sin efectos secundarios
❌ Error 4: Olvidar la operación terminal
// ❌ 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.
💬 Foro de discusión
¿Tienes dudas sobre Streams en Java? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!