Stream #

Imagine you have a list of 10,000 transactions and need to find the total value of transactions that happened this month, categorized as “large” (above Rp5 million), from premium customers. With traditional loops, you’d need three or four nested loops with scattered temporary variables. With the Stream API, this can be solved in one chained expression that reads like a sentence: take transactions, filter this month, filter large values, filter premium customers, sum the values. The Stream API was introduced in Java 8 as a new way to process collections of data declaratively — you describe what you want to do, not how to do it. This article covers how to create streams, all available intermediate and terminal operations, using Collectors for complex result collection, Optional for safe null handling, parallel streams for parallel processing, and the idiomatic patterns that frequently appear in production code.

Basic Concepts #

A stream isn’t a data structure — it doesn’t store elements. A stream is a pipeline describing a series of operations to apply to a data source. Its execution is lazy: intermediate operations aren’t run until a terminal operation triggers them.

flowchart LR
    A["Data Source\n(Collection, Array,\nFile, Generator)"] -->|"stream()"| B["Stream"]
    B -->|"filter()"| C["Stream"]
    C -->|"map()"| D["Stream"]
    D -->|"sorted()"| E["Stream"]
    E -->|"collect() / forEach()\ncount() / reduce()"| F["Final Result\n(List, Map, value)"]

    style B stroke:#999,stroke-width:2px
    style C stroke:#999,stroke-width:2px
    style D stroke:#999,stroke-width:2px
    style E stroke:#999,stroke-width:2px

Three important Stream characteristics:

Lazy evaluation — intermediate operations are only executed when a terminal operation is called, and only for the elements actually needed. filter doesn’t process all elements first and then run map — both are processed per element in a single pass.

Single-use — a stream can only be consumed once. After a terminal operation is called, that stream can’t be used again.

Doesn’t modify the source — a stream doesn’t modify the original collection. It produces new results.

Operation TypeExamplesReturn
Intermediatefilter, map, sorted, distinct, limitNew stream (lazy)
Terminalcollect, forEach, count, reduce, findFirstA concrete value or void

Creating Streams #

From Collections and Arrays #

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

// From a List
List<String> fruits = List.of("Apple", "Mango", "Orange", "Banana");
Stream<String> fruitStream = fruits.stream();

// From a Set
Set<Integer> numbers = Set.of(1, 2, 3, 4, 5);
Stream<Integer> numberStream = numbers.stream();

// From a Map — stream entries, keys, or values
Map<String, Integer> prices = Map.of("Apple", 5000, "Mango", 8000);
Stream<Map.Entry<String, Integer>> entryStream = prices.entrySet().stream();
Stream<String>  keyStream   = prices.keySet().stream();
Stream<Integer> valueStream = prices.values().stream();

// From an array
String[] arr = {"a", "b", "c"};
Stream<String> arrStream = Arrays.stream(arr);
Stream<String> subStream = Arrays.stream(arr, 1, 3); // indices 1 to 2: ["b", "c"]

Static Streams and Generators #

// Stream.of() — create from direct elements
Stream<String> direct = Stream.of("One", "Two", "Three");

// Stream.empty() — an empty stream (useful as a return value)
Stream<String> empty = Stream.empty();

// Stream.generate() — an unbounded stream from a Supplier
// Always needs limit() to avoid going infinite
Stream<Double> random = Stream.generate(Math::random).limit(5);
Stream<String> uuids  = Stream.generate(() -> UUID.randomUUID().toString()).limit(3);

// Stream.iterate() — an unbounded stream with a seed and function
Stream<Integer> evens = Stream.iterate(0, n -> n + 2).limit(10);
// [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

// Stream.iterate() with a stop predicate (Java 9+)
Stream<Integer> lessThan20 = Stream.iterate(1, n -> n < 20, n -> n * 2);
// [1, 2, 4, 8, 16]

// Stream.concat() — combine two streams
Stream<String> combined = Stream.concat(
    Stream.of("a", "b"),
    Stream.of("c", "d")
); // ["a", "b", "c", "d"]

Primitive Streams #

For high performance, avoid boxing by using primitive streams:

// IntStream, LongStream, DoubleStream
IntStream ints = IntStream.range(1, 6);        // [1, 2, 3, 4, 5]
IntStream ints2 = IntStream.rangeClosed(1, 5); // [1, 2, 3, 4, 5] (inclusive)
LongStream longs  = LongStream.range(0L, 100L);
DoubleStream decimals = DoubleStream.of(1.1, 2.2, 3.3);

// Converting between object and primitive streams
Stream<Integer> boxed = ints.boxed();          // IntStream → Stream<Integer>
IntStream unboxed = fruits.stream().mapToInt(String::length); // Stream<String> → IntStream

Intermediate Operations #

Intermediate operations return a new stream and are lazy. You can chain as many as needed.

filter — Filtering Elements #

List<String> names = List.of("Budi", "Ani", "Citra", "Doni", "Eva");

// Filter names longer than 3
List<String> longNames = names.stream()
    .filter(n -> n.length() > 3)
    .collect(Collectors.toList());
// [Budi, Citra, Doni]

// Multiple filters can be chained
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> bigEvens = numbers.stream()
    .filter(n -> n % 2 == 0)  // filter evens
    .filter(n -> n > 4)        // filter greater than 4
    .collect(Collectors.toList());
// [6, 8, 10]

map — Transforming Elements #

List<String> names = List.of("budi", "ani", "citra");

// Change each element into another form
List<String> uppercased = names.stream()
    .map(String::toUpperCase) // method reference
    .collect(Collectors.toList());
// [BUDI, ANI, CITRA]

// Type transformation
record Product(Long id, String name, double price) {}

List<Product> products = List.of(
    new Product(1L, "Laptop", 12_000_000),
    new Product(2L, "Mouse", 150_000),
    new Product(3L, "Keyboard", 450_000)
);

// Take only the names
List<String> productNames = products.stream()
    .map(Product::name)
    .collect(Collectors.toList());
// ["Laptop", "Mouse", "Keyboard"]

// Raise prices by 10%
List<Product> priceRaised = products.stream()
    .map(p -> new Product(p.id(), p.name(), p.price() * 1.1))
    .collect(Collectors.toList());

// mapToInt/mapToDouble/mapToLong for primitive streams
IntStream nameLengths = names.stream().mapToInt(String::length);
double totalPrice = products.stream().mapToDouble(Product::price).sum();

flatMap — Flattening Nested Streams #

flatMap is useful when each element produces several elements — it “flattens” a two-level stream into one.

// Problem: every order has several items
record Order(String id, List<String> items) {}

List<Order> orders = List.of(
    new Order("O1", List.of("Laptop", "Mouse")),
    new Order("O2", List.of("Keyboard")),
    new Order("O3", List.of("Monitor", "HDMI Cable", "Stand"))
);

// map produces Stream<List<String>> — nested, hard to process
// flatMap produces Stream<String> — flat, easy to process
List<String> allItems = orders.stream()
    .flatMap(order -> order.items().stream()) // every List<String> becomes a Stream<String>
    .collect(Collectors.toList());
// ["Laptop", "Mouse", "Keyboard", "Monitor", "HDMI Cable", "Stand"]

// Count unique items from all orders
long uniqueItems = orders.stream()
    .flatMap(o -> o.items().stream())
    .distinct()
    .count();

// Splitting sentences into words
List<String> sentences = List.of("Hello world", "Java Stream API");
List<String> words = sentences.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))
    .collect(Collectors.toList());
// ["Hello", "world", "Java", "Stream", "API"]

sorted, distinct, limit, skip, peek #

List<Integer> numbers = List.of(5, 2, 8, 1, 9, 3, 7, 4, 6);

// sorted — ascending (natural order)
List<Integer> sorted = numbers.stream()
    .sorted()
    .collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6, 7, 8, 9]

// sorted with a Comparator
List<Product> expensiveFirst = products.stream()
    .sorted(Comparator.comparingDouble(Product::price).reversed())
    .collect(Collectors.toList());

// distinct — remove duplicates (uses equals())
List<Integer> unique = List.of(1, 2, 2, 3, 3, 3, 4).stream()
    .distinct()
    .collect(Collectors.toList());
// [1, 2, 3, 4]

// limit — take the first n elements
List<Integer> topThree = numbers.stream()
    .sorted()
    .limit(3)
    .collect(Collectors.toList());
// [1, 2, 3]

// skip — skip the first n elements
List<Integer> withoutFour = numbers.stream()
    .sorted()
    .skip(4)
    .collect(Collectors.toList());
// [5, 6, 7, 8, 9]

// peek — debug without changing the stream (use only for debugging)
List<String> result = names.stream()
    .peek(n -> System.out.println("Before filter: " + n))
    .filter(n -> n.length() > 3)
    .peek(n -> System.out.println("After filter: " + n))
    .collect(Collectors.toList());

Terminal Operations #

Terminal operations trigger the execution of the entire pipeline and produce a concrete value.

forEach and forEachOrdered #

// forEach — iteration (order not guaranteed in parallel streams)
products.stream().forEach(p -> System.out.println(p.name()));

// forEachOrdered — order always follows the source (important in parallel streams)
products.parallelStream().forEachOrdered(p -> System.out.println(p.name()));

count, min, max, sum, average #

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

long count = numbers.stream().count(); // 8

// min and max use a Comparator, return Optional
Optional<Integer> min = numbers.stream().min(Integer::compareTo); // Optional[1]
Optional<Integer> max = numbers.stream().max(Integer::compareTo); // Optional[9]

// sum and average are only available on primitive streams
int total    = numbers.stream().mapToInt(Integer::intValue).sum(); // 31
double avg  = numbers.stream().mapToInt(Integer::intValue).average().orElse(0); // 3.875

// IntSummaryStatistics — all statistics at once
IntSummaryStatistics stats = numbers.stream()
    .mapToInt(Integer::intValue)
    .summaryStatistics();
System.out.println("Min: " + stats.getMin());     // 1
System.out.println("Max: " + stats.getMax());     // 9
System.out.println("Sum: " + stats.getSum());     // 31
System.out.println("Avg: " + stats.getAverage()); // 3.875
System.out.println("Count: " + stats.getCount()); // 8

findFirst, findAny, anyMatch, allMatch, noneMatch #

List<String> names = List.of("Budi", "Ani", "Citra", "Doni");

// findFirst — the first matching element (deterministic)
Optional<String> first = names.stream()
    .filter(n -> n.startsWith("C"))
    .findFirst(); // Optional["Citra"]

// findAny — any matching element (faster in parallel)
Optional<String> any = names.parallelStream()
    .filter(n -> n.length() > 3)
    .findAny(); // could be Budi, Citra, or Doni — not guaranteed

// anyMatch — does any element match? (short-circuit)
boolean hasLong = names.stream().anyMatch(n -> n.length() > 4); // true (Citra)

// allMatch — do all elements match?
boolean allShort = names.stream().allMatch(n -> n.length() <= 5); // true

// noneMatch — does no element match?
boolean noZ = names.stream().noneMatch(n -> n.startsWith("Z")); // true

reduce — Accumulating Elements #

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

// reduce with an identity (initial value) — always returns a value (not an Optional)
int sum = numbers.stream().reduce(0, Integer::sum);    // 15
int product   = numbers.stream().reduce(1, (a, b) -> a * b); // 120

// reduce without an identity — returns an Optional (the stream could be empty)
Optional<Integer> max = numbers.stream().reduce(Integer::max); // Optional[5]

// reduce to build a string
String joined = Stream.of("Hello", "world", "Java")
    .reduce("", (a, b) -> a.isEmpty() ? b : a + " " + b);
// "Hello world Java"

// But for joining strings, use Collectors.joining() — more efficient
String joined2 = Stream.of("Hello", "world", "Java")
    .collect(Collectors.joining(" ")); // "Hello world Java"

Collectors — Gathering Results #

Collectors provides various ways to gather stream elements into collections or aggregate values.

Basic Collectors #

import java.util.stream.Collectors;

List<Product> products = /* ... */;

// toList() — Java 16+ (unmodifiable), or Collectors.toList() (modifiable)
List<Product> asList  = products.stream().collect(Collectors.toList());
List<Product> asListv2 = products.stream().toList(); // Java 16+, unmodifiable

// toSet() — remove duplicates
Set<String> productNames = products.stream()
    .map(Product::name)
    .collect(Collectors.toSet());

// toMap() — create a Map from a stream
Map<Long, Product> byId = products.stream()
    .collect(Collectors.toMap(Product::id, p -> p));

// toMap() with a merge function for duplicate keys
Map<Integer, String> byNameLength = products.stream()
    .collect(Collectors.toMap(
        p -> p.name().length(),            // key: name length
        Product::name,                       // value: name
        (existing, created) -> existing + ", " + created // merge if keys are equal
    ));

// joining — combine strings
String namesJoined = products.stream()
    .map(Product::name)
    .collect(Collectors.joining(", ", "[", "]"));
// "[Laptop, Mouse, Keyboard]"

// counting — count elements
long count = products.stream().collect(Collectors.counting());

// summarizing — numeric statistics
DoubleSummaryStatistics priceStats = products.stream()
    .collect(Collectors.summarizingDouble(Product::price));

groupingBy — Grouping Elements #

record Student(String name, String major, double gpa) {}

List<Student> students = List.of(
    new Student("Budi",  "Informatics", 3.8),
    new Student("Ani",   "Informatics", 3.5),
    new Student("Citra", "Mathematics",  3.9),
    new Student("Doni",  "Mathematics",  3.2),
    new Student("Eva",   "Physics",      3.7)
);

// Group by major
Map<String, List<Student>> byMajor = students.stream()
    .collect(Collectors.groupingBy(Student::major));
// {Informatics=[Budi, Ani], Mathematics=[Citra, Doni], Physics=[Eva]}

// Group and count per group
Map<String, Long> countByMajor = students.stream()
    .collect(Collectors.groupingBy(Student::major, Collectors.counting()));
// {Informatics=2, Mathematics=2, Physics=1}

// Group and calculate the average GPA per major
Map<String, Double> avgGpaByMajor = students.stream()
    .collect(Collectors.groupingBy(
        Student::major,
        Collectors.averagingDouble(Student::gpa)
    ));

// Group and take only names per major (downstream collector)
Map<String, List<String>> namesByMajor = students.stream()
    .collect(Collectors.groupingBy(
        Student::major,
        Collectors.mapping(Student::name, Collectors.toList())
    ));

// Double groupingBy — group at two levels
Map<String, Map<String, List<Student>>> nested = students.stream()
    .collect(Collectors.groupingBy(
        Student::major,
        Collectors.groupingBy(s -> s.gpa() >= 3.5 ? "Cum Laude" : "Regular")
    ));

partitioningBy — Splitting into Two Groups #

// partitioningBy always produces Map<Boolean, List<T>>
Map<Boolean, List<Student>> partition = students.stream()
    .collect(Collectors.partitioningBy(s -> s.gpa() >= 3.5));

List<Student> cumLaude  = partition.get(true);  // GPA >= 3.5
List<Student> regular   = partition.get(false); // GPA < 3.5

// partitioningBy with a downstream collector
Map<Boolean, Long> partitionCount = students.stream()
    .collect(Collectors.partitioningBy(
        s -> s.gpa() >= 3.5,
        Collectors.counting()
    ));
// {true=4, false=1}

teeing — Two Collectors at Once (Java 12+) #

// Calculate the minimum and maximum in one pass
record MinMax(double min, double max) {}

MinMax minmax = students.stream()
    .collect(Collectors.teeing(
        Collectors.minBy(Comparator.comparingDouble(Student::gpa)),
        Collectors.maxBy(Comparator.comparingDouble(Student::gpa)),
        (min, max) -> new MinMax(
            min.map(Student::gpa).orElse(0.0),
            max.map(Student::gpa).orElse(0.0)
        )
    ));
// MinMax[min=3.2, max=3.9]

Optional — Values That May Not Exist #

Optional<T> is a wrapper that explicitly states a value may not exist — a safer replacement for null. Streams often return Optional from terminal operations like findFirst(), min(), max().

Creating and Using Optional #

import java.util.Optional;

// Creating an Optional
Optional<String> present    = Optional.of("value");         // definitely present
Optional<String> maybe = Optional.ofNullable(getFromDB()); // may be null
Optional<String> empty  = Optional.empty();            // definitely empty

// ANTI-PATTERN: using Optional like a regular null check
if (present.isPresent()) {
    String value = present.get();
    System.out.println(value);
}

// CORRECT: use Optional's functional methods
present.ifPresent(System.out::println);

// orElse — a default value if empty
String result = maybe.orElse("default");

// orElseGet — a default value from a Supplier (lazy, only computed if empty)
String lazyResult = maybe.orElseGet(() -> computeDefault());

// orElseThrow — throw an exception if empty
String required = maybe.orElseThrow(() -> new RuntimeException("Data not found"));

// map — transform if present
Optional<Integer> length = present.map(String::length); // Optional[5]

// flatMap — a transform that also returns an Optional
Optional<String> trimmed = present.flatMap(s -> s.isEmpty() ? Optional.empty() : Optional.of(s.trim()));

// filter — empty it if it doesn't match
Optional<String> onlyLong = present.filter(s -> s.length() > 3);

// stream() — convert an Optional to a Stream (useful inside flatMap)
List<String> result2 = List.of(Optional.of("Present"), Optional.empty(), Optional.of("Also"))
    .stream()
    .flatMap(Optional::stream) // only the present ones
    .collect(Collectors.toList());
// ["Present", "Also"]

Parallel Streams #

A parallel stream splits the data into several parts and processes them in parallel using a ForkJoinPool. It can speed up operations on large datasets, but isn’t always faster.

Using Parallel Streams #

List<Integer> bigNumbers = IntStream.rangeClosed(1, 10_000_000)
    .boxed()
    .collect(Collectors.toList());

// Sequential — one thread
long seqSum = bigNumbers.stream()
    .filter(n -> n % 2 == 0)
    .mapToLong(Long::valueOf)
    .sum();

// Parallel — many threads (ForkJoinPool.commonPool())
long parSum = bigNumbers.parallelStream()
    .filter(n -> n % 2 == 0)
    .mapToLong(Long::valueOf)
    .sum();

// Converting from sequential to parallel and back
bigNumbers.stream()
    .parallel()   // convert to parallel
    .sequential() // convert back to sequential
    .forEach(System.out::println);

When a Parallel Stream Is Faster (and When It Isn’t) #

// GOOD for parallel streams:
// - Very large data (>10,000 elements)
// - Expensive per-element computation (CPU-bound)
// - Result order doesn't matter or forEachOrdered is used

// NOT GOOD:
// - Little data — threading overhead outweighs the benefit
// - I/O-bound operations (parallel doesn't help I/O)
// - Operations depending on shared state (race condition!)

// ANTI-PATTERN: modifying a collection from a parallel stream
List<String> result = new ArrayList<>();
names.parallelStream().forEach(result::add); // ✗ race condition!

// CORRECT: collect with collect()
List<String> safeResult = names.parallelStream()
    .filter(n -> n.length() > 3)
    .collect(Collectors.toList()); // ✓ thread-safe

Idiomatic Patterns #

Transforming Object Lists #

// Convert List<Entity> → List<DTO> (the most common pattern in applications)
record ProductDTO(String name, String formattedPrice) {}

List<ProductDTO> dtos = products.stream()
    .map(p -> new ProductDTO(
        p.name(),
        "Rp" + String.format("%,.0f", p.price())
    ))
    .collect(Collectors.toList());

Search with a Default #

// Find the most expensive product, or a default product if the list is empty
Product mostExpensive = products.stream()
    .max(Comparator.comparingDouble(Product::price))
    .orElse(new Product(0L, "None", 0));

// Find by a condition
Optional<Product> laptop = products.stream()
    .filter(p -> p.name().equalsIgnoreCase("laptop"))
    .findFirst();

Deduplication by a Specific Key #

// Remove duplicates based on a specific field (not the whole object's equals())
Map<String, Product> byName = products.stream()
    .collect(Collectors.toMap(
        Product::name,
        p -> p,
        (existing, created) -> existing // keep first
    ));
List<Product> unique = new ArrayList<>(byName.values());

Partitioning and Processing Two Groups #

var partition = products.stream()
    .collect(Collectors.partitioningBy(p -> p.price() > 1_000_000));

List<Product> expensive  = partition.get(true);
List<Product> cheap  = partition.get(false);

System.out.println("Expensive products: " + expensive.size());
System.out.println("Cheap products: " + cheap.size());

Building a Report from a Stream #

// Build a concise report in one expression
String report = products.stream()
    .sorted(Comparator.comparingDouble(Product::price).reversed())
    .map(p -> String.format("%-20s Rp%,.0f", p.name(), p.price()))
    .collect(Collectors.joining("\n",
        "=== PRODUCT LIST (Most Expensive) ===\n",
        "\n=== END OF REPORT ==="));

System.out.println(report);

Streams from Files #

import java.nio.file.Files;
import java.nio.file.Path;

// Process a large file line by line — lazy, memory efficient
long errorCount = Files.lines(Path.of("server.log"))
    .filter(line -> line.contains("ERROR"))
    .count();

// Collect all unique IPs from a log
Set<String> uniqueIPs = Files.lines(Path.of("access.log"))
    .map(line -> line.split(" ")[0]) // the first column is the IP
    .collect(Collectors.toSet());

// Remember: Files.lines() must be closed!
try (Stream<String> lines = Files.lines(Path.of("data.csv"))) {
    lines.skip(1) // skip the header
         .map(line -> line.split(","))
         .filter(col -> col.length >= 3)
         .forEach(col -> System.out.println(col[0] + " → " + col[2]));
}

When to Use Streams #

Use STREAM when:
  ✓ You need filter, map, reduce on collections
  ✓ You need grouping, partitioning, or data aggregation
  ✓ You need object list transformations (Entity → DTO)
  ✓ You want more declarative, readable code
  ✓ You need to process large files line by line with lazy evaluation

Use REGULAR LOOPS when:
  ✗ You need break/continue based on complex conditions
  ✗ You need to modify elements while iterating
  ✗ The operation is very simple and a loop is clearer
  ✗ You need direct index access (use IntStream.range instead)

Use PARALLEL STREAMS when:
  ✓ The dataset is very large (>10,000 elements) and the operations are CPU-bound
  ✓ The result order doesn't matter
  ✗ Avoid if there's shared mutable state
  ✗ Avoid for I/O operations — that's not the bottleneck parallel solves

Anti-patterns to avoid:
  ✗ Don't modify a collection from inside a stream
  ✗ Don't use peek() for business logic, only for debugging
  ✗ Don't forget to close streams created from I/O (Files.lines)
  ✗ Don't chain too many operations in one stream without comments

Summary #

  • A stream is a lazy pipeline — intermediate operations aren’t executed until a terminal operation triggers them. This enables optimizations like short-circuiting and single-pass processing.
  • filter filters, map transforms, flatMap flattens — these are the most frequently used operations. flatMap is used when each element produces several elements (lists within lists).
  • collect(Collectors.toList()) or .toList() (Java 16+) to gather into a List. Use Collectors.toMap(), groupingBy(), joining(), or partitioningBy() for more structured results.
  • groupingBy() is the most important collector — it groups elements by a key function and supports downstream collectors for further aggregation (counting, averaging, mapping).
  • Optional replaces null — use orElse(), orElseGet(), map(), filter(), ifPresent() instead of isPresent() + get(). Don’t return null from methods that produce an Optional.
  • Primitive streams (IntStream, LongStream, DoubleStream) avoid boxing overhead — use them for heavy numeric operations. mapToInt(), mapToDouble(), sum(), average(), summaryStatistics() are available here.
  • Parallel streams aren’t a universal solution — only effective for large datasets and CPU-bound operations. Always measure with a benchmark before enabling parallelism in production.
  • Files.lines() must be closed — file-backed streams hold OS resources. Always wrap them in try-with-resources.
  • Don’t modify collections from inside streams — use collect() to produce new collections, not modify the one being iterated.

← Previous: Mocking   Next: JSON →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact