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 objectsFiles.newInputStream(path); // I/O stream — moving bytesFor 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 / forEachIntermediate 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 runsThat 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, JinzoChained 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); // trueUnwrap 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=3000summaryStatistics() 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 uponStart 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)); // ConcurrentModificationExceptionpeek 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
| Goal | Operation |
|---|---|
| keep some | filter |
| transform each | map, mapToInt |
| collection field → flat | flatMap |
| order | sorted(Comparator…) |
| first / any | findFirst, findAny |
| does any / do all | anyMatch, allMatch, noneMatch |
| biggest / smallest | max, min |
| how many | count |
| sum / average / all four | mapToInt(…).sum(), .summaryStatistics() |
| cut off | limit, skip |
| remove duplicates | distinct |
| into a list / set | toList(), Collectors.toSet() |
| into a string | Collectors.joining(", ") |
| into a map | Collectors.toMap(k, v, merge) |
| bucket by key | Collectors.groupingBy |
| split in two | Collectors.partitioningBy |
Key points
Intermediate operations are lazy; nothing happens until a terminal operation runs.
A stream can only be consumed once.
flatMapis for fields that are themselves collections.
groupingBy+ a downstream collector replaces most manualMap<K, List<V>>loops.
Collectors.toMapthrows on duplicate keys unless you pass a merge function.
counting()returnsLong, andgroupingByreturns an unorderedHashMap.
Stream.toList()is immutable.
parallelStream()is a micro-optimisation with sharp edges, not a default.
Filter in the database, not in the stream.