Skip to content
Java Streams and Lambdas — Explained with Examples

Java Streams and Lambdas — Explained with Examples

DodaTech Updated Jun 15, 2026 7 min read

Java Streams provide a functional approach to processing sequences of elements, enabling declarative data transformations with operations like map, filter, and reduce that can run sequentially or in parallel without explicit threading.

Why Streams and Lambdas Matter

Before Java 8, iterating over collections meant writing verbose for-loops with mutable state. Streams let you express complex data pipelines in a few readable lines. DodaTech’s analytics engine processes millions of log entries daily using Streams, reducing code from hundreds of imperative lines to a dozen declarative ones. Combined with lambdas, streams enable the kind of functional programming that makes code both safer and more expressive.

The Stream Pipeline — Source, Operations, Terminal

Think of a stream pipeline like an assembly line in a factory. Raw materials (data) enter at one end, pass through a series of workstations (intermediate operations), and come out as finished products at the other end (terminal operation).

    flowchart LR
    A[Data Source<br/>Collection, Array, I/O] --> B[Stream]
    B --> C[Intermediate Op 1<br/>filter]
    C --> D[Intermediate Op 2<br/>map]
    D --> E[Intermediate Op 3<br/>sorted]
    E --> F[Terminal Op<br/>collect / forEach / reduce]
    F --> G[Result<br/>List, Map, Optional, int]
    style F fill:#22c55e,color:#fff
    style A fill:#3b82f6,color:#fff
  

Source — Where Data Comes From

A stream source can be a collection, array, I/O channel, or generator function.

import java.util.*;
import java.util.stream.*;

public class StreamSourceDemo {
    public static void main(String[] args) {
        // From a collection
        List<String> list = Arrays.asList("a", "b", "c");
        Stream<String> fromList = list.stream();

        // From an array
        String[] arr = {"x", "y", "z"};
        Stream<String> fromArray = Arrays.stream(arr);

        // From values
        Stream<Integer> fromValues = Stream.of(1, 2, 3, 4, 5);

        // Infinite stream (limit it!)
        Stream<Double> randoms = Stream.generate(Math::random).limit(3);

        System.out.println("From values: " + fromValues.collect(Collectors.toList()));
        System.out.println("Randoms: " + randoms.collect(Collectors.toList()));
    }
}

Expected output:

From values: [1, 2, 3, 4, 5]
Randoms: [0.437826, 0.928471, 0.156392]

Intermediate Operations — Transform the Data

Intermediate operations are lazy — they don’t execute until a terminal operation is called. Each operation returns a new stream.

import java.util.*;
import java.util.stream.*;

public class IntermediateOpsDemo {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        List<String> result = numbers.stream()
            .filter(n -> n % 2 == 0)           // Keep even numbers
            .map(n -> "Number: " + n)          // Convert to string
            .limit(3)                          // Take first 3
            .collect(Collectors.toList());

        System.out.println("First 3 even numbers: " + result);
    }
}

Expected output:

First 3 even numbers: [Number: 2, Number: 4, Number: 6]

Terminal Operations — Produce a Result

Terminal operations trigger pipeline execution. After calling one, the stream is consumed and cannot be reused.

import java.util.*;
import java.util.stream.*;

public class TerminalOpsDemo {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(3, 7, 2, 9, 1, 8, 4, 6, 5);

        long count = numbers.stream()
            .filter(n -> n > 5)
            .count();
        System.out.println("Count > 5: " + count);

        Optional<Integer> min = numbers.stream().min(Integer::compare);
        Optional<Integer> max = numbers.stream().max(Integer::compare);
        System.out.println("Min: " + min.orElse(-1) + ", Max: " + max.orElse(-1));

        List<Integer> sortedTop3 = numbers.stream()
            .sorted(Comparator.reverseOrder())
            .limit(3)
            .collect(Collectors.toList());
        System.out.println("Top 3: " + sortedTop3);

