Java Fundamentals Tutorial 0/33 lessons ~6 min read Lesson 17

    Streams API

    Every nightly settlement batch, fraud scoring pipeline, and regulatory report at a tier-1 bank processes millions of payment rows.

    Course progress0%
    Focus
    21 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Introduction

    Every nightly settlement batch, fraud scoring pipeline, and regulatory report at a tier-1 bank processes millions of payment rows. Before Java 8, that meant nested for loops, temporary lists, and error-prone index math. The Streams API (java.util.stream) expresses data transformations as declarative pipelines: filter eligible transactions, map to DTOs, reduce totals, collect into immutable snapshots, and groupingBy for per-merchant rollups.

    This lesson teaches enterprise Streams from a staff-engineer lens — not toy examples, but how payment reconciliation, AML aggregation, and ledger reporting actually compose pipelines. You will learn intermediate operations (map, filter, flatMap), terminal operations (reduce, collect), collectors like groupingBy, and the performance considerations that separate a 200ms report from a 20-minute GC pause.

    Streams are lazy, optionally parallel, and integrate with Optional and method references — foundations for modern Spring Data projections and reactive-adjacent batch jobs without rewriting your entire stack.

    Business problem

    Imperative collection code scales poorly in financial systems:

    • Reconciliation bugs: Manual loops with off-by-one errors miss duplicate settlement IDs — auditors find discrepancies weeks later.
    • Unmaintainable batch jobs: 400-line nested loops for "group by merchant, sum amount, filter failed" — every change risks regression.
    • Silent performance debt: .parallelStream() on shared ArrayList during peak settlement — data races and wrong totals.
    • Memory spikes: Eagerly materializing every intermediate list in a 10M-row import — heap exhaustion before reduce completes.

    Why this topic exists

    Streams exist to separate WHAT from HOW — you describe transformations; the library handles iteration, short-circuiting, and optional parallelism.

    • Declarative clarity: payments.stream().filter(Payment::isSettled).mapToLong(Payment::amountCents).sum() reads like the business rule.
    • Composable pipelines: Chain map → filter → collect without temporary variables cluttering scope.
    • Lazy evaluation: Intermediate ops fuse until a terminal op runs — skip work when findFirst suffices.
    • Collector ecosystem: groupingBy, partitioningBy, joining — standard aggregation patterns built-in.

    Core concepts

    Five pillars of enterprise Streams:

    • map: Transform each element — Payment → PaymentDto, or mapToLong for primitive streams avoiding boxing.
    • filter: Predicate retention — settled, amount > threshold, merchant in allowlist; short-circuits on find operations.
    • reduce: Associative fold — sum, max, custom accumulator; reduce(identity, accumulator, combiner) for parallel.
    • collect: Terminal mutable reduction — toList(), toMap, custom Collector with supplier/accumulator/combiner.
    • groupingBy: Collectors.groupingBy(Payment::merchantId, summingLong(Payment::amountCents)) — SQL GROUP BY in memory.

    Internal architecture

    Stream pipeline anatomy:

    text
    Source → intermediate* → terminal
    (collection) (map,filter,flatMap) (collect,reduce,forEach)
    Example — merchant settlement rollup:
    List<Payment> payments = ledger.findUnsettled();
    Map<String, Long> byMerchant = payments.stream()
    .filter(Payment::isAuthorized) // intermediate
    .filter(p -> p.amountCents() > 0) // intermediate
    .collect(Collectors.groupingBy(
    Payment::merchantId, // classifier
    Collectors.summingLong(Payment::amountCents) // downstream
    )); // terminal
    Performance rules:
    • Prefer sequential stream unless proven CPU-bound + large N
    • Use mapToLong / sum() not map + reduce boxing
    • Avoid parallel on IO-bound or small N (< 10k)
    • toList() (Java 16+) returns unmodifiable list
    • Stateful ops (distinct, sorted) may buffer entire stream

    Five operations — each maps to a banking pipeline stage you will ship in batch jobs.

    1. map

    Transform payment → settlement DTO
    Payment
    source row
    map()
    transform
    SettlementDto
    downstream
    collect
    terminal
    map applies a function per element — 1:1 transformation.
    • Object map: .map(p -> new SettlementDto(p.id(), p.amountCents())) — DTO for API response.
    • Primitive streams: mapToLong(Payment::amountCents) avoids Long boxing in high-volume sums.
    • flatMap: One payment with many line items — flatMap(p -> p.lineItems().stream()) flattens nested collections.
    • Method references: Payment::merchantId when mapping identity or getter — cleaner in production code.

    2. filter

    Retain authorized payments only
    All payments
    source
    filter(auth)
    predicate
    filter(amount>0)
    chain
    Eligible set
    lazy
    filter drops elements — pipeline stays lazy until terminal op.
    • Predicate: filter(Payment::isAuthorized) — business rule as boolean function.
    • Chaining: Multiple filters fuse — equivalent to single AND predicate; order rarely matters for correctness.
    • Short-circuit: filter(...).findFirst() may not scan entire collection — important for large ledgers.
    • distinct: Stateful filter — requires memory for seen elements; costly on millions of IDs.

    3. reduce

    Fold payment amounts to total
    100
    p1 cents
    + 250
    p2 cents
    + 75
    p3 cents
    425 total
    reduce
    reduce combines elements with associative accumulator.
    • Simple reduce: .map(Payment::amountCents).reduce(0L, Long::sum) — prefer mapToLong().sum() instead.
    • Optional result: .reduce(Long::max) returns Optional<Long> when stream may be empty.
    • Associativity required: Parallel reduce needs combiner — (a,b) -> a+b works; non-associative ops corrupt parallel results.
    • Custom fold: Build immutable summary object — reduce(new Summary(), Summary::add, Summary::combine).

    4. collect

    Terminal mutable reduction
    stream
    elements
    Collector
    supplier+acc
    ArrayList
    mutable acc
    toList()
    immutable out
    collect uses Collector — most flexible terminal operation.
    • toList(): Java 16+ unmodifiable list — default for read-only downstream consumers.
    • toMap: toMap(Payment::id, Function.identity()) — watch duplicate keys; use merge function in production.
    • joining: mapping(Payment::id, joining(", ")) — audit log of processed IDs.
    • Custom collector: Immutable aggregation — supplier ArrayList::new, accumulator, combiner for parallel.

    5. groupingBy & Performance

    GROUP BY merchant + performance triage
    payments
    10M rows
    groupingBy
    HashMap
    per-merchant sum
    downstream
    sequential?
    measure first
    groupingBy is powerful — profile before parallelStream().
    • groupingBy: groupingBy(Payment::merchantId, summingLong(Payment::amountCents)) — settlement dashboard.
    • partitioningBy: Boolean classifier — partitioningBy(Payment::isFailed) splits success/failure buckets.
    • Parallel streams: Only when N large, ops CPU-heavy, source thread-safe, no shared mutation — otherwise sequential wins.
    • Boxing cost: Stream<Long> on 50M rows allocates — use LongStream and primitive collectors.

    Code walkthrough

    Enterprise settlement pipeline — filter, map, groupingBy, reduce:

    • filter chain: Business rules readable — authorized AND not settled before any aggregation.
    • mapToLong + sum: Primitive stream avoids boxing 10M Long objects in batch jobs.
    • groupingBy + summingLong: Downstream collector aggregates per group — SQL GROUP BY equivalent.
    • toList(): Immutable result — safe to pass across service boundaries.
    • OptionalLong max: Terminal on empty stream returns empty Optional — no sentinel -1.
    java
    import java.util.*;
    import java.util.stream.*;
    record Payment(String id, String merchantId, long amountCents,
    boolean authorized, boolean settled) {}
    class SettlementBatch {
    public static void main(String[] args) {
    List<Payment> payments = List.of(
    new Payment("P1", "M-ACME", 10_000L, true, false),
    new Payment("P2", "M-ACME", 5_000L, true, false),
    new Payment("P3", "M-GLOBEX", 20_000L, true, false),
    new Payment("P4", "M-GLOBEX", 1_000L, false, false),
    new Payment("P5", "M-ACME", 500L, true, true)
    );
    // filter + mapToLong + sum — total unsettled authorized volume
    long unsettledTotal = payments.stream()
    .filter(Payment::authorized)
    .filter(p -> !p.settled())
    .mapToLong(Payment::amountCents)
    .sum();
    System.out.println("Unsettled authorized cents: " + unsettledTotal);
    // groupingBy — per-merchant unsettled totals
    Map<String, Long> byMerchant = payments.stream()
    .filter(Payment::authorized)
    .filter(p -> !p.settled())
    .collect(Collectors.groupingBy(
    Payment::merchantId,
    Collectors.summingLong(Payment::amountCents)
    ));
    System.out.println("By merchant: " + byMerchant);
    // map + collect — settlement instructions
    List<String> instructions = payments.stream()
    .filter(Payment::authorized)
    .filter(p -> !p.settled())
    .map(p -> "SETTLE " + p.id() + " merchant=" + p.merchantId())
    .toList();
    System.out.println("Instructions: " + instructions);
    // reduce — largest single payment
    OptionalLong max = payments.stream()
    .filter(Payment::authorized)
    .mapToLong(Payment::amountCents)
    .max();
    max.ifPresent(m -> System.out.println("Max payment cents: " + m));
    }
    }

    Production example

    Spring Batch — nightly settlement with Streams:

    • Batch context: Stream over in-memory list after JDBC fetch — don't stream from ResultSet without careful resource handling.
    • collectingAndThen: Post-process grouped lists into domain records — keeps pipeline declarative.
    • Sequential default: Parallel rarely helps post-DB aggregation under 100k rows on typical 8-core servers.
    • Immutable output: Map returned to caller — defensive copy if exposing mutable internals.
    java
    @Service
    public class SettlementAggregationService {
    private final PaymentRepository repo;
    public Map<String, MerchantSettlement> computeUnsettled(LocalDate businessDate) {
    List<PaymentEntity> rows = repo.findByBusinessDateAndSettledFalse(businessDate);
    // Sequential stream — DB already returned list; parallel rarely helps here
    return rows.stream()
    .filter(PaymentEntity::isAuthorized)
    .filter(p -> p.getAmountCents() > 0)
    .collect(Collectors.groupingBy(
    PaymentEntity::getMerchantId,
    Collectors.collectingAndThen(
    Collectors.toList(),
    list -> new MerchantSettlement(
    list.stream().mapToLong(PaymentEntity::getAmountCents).sum(),
    list.size()
    )
    )
    ));
    }
    // Anti-pattern avoided: parallelStream on shared ArrayList during request handling
    public long totalVolume(List<PaymentEntity> payments) {
    return payments.stream()
    .mapToLong(PaymentEntity::getAmountCents)
    .sum();
    }
    }
    record MerchantSettlement(long totalCents, int count) {}

    Enterprise case study

    Card processor parallelStream incident: A payment analytics service called transactions.parallelStream() on a shared ArrayList populated by multiple Kafka consumer threads. Under load, the list was structurally modified during parallel split — intermittent ConcurrentModificationException and, worse, duplicate-skipped rows in fraud totals. Fix: copy to immutable list before streaming (List.copyOf), use sequential stream, moved aggregation to DB with GROUP BY for 50M+ rows. Lesson: Streams simplify code but don't replace thread safety or database-scale aggregation.

    • Symptom: Wrong merchant totals 0.3% of runs — only under multi-consumer load.
    • Root cause: Non-thread-safe source + parallel fork/join split.
    • Fix 1: Immutable snapshot before stream; sequential processing.
    • Fix 2: Push groupingBy logic to SQL for large datasets — stream in JVM for < 500k rows.

    Performance considerations

    Streams performance — staff-engineer checklist:

    • Sequential vs parallel: Parallel overhead ~ fork/join pool — only wins when N > ~10k and ops CPU-heavy; measure with JMH.
    • Primitive streams: mapToLong, IntStream — avoid boxing in high-volume payment batches.
    • Stateful intermediates: sorted(), distinct() buffer — O(n) memory or O(n log n) time.
    • Short-circuit: anyMatch, findFirst stop early — use instead of filter+collect when one result suffices.
    • Collector choice: toList() vs toCollection(ArrayList::new) — mutability trade-off; groupingBy uses HashMap — pre-size if count known.

    Security considerations

    Streams in regulated systems:

    • PII in logs: forEach(System.out::println) on Payment stream in prod — mask PAN/account before logging.
    • Side effects in forEach: Mutating shared state in parallel stream — race conditions; use collect or synchronized accumulators.
    • Filter bypass: Authorization filter omitted in copy-paste pipeline — all rows exported; code review checklists for filter predicates.
    • Denial via sort: Adversarial comparator in sorted() — tie-break attacks rare but infinite loops possible with broken comparators.

    Scalability considerations

    Scaling stream-based batch jobs:

    • Database pushdown: GROUP BY in SQL beats in-memory groupingBy on tens of millions of rows.
    • Chunked processing: Stream pages from DB (keyset pagination) — constant memory vs loading full day into heap.
    • Distributed aggregation: Kafka streams / Spark for cross-partition sums — JVM stream is single-node.
    • ForkJoinPool common pool: parallelStream() uses shared pool — can starve other parallel work; custom ForkJoinPool for isolation.

    Production challenges

    Real production Stream failures:

    • Duplicate key in toMap: IllegalStateException at terminal — production batch fails at 3 AM; always supply merge function.
    • Null in stream: NullPointerException in map — filter nulls upstream or use Optional flatMap chain.
    • Stream consumed twice: IllegalStateException — streams single-use; collect to list if multiple passes needed.
    • Parallel + non-associative reduce: Wrong totals silently — string concat or custom ops without combiner.
    • Debugging lazy pipelines: Breakpoint in filter never hits until terminal — use peek sparingly in dev only.

    Common mistakes

    • Using parallelStream() by default — overhead and thread-safety risks outweigh gains on small or IO-bound data.
    • Boxing with map instead of mapToLong on million-row ledgers — GC pressure and 10x slower.
    • toMap without merge function when duplicate keys possible — batch job crashes on first duplicate.
    • Side effects in forEach (mutating external list) — use collect instead for clarity and thread safety.
    • Calling stream twice — streams are single-shot; materialize with toList() if needed twice.

    Debugging guide

    Debug stream pipelines in production:

    • peek (dev only): .peek(p -> log.debug("{}", p.id())) — remove before prod; never log PII unmasked.
    • Materialize checkpoint: Collect intermediate to list in test — verify filter counts match SQL COUNT(*).
    • Compare to SQL: Run equivalent GROUP BY in read replica — diff totals expose pipeline bugs.
    • JFR allocation: Spike during collect — boxing or unnecessary toList() in hot path.
    • Stream pipeline in stack trace: Terminal op line number points to collect/reduce — walk upstream filters.
    bash
    # Compare stream result count to DB
    SELECT merchant_id, SUM(amount_cents), COUNT(*)
    FROM payments
    WHERE authorized AND NOT settled
    GROUP BY merchant_id;
    # JMH microbench — sequential vs parallel
    @BenchmarkMode(Mode.AverageTime)
    public class SettlementBench {
    @Benchmark
    public long sequentialSum(List<Payment> data) {
    return data.stream().mapToLong(Payment::amountCents).sum();
    }
    }

    Best practices

    • Prefer sequential streams unless JMH proves parallel benefit on production-sized data.
    • Use mapToLong / IntStream / DoubleStream for numeric aggregation — avoid boxing.
    • Always provide merge function in toMap when keys may collide — (a, b) -> a or merge logic.
    • Keep pipelines pure — no side effects in map/filter; collect results explicitly.
    • Push large aggregations to SQL or distributed engines; stream in JVM for moderate in-memory sets.
    • Use descriptive method references and extracted predicates — test predicates in isolation.
    • Replace filter+collect+get(0) with findFirst when searching single element.

    Anti-patterns

    • parallelStream() on shared mutable ArrayList from concurrent producers.
    • Nested loops rewritten as stream of streams without flatMap — unreadable and slow.
    • reduce for sum instead of mapToLong().sum() — boxing and verbosity.
    • sorted() on large stream when only top-K needed — use partial heap or DB ORDER BY LIMIT.
    • forEach to mutate external collection — use collect(toList()) or groupingBy.

    Staff engineer notes

    • Streams improve readability until pipelines exceed 5 operations — extract named methods or private collectors.
    • groupingBy in memory is a code smell above ~500k rows — ask why data isn't aggregated in the database.
    • parallelStream shares ForkJoinPool.commonPool() — one bad job affects entire JVM.
    • Records + streams are the modern Java batch style — immutable rows, declarative transforms.
    • In fintech code review: every terminal collect on payment data needs a test comparing SQL ground truth.

    Interview questions

    Interview preparation

    15 questions grouped by difficulty — expand any card for a model answer and follow-up probe.

    Beginner

    5
    1. 1What is the difference between intermediate and terminal stream operations?
      Beginner

      Model answer

      • Intermediate ops (map, filter, flatMap, sorted) return Stream and are lazy
      • no execution until terminal op. Terminal ops (collect, reduce, forEach, count) consume stream and produce result or side effect. Stream can be used only once.

      Follow-up probe

      Name a short-circuit terminal.

    2. 2Explain map vs flatMap in a payment context.
      Beginner

      Model answer

      g.

      Payment to amount.

      g.

      payment to stream of line items then flatten all items.

      Use flatMap when element contains nested collection.

      Follow-up probe

      Optional flatMap?

    3. 3What does Collectors.groupingBy do?
      Beginner

      Model answer

      Classifier function maps each element to key; elements grouped into Map> or with downstream collector like summingLong for aggregated values. SQL GROUP BY equivalent in memory.

      Follow-up probe

      groupingBy concurrent?

    4. 4When should you use parallelStream()?
      Beginner

      Model answer

      Rarely by default.

      When: large N (typically 10k+), CPU-bound operations, thread-safe immutable source, associative combiners for reduce/collect.

      Avoid for IO-bound, small data, or shared mutable sources.

      Follow-up probe

      Which pool?

    5. 5Why prefer mapToLong over map to Long?
      Beginner

      Model answer

      • mapToLong produces LongStream
      • no Long boxing per element. sum(), max(), average() on primitive stream avoid allocation and GC pressure on high-volume financial data.

      Follow-up probe

      Boxed reduction cost?

    Intermediate

    5
    1. 6What is lazy evaluation in streams?
      Intermediate

      Model answer

      • Intermediate operations don't process elements until terminal invoked. Enables fusion and short-circuit
      • findFirst may process only until match found. Source spliterator drives iteration.

      Follow-up probe

      sorted() lazy?

    2. 7How handle duplicate keys in Collectors.toMap?
      Intermediate

      Model answer

      Provide merge function: toMap(keyFn, valueFn, (existing, replacement) -> existing).

      Without it IllegalStateException on duplicate.

      Or groupingBy if multiple values per key expected.

      Follow-up probe

      toConcurrentMap?

    3. 8Difference between reduce and collect?
      Intermediate

      Model answer

      • reduce: immutable fold with binary operator, returns Optional or identity+accumulator. collect: mutable reduction via Collector (supplier, accumulator, combiner, finisher)
      • more flexible for grouping, joining, custom aggregates.

      Follow-up probe

      Custom Collector?

    4. 9Can you reuse a Stream?
      Intermediate

      Model answer

      No.

      Once terminal operation runs, stream closed.

      Second terminal throws IllegalStateException.

      Materialize with toList() if multiple passes needed.

      Follow-up probe

      Stream from Iterator?

    5. 10Explain short-circuit operations.
      Intermediate

      Model answer

      • anyMatch, allMatch, noneMatch, findFirst, findAny stop processing when result determined. Important for performance on large ledgers
      • don't collect entire list to check existence.

      Follow-up probe

      findAny vs findFirst?

    Advanced

    5
    1. 11Performance pitfalls of sorted() and distinct()?
      Advanced

      Model answer

      sorted(): O(n log n), buffers all elements.

      distinct(): stateful, HashSet memory.

      On large streams consider sorting in DB or bounded top-K algorithms instead.

      Follow-up probe

      distinct on records?

    2. 12Design pipeline: total unsettled authorized payments per merchant.
      Advanced

      Model answer

      collect(groupingBy(Payment::merchantId, summingLong(Payment::amountCents))).

      Validate against SQL GROUP BY.

      Sequential unless proven CPU-bound at scale.

      Follow-up probe

      Scale to 50M rows?

    3. 13parallelStream thread-safety requirements?
      Advanced

      Model answer

      Source must not be structurally modified during execution.

      Operations must be stateless and side-effect free.

      Collectors for concurrent reduction need CONCURRENT characteristic.

      Shared mutable accumulators in forEach race.

      Follow-up probe

      ConcurrentModificationException?

    4. 14Compare Stream vs traditional loop for batch jobs.
      Advanced

      Model answer

      Streams: declarative, composable, lazy, easier parallel (with caveats).

      Loops: explicit control, easier debugging, lower abstraction overhead for hot paths.

      Staff choice: streams for clarity at moderate scale; SQL/loops for extreme scale or tight latency.

      Follow-up probe

      When refactor stream to loop?

    5. 15How implement custom Collector for risk score histogram?
      Advanced

      Model answer

      putAll merge counts), finisher (unmodifiableMap)).

      Characteristics UNORDERED if order irrelevant.

      Test sequential and parallel combiner associativity.

      Follow-up probe

      Collector.Characteristics?

    Hands-on exercise

    Lab: Settlement stream pipeline

    • Run playground — verify merchant totals and unsettled sum.
    • Add filter for amountCents > 1000 — observe changed groupingBy output.
    • Replace sum with reduce and compare performance conceptually (boxing).
    • Introduce duplicate merchant payment ID in toMap — add merge function to fix.
    • Rewrite one pipeline using parallelStream — discuss when unsafe with shared list.
    • Bonus: write equivalent SQL GROUP BY and compare results.

    JavaStreams API

    Starter Templates
    OutputRemote JVM (Piston · Java 15)
    Press Run to execute. Real javac + JVM via remote API. Falls back to simulator on network failure.

    Architecture trade-offs

    • Stream vs loop: Stream clearer; loop faster to debug and sometimes faster at runtime.
    • Sequential vs parallel: Sequential safe default; parallel for proven CPU-bound large N only.
    • In-memory vs SQL aggregation: Stream flexible; DB scales for huge datasets.
    • toList() vs mutable list: Immutable safer; mutable needed if downstream mutates.

    Summary

    The Streams API is the standard way modern Java services transform payment and ledger data in memory — readable pipelines with lazy evaluation and rich collectors. Master map, filter, reduce, collect, and groupingBy, then apply performance discipline: primitive streams, sequential default, database pushdown at scale. Next: Functional Programming — lambdas and Optional that power every stream operation.

    Key takeaways

    • Streams express payment pipelines declaratively — filter, map, collect, groupingBy map to settlement rules.
    • Use primitive streams (mapToLong) for high-volume monetary aggregation — avoid boxing.
    • Sequential streams by default; parallel only with thread-safe source and measured CPU benefit.
    • groupingBy + downstream collectors replace nested loops for merchant rollups.
    • Validate stream aggregation against SQL ground truth in fintech batch jobs.
    Ready to mark this lesson complete?Track your journey across the entire course.