Saltar a contenido

🌊 Streams en Java

🧠 1. ¿Qué es un Stream?

Un Stream en Java es una forma de procesar una secuencia de datos utilizando operaciones encadenadas.

No hay que confundirlo con los streams de entrada/salida, como los usados para leer ficheros.

Un Stream permite trabajar con datos de una colección, array u otra fuente de una forma más declarativa.

Es decir, en lugar de escribir paso a paso cómo recorrer una lista, indicamos qué queremos conseguir.

List<String> nombres = List.of("Ana", "Pedro", "Lucía", "Luis");

nombres.stream()
       .filter(nombre -> nombre.length() > 4)
       .forEach(System.out::println);

Este código significa:

  1. Coge la lista de nombres.
  2. Crea un stream.
  3. Quédate solo con los nombres de más de 4 letras.
  4. Muestra cada resultado por pantalla.

🔁 2. Bucle tradicional vs Stream

Antes de aprender streams, vamos a comparar dos formas de hacer lo mismo.

✅ Con bucle tradicional

List<String> nombres = List.of("Ana", "Pedro", "Lucía", "Luis");
List<String> resultado = new ArrayList<>();

for (String nombre : nombres) {
    if (nombre.length() > 4) {
        resultado.add(nombre.toUpperCase());
    }
}

System.out.println(resultado);

✅ Con Stream

List<String> nombres = List.of("Ana", "Pedro", "Lucía", "Luis");

List<String> resultado = nombres.stream()
        .filter(nombre -> nombre.length() > 4)
        .map(String::toUpperCase)
        .toList();

System.out.println(resultado);

🧩 ¿Qué cambia?

Bucle tradicional Stream
Decimos paso a paso cómo recorrer la lista. Decimos qué transformación queremos hacer.
Usamos variables auxiliares. Encadenamos operaciones.
Es muy claro para algoritmos sencillos. Es muy limpio para filtros, transformaciones y búsquedas.
Puede ser mejor cuando hay mucha lógica compleja. Puede ser mejor cuando procesamos colecciones.

🧱 3. Partes de un Stream

Una tubería de Stream suele tener tres partes:

fuente.stream()
      .operacionIntermedia()
      .operacionIntermedia()
      .operacionTerminal();

Streams

🟦 1. Fuente de datos

Es de dónde salen los datos.

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

También podemos crear streams desde arrays:

String[] nombres = {"Ana", "Luis", "Marta"};
Arrays.stream(nombres);

O directamente:

Stream.of("rojo", "verde", "azul");

🟨 2. Operaciones intermedias

Transforman el stream y devuelven otro stream.

Ejemplos:

filter()
map()
sorted()
distinct()
limit()
skip()
peek()
flatMap()

🟥 3. Operaciones terminales

Cierran el stream y producen un resultado final.

Ejemplos:

forEach()
toList()
collect()
count()
findFirst()
anyMatch()
allMatch()
noneMatch()
min()
max()
reduce()

⚠️ 4. Idea clave: los Streams son perezosos

Las operaciones intermedias no se ejecutan hasta que aparece una operación terminal.

List<String> nombres = List.of("Ana", "Pedro", "Lucía");

nombres.stream()
       .filter(nombre -> {
           System.out.println("Filtrando: " + nombre);
           return nombre.length() > 4;
       });

Este código no imprime nada, porque falta una operación terminal.

Ahora sí:

nombres.stream()
       .filter(nombre -> {
           System.out.println("Filtrando: " + nombre);
           return nombre.length() > 4;
       })
       .forEach(System.out::println);

🧠 Recuerda

Sin operación terminal, el stream no se pone en marcha.


🦄 Funciones de Stream

Streams


🛑 Operaciones intermedias

🔍 filter(): filtrar elementos

filter() sirve para quedarse solo con los elementos que cumplen una condición.

Recibe un Predicate<T>, es decir, una función que devuelve true o false.

List<Integer> numeros = List.of(2, 5, 8, 11, 14);

List<Integer> pares = numeros.stream()
        .filter(n -> n % 2 == 0)
        .toList();

System.out.println(pares); // [2, 8, 14]

🧪 Otro ejemplo

List<String> palabras = List.of("sol", "montaña", "río", "ordenador");

List<String> largas = palabras.stream()
        .filter(palabra -> palabra.length() >= 5)
        .toList();

System.out.println(largas); // [montaña, ordenador]

🔄 map(): transformar elementos

map() sirve para transformar cada elemento en otro.

Recibe una Function<T, R>.

List<String> nombres = List.of("ana", "pedro", "lucía");

List<String> mayusculas = nombres.stream()
        .map(String::toUpperCase)
        .toList();

System.out.println(mayusculas); // [ANA, PEDRO, LUCÍA]

🧪 Transformar objetos en datos simples

class Alumno {
    private String nombre;
    private double nota;

    public Alumno(String nombre, double nota) {
        this.nombre = nombre;
        this.nota = nota;
    }

    public String getNombre() {
        return nombre;
    }

    public double getNota() {
        return nota;
    }
}
List<Alumno> alumnos = List.of(
        new Alumno("Ana", 8.5),
        new Alumno("Luis", 4.2),
        new Alumno("Marta", 6.7)
);

List<String> nombres = alumnos.stream()
        .map(Alumno::getNombre)
        .toList();

System.out.println(nombres); // [Ana, Luis, Marta]

🧹 distinct(): eliminar duplicados

List<String> nombres = List.of("Ana", "Luis", "Ana", "Marta", "Luis");

List<String> sinRepetidos = nombres.stream()
        .distinct()
        .toList();

System.out.println(sinRepetidos); // [Ana, Luis, Marta]

En objetos propios, distinct() depende de que equals() y hashCode() estén bien definidos.


📏 limit() y skip()

limit()

Se queda con los primeros elementos.

List<Integer> numeros = List.of(10, 20, 30, 40, 50);

List<Integer> primerosTres = numeros.stream()
        .limit(3)
        .toList();

System.out.println(primerosTres); // [10, 20, 30]

skip()

Salta los primeros elementos.

List<Integer> numeros = List.of(10, 20, 30, 40, 50);

List<Integer> sinLosDosPrimeros = numeros.stream()
        .skip(2)
        .toList();

System.out.println(sinLosDosPrimeros); // [30, 40, 50]

🔡 sorted(): ordenar elementos

Orden natural

List<Integer> numeros = List.of(5, 1, 9, 3);

List<Integer> ordenados = numeros.stream()
        .sorted()
        .toList();

System.out.println(ordenados); // [1, 3, 5, 9]

Ordenar objetos

List<Alumno> alumnos = List.of(
        new Alumno("Ana", 8.5),
        new Alumno("Luis", 4.2),
        new Alumno("Marta", 6.7)
);

List<Alumno> ordenadosPorNota = alumnos.stream()
        .sorted(Comparator.comparing(Alumno::getNota))
        .toList();

Orden inverso

List<Alumno> ordenadosPorNotaDesc = alumnos.stream()
        .sorted(Comparator.comparing(Alumno::getNota).reversed())
        .toList();

👀 peek(): mirar sin transformar

peek() permite ver qué elementos pasan por una parte del stream.

Se usa sobre todo para depurar.

List<String> resultado = Stream.of("uno", "dos", "tres", "cuatro")
        .filter(p -> p.length() > 3)
        .peek(p -> System.out.println("Después del filtro: " + p))
        .map(String::toUpperCase)
        .peek(p -> System.out.println("Después del map: " + p))
        .toList();

⚠️ Importante

No conviene usar peek() para modificar datos. Para transformar datos, usa map().


📦 flatMap(): aplanar estructuras

flatMap() se usa cuando tenemos estructuras anidadas.

Por ejemplo, una lista de listas:

List<List<String>> grupos = List.of(
        List.of("Ana", "Luis"),
        List.of("Marta", "Pedro"),
        List.of("Lucía")
);

Si usamos map(), obtenemos listas dentro de un stream:

grupos.stream()
      .map(grupo -> grupo.stream());

Con flatMap(), unimos todos los elementos en un único stream:

List<String> todos = grupos.stream()
        .flatMap(grupo -> grupo.stream())
        .toList();

System.out.println(todos); // [Ana, Luis, Marta, Pedro, Lucía]

Con referencia de método:

List<String> todos = grupos.stream()
        .flatMap(List::stream)
        .toList();

🛑 Operaciones terminales

Las operaciones terminales son las que ejecutan realmente la tubería.

🖨️ forEach()

Sirve para hacer algo con cada elemento.

List<String> nombres = List.of("Ana", "Luis", "Marta");

nombres.stream()
       .forEach(System.out::println);

⚠️ Cuidado

forEach() no se suele usar para construir resultados. Para eso normalmente usamos toList() o collect().


📋 toList() y collect()

toList()

Forma moderna y sencilla para obtener una lista.

List<String> nombres = List.of("Ana", "Luis", "Marta");

List<String> resultado = nombres.stream()
        .filter(n -> n.length() > 3)
        .toList();

collect(Collectors.toList())

Forma clásica, muy habitual en ejemplos de Java 8.

List<String> resultado = nombres.stream()
        .filter(n -> n.length() > 3)
        .collect(Collectors.toList());

collect(Collectors.toSet())

Set<String> resultado = nombres.stream()
        .collect(Collectors.toSet());

🔢 count()

Devuelve cuántos elementos quedan en el stream.

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

long cantidadPares = numeros.stream()
        .filter(n -> n % 2 == 0)
        .count();

System.out.println(cantidadPares); // 3

🔢 Operaciones numéricas: sum() y average()

En un Stream<T> normal NO podemos usar directamente operaciones como sum() o average().

Por ejemplo, esto no funciona:

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

int suma = numeros.stream()
        .sum(); // ❌ Error

¿Por qué?

Porque numeros.stream() crea un:

Stream<Integer>

Y sum() no existe en Stream<Integer>.


🧠 ¿Qué necesitamos entonces?

Necesitamos convertir el stream a un stream numérico:

Método Convierte a... Se usa para...
mapToInt() IntStream números int
mapToDouble() DoubleStream números double
mapToLong() LongStream números long

✅ Ejemplo de sum() con mapToInt()

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

int suma = numeros.stream()
        .mapToInt(n -> n)
        .sum();

System.out.println(suma); // 15

También se puede escribir con referencia de método:

int suma = numeros.stream()
        .mapToInt(Integer::intValue)
        .sum();

📊 average(): calcular la media

List<Integer> numeros = List.of(7, 8, 9, 10);

double media = numeros.stream()
        .mapToInt(Integer::intValue)
        .average()
        .orElse(0);

System.out.println(media); // 8.5

⚠️ average() devuelve un OptionalDouble, porque la lista podría estar vacía.


💰 Ejemplo con objetos y mapToDouble()

class Producto {
    private String nombre;
    private double precio;

    public Producto(String nombre, double precio) {
        this.nombre = nombre;
        this.precio = precio;
    }

    public double getPrecio() {
        return precio;
    }
}
List<Producto> productos = List.of(
        new Producto("Ratón", 15.99),
        new Producto("Teclado", 29.99),
        new Producto("Monitor", 149.99)
);

double total = productos.stream()
        .mapToDouble(Producto::getPrecio)
        .sum();

System.out.println(total);

📈 Calcular precio medio

double media = productos.stream()
        .mapToDouble(Producto::getPrecio)
        .average()
        .orElse(0);

System.out.println(media);

🔎 findFirst() y findAny()

findFirst()

Devuelve el primer elemento encontrado.

List<String> nombres = List.of("Ana", "Pedro", "Lucía");

Optional<String> primeroLargo = nombres.stream()
        .filter(n -> n.length() > 4)
        .findFirst();

System.out.println(primeroLargo.orElse("No encontrado"));

findAny()

Devuelve algún elemento que cumpla la condición. En streams secuenciales suele parecerse a findFirst(), pero en streams paralelos puede devolver otro.

Optional<String> alguno = nombres.stream()
        .filter(n -> n.length() > 4)
        .findAny();

anyMatch(), allMatch() y noneMatch()

Estas operaciones devuelven un booleano.

anyMatch()

¿Algún elemento cumple la condición?

boolean hayAprobados = alumnos.stream()
        .anyMatch(a -> a.getNota() >= 5);

allMatch()

¿Todos cumplen la condición?

boolean todosAprobados = alumnos.stream()
        .allMatch(a -> a.getNota() >= 5);

noneMatch()

¿Ninguno cumple la condición?

boolean nadieTieneDiez = alumnos.stream()
        .noneMatch(a -> a.getNota() == 10);

🏆 min() y max()

Devuelven un Optional, porque el stream podría estar vacío.

List<Integer> numeros = List.of(4, 9, 1, 7);

Optional<Integer> minimo = numeros.stream().min(Integer::compareTo);
Optional<Integer> maximo = numeros.stream().max(Integer::compareTo);

System.out.println(minimo.orElse(0)); // 1
System.out.println(maximo.orElse(0)); // 9

Con objetos

Optional<Alumno> mejorAlumno = alumnos.stream()
        .max(Comparator.comparing(Alumno::getNota));

mejorAlumno.ifPresent(a -> System.out.println(a.getNombre()));

🧮 reduce(): reducir a un único valor

reduce() permite combinar todos los elementos hasta obtener un único resultado.

Sumar números

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

int suma = numeros.stream()
        .reduce(0, (a, b) -> a + b);

System.out.println(suma); // 10

Con referencia de método:

int suma = numeros.stream()
        .reduce(0, Integer::sum);

Producto

int producto = numeros.stream()
        .reduce(1, (a, b) -> a * b);

System.out.println(producto); // 24

Concatenar textos

List<String> palabras = List.of("Java", "es", "potente");

String frase = palabras.stream()
        .reduce("", (a, b) -> a + " " + b)
        .trim();

System.out.println(frase); // Java es potente

🧺 Collectors.groupingBy(): agrupar datos

Una de las operaciones más útiles con streams es agrupar.

class Producto {
    private String nombre;
    private String categoria;
    private double precio;

    public Producto(String nombre, String categoria, double precio) {
        this.nombre = nombre;
        this.categoria = categoria;
        this.precio = precio;
    }

    public String getNombre() {
        return nombre;
    }

    public String getCategoria() {
        return categoria;
    }

    public double getPrecio() {
        return precio;
    }
}
List<Producto> productos = List.of(
        new Producto("Ratón", "Informática", 15.99),
        new Producto("Teclado", "Informática", 29.99),
        new Producto("Cuaderno", "Papelería", 2.50),
        new Producto("Bolígrafo", "Papelería", 1.20)
);

Map<String, List<Producto>> porCategoria = productos.stream()
        .collect(Collectors.groupingBy(Producto::getCategoria));

System.out.println(porCategoria);

💰 Collectors.summingDouble(), averagingDouble() y counting()

Sumar precios por categoría

Map<String, Double> totalPorCategoria = productos.stream()
        .collect(Collectors.groupingBy(
                Producto::getCategoria,
                Collectors.summingDouble(Producto::getPrecio)
        ));

Precio medio por categoría

Map<String, Double> mediaPorCategoria = productos.stream()
        .collect(Collectors.groupingBy(
                Producto::getCategoria,
                Collectors.averagingDouble(Producto::getPrecio)
        ));

Contar productos por categoría

Map<String, Long> cantidadPorCategoria = productos.stream()
        .collect(Collectors.groupingBy(
                Producto::getCategoria,
                Collectors.counting()
        ));

⚡ Streams de tipos primitivos

Java tiene streams especiales para algunos tipos primitivos:

IntStream
LongStream
DoubleStream

Son útiles para trabajar con números sin tener que usar Integer, Long o Double.

int suma = IntStream.of(1, 2, 3, 4, 5)
        .sum();

double media = IntStream.of(1, 2, 3, 4, 5)
        .average()
        .orElse(0);

int maximo = IntStream.of(1, 2, 3, 4, 5)
        .max()
        .orElse(0);

Pasar de Stream<Integer> a IntStream

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

int suma = numeros.stream()
        .mapToInt(Integer::intValue)
        .sum();

🧭 Orden recomendado para leer un Stream

Cuando veas un stream, léelo de arriba a abajo como una receta.

List<String> resultado = alumnos.stream()
        .filter(a -> a.getNota() >= 5)
        .sorted(Comparator.comparing(Alumno::getNota).reversed())
        .map(Alumno::getNombre)
        .toList();

Traducción:

  1. Coge los alumnos.
  2. Quédate solo con los aprobados.
  3. Ordénalos por nota de mayor a menor.
  4. Transforma cada alumno en su nombre.
  5. Guarda el resultado en una lista.

🧨 Errores típicos con Streams

❌ Error 1: olvidar la operación terminal

numeros.stream()
       .filter(n -> n > 5);

No hace nada útil, porque falta una operación terminal.

✅ Correcto:

List<Integer> mayores = numeros.stream()
        .filter(n -> n > 5)
        .toList();

❌ Error 2: intentar reutilizar un stream

Stream<String> stream = nombres.stream();

stream.forEach(System.out::println);
stream.count(); // Error

Un stream se consume una vez.

✅ Correcto:

nombres.stream().forEach(System.out::println);
long cantidad = nombres.stream().count();

❌ Error 3: modificar la lista mientras se recorre

nombres.stream()
       .forEach(nombre -> nombres.remove(nombre)); // Mala idea

✅ Mejor crear una nueva lista:

List<String> filtrados = nombres.stream()
        .filter(nombre -> nombre.length() > 3)
        .toList();

❌ Error 4: usar streams para todo

No siempre hay que usar streams.

Este código es perfectamente válido:

for (int i = 0; i < 10; i++) {
    System.out.println(i);
}

Los streams brillan especialmente cuando trabajamos con colecciones y queremos filtrar, transformar, buscar, agrupar o reducir datos.


🆚 ¿Cuándo usar Stream y cuándo usar bucle?

Usa Stream cuando...

✅ Quieres filtrar datos.
✅ Quieres transformar una colección en otra.
✅ Quieres buscar elementos.
✅ Quieres agrupar o contar datos.
✅ La operación se lee bien encadenada.
✅ No necesitas modificar variables externas.

Usa bucle cuando...

✅ La lógica tiene muchos pasos complejos.
✅ Necesitas break o continue de forma clara.
✅ Estás modificando varias variables.
✅ El stream queda menos legible que el bucle.
✅ Estás empezando y necesitas entender bien el recorrido.


🧩 Ejemplo completo 1: filtrar, ordenar y transformar

Queremos obtener los nombres de los alumnos aprobados, ordenados por nota de mayor a menor.

import java.util.Comparator;
import java.util.List;

class Alumno {
    private String nombre;
    private int edad;
    private double nota;

    public Alumno(String nombre, int edad, double nota) {
        this.nombre = nombre;
        this.edad = edad;
        this.nota = nota;
    }

    public String getNombre() {
        return nombre;
    }

    public int getEdad() {
        return edad;
    }

    public double getNota() {
        return nota;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Alumno> alumnos = List.of(
                new Alumno("Ana", 19, 8.5),
                new Alumno("Luis", 21, 4.2),
                new Alumno("Marta", 20, 9.1),
                new Alumno("Pedro", 18, 6.3)
        );

        List<String> aprobados = alumnos.stream()
                .filter(a -> a.getNota() >= 5)
                .sorted(Comparator.comparing(Alumno::getNota).reversed())
                .map(Alumno::getNombre)
                .toList();

        System.out.println(aprobados);
    }
}

Resultado:

[Marta, Ana, Pedro]

🧩 Ejemplo completo 2: productos caros por categoría

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

class Producto {
    private String nombre;
    private String categoria;
    private double precio;

    public Producto(String nombre, String categoria, double precio) {
        this.nombre = nombre;
        this.categoria = categoria;
        this.precio = precio;
    }

    public String getNombre() {
        return nombre;
    }

    public String getCategoria() {
        return categoria;
    }

    public double getPrecio() {
        return precio;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Producto> productos = List.of(
                new Producto("Ratón", "Informática", 15.99),
                new Producto("Teclado", "Informática", 29.99),
                new Producto("Monitor", "Informática", 149.99),
                new Producto("Cuaderno", "Papelería", 2.50),
                new Producto("Agenda", "Papelería", 8.99)
        );

        Map<String, List<String>> carosPorCategoria = productos.stream()
                .filter(p -> p.getPrecio() > 10)
                .collect(Collectors.groupingBy(
                        Producto::getCategoria,
                        Collectors.mapping(Producto::getNombre, Collectors.toList())
                ));

        System.out.println(carosPorCategoria);
    }
}

Resultado aproximado:

{Informática=[Ratón, Teclado, Monitor]}

🧪 Mini chuleta de operaciones

Operación Tipo Sirve para... Devuelve
filter() Intermedia Filtrar elementos Stream<T>
map() Intermedia Transformar elementos Stream<R>
flatMap() Intermedia Aplanar estructuras Stream<R>
sorted() Intermedia Ordenar Stream<T>
distinct() Intermedia Quitar duplicados Stream<T>
limit() Intermedia Limitar cantidad Stream<T>
skip() Intermedia Saltar elementos Stream<T>
peek() Intermedia Depurar Stream<T>
forEach() Terminal Recorrer y ejecutar acción void
toList() Terminal Convertir a lista List<T>
collect() Terminal Recoger o agrupar Depende
count() Terminal Contar long
findFirst() Terminal Buscar el primero Optional<T>
anyMatch() Terminal Comprobar si alguno cumple boolean
allMatch() Terminal Comprobar si todos cumplen boolean
noneMatch() Terminal Comprobar si ninguno cumple boolean
min() Terminal Obtener mínimo Optional<T>
max() Terminal Obtener máximo Optional<T>
reduce() Terminal Reducir a un valor Depende

🧠 Relación con interfaces funcionales

Los streams usan muchas de las interfaces funcionales que ya hemos visto.

Método Interfaz funcional Ejemplo
filter() Predicate<T> n -> n > 5
map() Function<T, R> String::length
forEach() Consumer<T> System.out::println
sorted() Comparator<T> Comparator.comparing(Alumno::getNota)
reduce() BinaryOperator<T> Integer::sum

✍️ Actividades guiadas

Actividad 1

Dada esta lista:

List<Integer> numeros = List.of(3, 8, 12, 5, 20, 7, 1);

Crea un stream que:

  1. Se quede solo con los números mayores que 5.
  2. Los multiplique por 2.
  3. Los guarde en una lista.

Actividad 2

Dada esta lista:

List<String> palabras = List.of("java", "stream", "lambda", "sol", "programacion");

Crea un stream que obtenga las palabras de más de 5 letras en mayúsculas.


Actividad 3

Dada una lista de alumnos, obtiene solo los nombres de los aprobados.


Actividad 4

Dada una lista de productos, calcula cuántos productos cuestan más de 50 euros.


Actividad 5

Dada una lista de alumnos, encuentra el alumno con mayor nota.


Actividad 6

Dada una lista de palabras, comprueba si alguna empieza por la letra A.


Actividad 7

Dada una lista de números, calcula la suma usando reduce().


Actividad 8

Dada una lista de productos, agrúpalos por categoría.


🛠️ 32. Actividades de transformación de bucle a Stream

Ejercicio 1

Convierte este código a Stream:

List<String> resultado = new ArrayList<>();

for (String nombre : nombres) {
    if (nombre.length() > 4) {
        resultado.add(nombre.toUpperCase());
    }
}

Ejercicio 2

Convierte este código a Stream:

int contador = 0;

for (Alumno alumno : alumnos) {
    if (alumno.getNota() >= 5) {
        contador++;
    }
}

Ejercicio 3

Convierte este código a Stream:

List<String> nombres = new ArrayList<>();

for (Producto producto : productos) {
    if (producto.getPrecio() > 20) {
        nombres.add(producto.getNombre());
    }
}

✅ Resumen final

Un Stream es una herramienta para procesar datos de forma encadenada y declarativa.

La estructura básica es:

fuente.stream()
      .operacionesIntermedias()
      .operacionTerminal();

Recuerda estas ideas:

  • Los streams no almacenan datos.
  • No modifican la fuente original.
  • Las operaciones intermedias son perezosas.
  • Hace falta una operación terminal para que el stream se ejecute.
  • Un stream no se puede reutilizar después de consumirse.
  • No siempre sustituyen a los bucles.
  • Son muy útiles para filtrar, transformar, buscar, contar, ordenar, agrupar y reducir datos.

📚 Referencias útiles

  • Documentación oficial de Oracle sobre Stream.
  • Tutorial oficial de Oracle sobre operaciones agregadas.
  • API oficial de java.util.stream.