Not the same as I/O streams

Two unrelated things share the word. This note is about the first:

deck.stream().filter(...).toList();   // Stream API — processing objects
Files.newInputStream(path);           // I/O stream — moving bytes

For the byte kind, see Heap streaming and JVM flags.

The model

Everything below runs against this:

enum Type { MONSTER, SPELL, TRAP }
enum Attribute { DARK, LIGHT, EARTH, WATER, FIRE, WIND, DIVINE }
 
record Card(
    String name,
    Type type,
    Attribute attribute,
    String monsterType,
    int level,
    int atk,
    int def,
    Set<String> archetypes
) {}
List<Card> deck = List.of(
    new Card("Blue-Eyes White Dragon", MONSTER, LIGHT, "Dragon",      8, 3000, 2500, Set.of("Blue-Eyes")),
    new Card("Dark Magician",          MONSTER, DARK,  "Spellcaster", 7, 2500, 2100, Set.of("Dark Magician")),
    new Card("Red-Eyes Black Dragon",  MONSTER, DARK,  "Dragon",      7, 2400, 2000, Set.of("Red-Eyes")),
    new Card("Summoned Skull",         MONSTER, DARK,  "Fiend",       6, 2500, 1200, Set.of()),
    new Card("Jinzo",                  MONSTER, DARK,  "Machine",     6, 2400, 1500, Set.of()),
    new Card("Gaia The Fierce Knight", MONSTER, EARTH, "Warrior",     7, 2300, 2100, Set.of()),
    new Card("Celtic Guardian",        MONSTER, EARTH, "Warrior",     4, 1400, 1200, Set.of()),
    new Card("Mystical Elf",           MONSTER, LIGHT, "Spellcaster", 4,  800, 2000, Set.of()),
    new Card("Baby Dragon",            MONSTER, WIND,  "Dragon",      3, 1200,  700, Set.of()),
    new Card("Kuriboh",                MONSTER, DARK,  "Fiend",       1,  300,  200, Set.of())
);

The shape of every stream

source  →  intermediate operations  →  terminal operation
deck       filter / map / sorted       toList / count / forEach

Intermediate operations are lazy: they build a pipeline and do nothing. Without a terminal operation, no element is ever touched.

deck.stream().filter(c -> c.atk() > 2000);   // does nothing at all
deck.stream().filter(c -> c.atk() > 2000).toList();  // now it runs

That laziness is also why the pipeline runs per element, not per stage: each card goes through filter → map → collect before the next one starts. Handy when you findFirst() — it stops as soon as it has one.

Filtering

List<Card> beaters = deck.stream()
    .filter(c -> c.atk() >= 2400)
    .toList();
// Blue-Eyes, Dark Magician, Red-Eyes, Summoned Skull, Jinzo

Chained filters read better than one && monster:

List<Card> darkHighLevel = deck.stream()
    .filter(c -> c.attribute() == DARK)
    .filter(c -> c.level() >= 6)
    .toList();

Mapping

List<String> names = deck.stream()
    .map(Card::name)
    .toList();
 
List<String> shouting = deck.stream()
    .map(Card::name)
    .map(String::toUpperCase)
    .toList();

For numbers, drop to a primitive stream and avoid boxing every value into an Integer:

int totalAtk = deck.stream()
    .mapToInt(Card::atk)
    .sum();

flatMap: one-to-many

A card has a Set<String> of archetypes. map would give you a stream of sets; flatMap flattens them into one stream of strings:

List<String> archetypes = deck.stream()
    .map(Card::archetypes)        // Stream<Set<String>>  ← probably not what you want
    .toList();
 
Set<String> allArchetypes = deck.stream()
    .flatMap(c -> c.archetypes().stream())   // Stream<String>
    .collect(Collectors.toSet());
// [Blue-Eyes, Dark Magician, Red-Eyes]

Whenever a field is itself a collection, flatMap is the operation you want.

Sorting

List<Card> byAtk = deck.stream()
    .sorted(Comparator.comparingInt(Card::atk))
    .toList();
 
List<Card> strongestFirst = deck.stream()
    .sorted(Comparator.comparingInt(Card::atk).reversed())
    .toList();
 
List<Card> byLevelThenName = deck.stream()
    .sorted(Comparator.comparingInt(Card::level).reversed()
        .thenComparing(Card::name))
    .toList();

sorted() is a stateful operation: it has to buffer every element before it can emit the first one. On a short list that is nothing; on a huge stream it is the step that holds everything in memory.

Finding and matching

Optional<Card> anyDragon = deck.stream()
    .filter(c -> c.monsterType().equals("Dragon"))
    .findFirst();
 
Optional<Card> strongest = deck.stream()
    .max(Comparator.comparingInt(Card::atk));      // Blue-Eyes
 
boolean hasExodiaPiece = deck.stream()
    .anyMatch(c -> c.name().startsWith("Exodia"));  // false
 
boolean allMonsters = deck.stream()
    .allMatch(c -> c.type() == MONSTER);            // true
 
boolean noTraps = deck.stream()
    .noneMatch(c -> c.type() == TRAP);              // true

Unwrap the Optional deliberately — never with a bare .get():

String name = strongest
    .map(Card::name)
    .orElse("empty deck");

anyMatch / findFirst short-circuit: they stop at the first hit rather than walking the list.

Counting and summing

long dragons = deck.stream()
    .filter(c -> c.monsterType().equals("Dragon"))
    .count();                                    // 3
 
double averageAtk = deck.stream()
    .mapToInt(Card::atk)
    .average()
    .orElse(0);                                  // 1880.0
 
IntSummaryStatistics stats = deck.stream()
    .mapToInt(Card::atk)
    .summaryStatistics();
// count=10, sum=18800, min=300, average=1880.0, max=3000

summaryStatistics() in one pass beats four separate streams.

Collecting

List<String> list   = deck.stream().map(Card::name).toList();
Set<Attribute> set  = deck.stream().map(Card::attribute).collect(Collectors.toSet());
 
String joined = deck.stream()
    .filter(c -> c.atk() >= 2400)
    .map(Card::name)
    .collect(Collectors.joining(", "));
// "Blue-Eyes White Dragon, Dark Magician, Red-Eyes Black Dragon, ..."

toMap, and its sharp edge

Map<String, Integer> atkByName = deck.stream()
    .collect(Collectors.toMap(Card::name, Card::atk));

That throws IllegalStateException the moment two cards share a key — and in a real deck you do hold three Kuribohs. Give it a merge function:

Map<String, Integer> atkByName = deck.stream()
    .collect(Collectors.toMap(Card::name, Card::atk, (a, b) -> a));

Grouping

This is where the Stream API earns its place.

Map<Attribute, List<Card>> byAttribute = deck.stream()
    .collect(Collectors.groupingBy(Card::attribute));
// DARK=[Dark Magician, Red-Eyes, Summoned Skull, Jinzo, Kuriboh], LIGHT=[...], ...

With a downstream collector you shape the values instead of getting raw lists:

Map<Attribute, Long> countByAttribute = deck.stream()
    .collect(Collectors.groupingBy(Card::attribute, Collectors.counting()));
// {WIND=1, EARTH=2, LIGHT=2, DARK=5}  ← note the order
 
Map<String, List<String>> namesByMonsterType = deck.stream()
    .collect(Collectors.groupingBy(
        Card::monsterType,
        Collectors.mapping(Card::name, Collectors.toList())));
// {Dragon=[Blue-Eyes White Dragon, Red-Eyes Black Dragon, Baby Dragon], ...}
 
Map<Attribute, Optional<Card>> strongestPerAttribute = deck.stream()
    .collect(Collectors.groupingBy(
        Card::attribute,
        Collectors.maxBy(Comparator.comparingInt(Card::atk))));

Two things to know: counting() gives you a Long, not an Integer, and groupingBy returns a HashMap, so iteration order is undefined. If you want it sorted:

Map<Attribute, Long> sorted = deck.stream()
    .collect(Collectors.groupingBy(Card::attribute, TreeMap::new, Collectors.counting()));

partitioningBy: grouping into exactly two

Map<Boolean, List<Card>> splitByPower = deck.stream()
    .collect(Collectors.partitioningBy(c -> c.atk() >= 2000));
 
List<Card> beaters = splitByPower.get(true);
List<Card> chaff   = splitByPower.get(false);

Unlike groupingBy, both keys always exist — even when one side is empty.

Putting it together

The top three DARK monsters by ATK, as a display string:

String topDark = deck.stream()
    .filter(c -> c.attribute() == DARK)
    .sorted(Comparator.comparingInt(Card::atk).reversed())
    .limit(3)
    .map(c -> "%s (%d ATK)".formatted(c.name(), c.atk()))
    .collect(Collectors.joining(" · "));
// "Dark Magician (2500 ATK) · Summoned Skull (2500 ATK) · Red-Eyes Black Dragon (2400 ATK)"

Read it top to bottom: take the deck, keep DARK, sort by ATK descending, take three, format, join. That readability is the actual argument for streams — not speed.

Traps

A stream is single-use.

Stream<Card> s = deck.stream();
s.filter(...).toList();
s.map(...).toList();   // IllegalStateException: stream has already been operated upon

Start a new one from the source each time.

Nothing runs without a terminal operation. A pipeline you forgot to terminate is not a bug you will see — it silently does nothing.

Don’t mutate the source inside the pipeline.

deck.stream().forEach(c -> deck.remove(c));   // ConcurrentModificationException

peek is for debugging only. It exists to look, not to act, and the JVM is allowed to skip it entirely when it can prove the result is unaffected.

toList() is unmodifiable. Stream.toList() (Java 16+) returns an immutable list — calling .add() on it throws. Use collect(Collectors.toCollection(ArrayList::new)) if you need a mutable one.

parallelStream() is almost never the answer. It only pays off on large datasets with genuinely independent, CPU-bound work, it uses the shared common ForkJoinPool, and it destroys ordering guarantees. On a 40-card deck it is strictly slower.

A stream is not automatically faster than a loop. For a simple sum over a small list, a for loop wins. Choose streams because the pipeline reads like the sentence you’d say out loud.

Don’t stream what the database should filter.

// loads every card in the collection, then throws most away
repository.findAll().stream()
    .filter(c -> c.attribute() == DARK)
    .toList();
 
// let the database do it
repository.findByAttribute(DARK);

The first version is a heap problem waiting to happen — see Heap streaming and JVM flags.

Quick reference

GoalOperation
keep somefilter
transform eachmap, mapToInt
collection field → flatflatMap
ordersorted(Comparator…)
first / anyfindFirst, findAny
does any / do allanyMatch, allMatch, noneMatch
biggest / smallestmax, min
how manycount
sum / average / all fourmapToInt(…).sum(), .summaryStatistics()
cut offlimit, skip
remove duplicatesdistinct
into a list / settoList(), Collectors.toSet()
into a stringCollectors.joining(", ")
into a mapCollectors.toMap(k, v, merge)
bucket by keyCollectors.groupingBy
split in twoCollectors.partitioningBy

Key points

Intermediate operations are lazy; nothing happens until a terminal operation runs.

A stream can only be consumed once.

flatMap is for fields that are themselves collections.

groupingBy + a downstream collector replaces most manual Map<K, List<V>> loops.

Collectors.toMap throws on duplicate keys unless you pass a merge function.

counting() returns Long, and groupingBy returns an unordered HashMap.

Stream.toList() is immutable.

parallelStream() is a micro-optimisation with sharp edges, not a default.

Filter in the database, not in the stream.