        boolean allPositive = numbers.stream().allMatch(n -> n > 0);
        System.out.println("All positive: " + allPositive);
    }
}

Expected output:

Count > 5: 4
Min: 1, Max: 9
Top 3: [9, 8, 7]
All positive: true

Map, Filter, Reduce — The Holy Trinity

Most data processing boils down to three operations: transform each element (map), select a subset (filter), and combine into one value (reduce).

import java.util.*;
import java.util.stream.*;

public class MapFilterReduceDemo {
    public static void main(String[] args) {
        List<Transaction> transactions = Arrays.asList(
            new Transaction("T1001", "DEPOSIT", 1500.00),
            new Transaction("T1002", "WITHDRAWAL", 200.00),
            new Transaction("T1003", "DEPOSIT", 3200.00),
            new Transaction("T1004", "WITHDRAWAL", 75.00),
            new Transaction("T1005", "DEPOSIT", 500.00),
            new Transaction("T1006", "FEE", 25.00)
        );

        double totalDeposits = transactions.stream()
            .filter(t -> t.getType().equals("DEPOSIT"))
            .map(Transaction::getAmount)
            .reduce(0.0, Double::sum);

        System.out.println("Total deposits: $" + totalDeposits);

        double highestWithdrawal = transactions.stream()
            .filter(t -> t.getType().equals("WITHDRAWAL"))
            .map(Transaction::getAmount)
            .reduce(0.0, Math::max);

        System.out.println("Highest withdrawal: $" + highestWithdrawal);

        String transactionIds = transactions.stream()
            .filter(t -> t.getAmount() > 500)
            .map(Transaction::getId)
            .collect(Collectors.joining(", "));

        System.out.println("Large transactions (>$500): " + transactionIds);
    }
}

class Transaction {
    private String id;
    private String type;
    private double amount;

    public Transaction(String id, String type, double amount) {
        this.id = id;
        this.type = type;
        this.amount = amount;
    }

    public String getId() { return id; }
    public String getType() { return type; }
    public double getAmount() { return amount; }
}

Expected output:

Total deposits: $5200.0
Highest withdrawal: $200.0
Large transactions (>$500): T1001, T1003

Collecting Results — More Than Just toList()

The Collectors utility class provides powerful ways to collect stream results.

import java.util.*;
import java.util.stream.*;

public class CollectorsDemo {
    public static void main(String[] args) {
        List<Transaction> txns = Arrays.asList(
            new Transaction("T1001", "DEPOSIT", 1500.00),
            new Transaction("T1002", "WITHDRAWAL", 200.00),
            new Transaction("T1003", "DEPOSIT", 3200.00),
            new Transaction("T1004", "WITHDRAWAL", 75.00),
            new Transaction("T1005", "DEPOSIT", 500.00),
            new Transaction("T1006", "FEE", 25.00)
        );

        Map<String, List<Transaction>> byType = txns.stream()
            .collect(Collectors.groupingBy(Transaction::getType));

        System.out.println("Grouped by type:");
        byType.forEach((type, list) ->
            System.out.println("  " + type + ": " + list.size() + " transactions"));

        Map<String, Double> totalByType = txns.stream()
            .collect(Collectors.groupingBy(
                Transaction::getType,
                Collectors.summingDouble(Transaction::getAmount)
            ));

        System.out.println("\nTotals by type:");
        totalByType.forEach((type, total) ->
            System.out.println("  " + type + ": $" + total));

        DoubleSummaryStatistics stats = txns.stream()
            .collect(Collectors.summarizingDouble(Transaction::getAmount));

        System.out.println("\nTransaction stats:");
        System.out.println("  Count: " + stats.getCount());
        System.out.println("  Average: $" + String.format("%.2f", stats.getAverage()));
        System.out.println("  Min: $" + stats.getMin());
        System.out.println("  Max: $" + stats.getMax());
    }
}

Expected output:

Grouped by type:
  DEPOSIT: 3 transactions
  WITHDRAWAL: 2 transactions
  FEE: 1 transactions

Totals by type:
  DEPOSIT: $5200.0
  WITHDRAWAL: $275.0
  FEE: $25.0

Transaction stats:
  Count: 6
  Average: $916.67
  Min: $25.0
  Max: $3200.0

Parallel Streams — Processing at Scale

Parallel streams automatically split the workload across multiple threads using the Fork-Join pool. They are ideal for CPU-intensive or independent operations on large datasets.

import java.util.*;
import java.util.stream.*;

public class ParallelStreamDemo {
    public static void main(String[] args) {
        List<Integer> numbers = IntStream.rangeClosed(1, 20)
            .boxed()
            .collect(Collectors.toList());

        System.out.println("Sequential processing:");
        long start = System.nanoTime();
        long sequentialSum = numbers.stream()
            .map(n -> {
                sleep(10); // Simulate work
                return n * 2;
            })
            .reduce(0, Integer::sum);
        long seqTime = (System.nanoTime() - start) / 1_000_000;
        System.out.println("  Sum: " + sequentialSum + ", Time: " + seqTime + "ms");

        System.out.println("Parallel processing:");
        start = System.nanoTime();
        long parallelSum = numbers.parallelStream()
            .map(n -> {
                sleep(10);
                return n * 2;
            })
            .reduce(0, Integer::sum);
        long parTime = (System.nanoTime() - start) / 1_000_000;
        System.out.println("  Sum: " + parallelSum + ", Time: " + parTime + "ms");
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

Expected output (times will vary):

Sequential processing:
  Sum: 420, Time: 205ms
Parallel processing:
  Sum: 420, Time: 52ms

When to use parallel streams:

  • Large datasets (thousands of elements)
  • Independent operations (no shared mutable state)
  • CPU-bound or I/O-bound work that benefits from concurrency

When to avoid them:

  • Small datasets (overhead of splitting and merging)
  • Operations with blocking or contention
  • findFirst() or limit() operations (sequential is more efficient)

Common Mistakes

  1. Reusing a consumed stream: Once a terminal operation is called, the stream is closed. Call stream() again from the source.

  2. Modifying the source during stream operations: Non-concurrent collections modified while streaming throw ConcurrentModificationException.

  3. Using parallel streams for non-thread-safe operations: Lambdas passed to parallel streams must not mutate shared state.

  4. Overusing parallelStream() for small datasets: The overhead of thread management can make parallel processing slower than sequential for small collections.

  5. Forgetting that map() changes the type: After map(), the stream type changes. Chain to match the next operation’s expected input.

Practice Questions

  1. What is the difference between map() and flatMap()?
  2. What makes intermediate operations “lazy”?
  3. When does a parallel stream outperform a sequential one?
  4. What does Collectors.groupingBy() return?
  5. Can you call two terminal operations on the same stream?

Answers:

  1. map() transforms each element 1:1. flatMap() transforms each element into a stream and flattens the results (useful for nested collections).
  2. They don’t execute until a terminal operation is called. They build a pipeline description that executes atomically at the terminal op.
  3. When the dataset is large, operations are independent (no shared state), and the added overhead of thread management is less than the time saved.
  4. A Map<K, List<V>> where keys are the grouping criteria and values are lists of elements in each group.
  5. No. A stream can only have one terminal operation. After it executes, the stream is consumed.

Mini Project: Log File Analyzer

Write a program that:

  1. Generates a list of 1000 log entries (timestamp, level, source, message)
  2. Filters for ERROR level entries
  3. Groups them by source
  4. Counts errors per source
  5. Finds the top 5 sources with the most errors
  6. Parallel-processes the entries and measures the speedup

This pattern is used in DodaTech’s analytics pipeline to process millions of device logs daily.

Related topics: Java, OOP, JVM, GC, CI/CD

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro