Stream API — Creation, Intermediate Ops, Terminal Ops, and Parallel Streams
In this tutorial, you will learn about Stream API. We cover key concepts, practical examples, and best practices to help you master this topic.
The Java Stream API processes sequences of elements with functional-style operations, enabling declarative data processing pipelines. Streams let you Express complex data transformations — filtering, mapping, reducing — as a chain of operations on a source, without explicit loops or mutable state.
What You'll Learn
- Creating streams from collections, arrays, and generators
- Intermediate operations: filter, map, flatMap, distinct, sorted, peek
- Terminal operations: collect, reduce, count, anyMatch, forEach
- Lazy evaluation and stream characteristics
- Parallel streams for multi-threaded processing
Why It Matters
Streams lead to more readable, less error-prone code. A stream pipeline reads like a problem description: "filter inactive users, map to names, sort, collect to list" — no nested loops, no temporary variables, no off-by-one errors.
Real-World Use
Batch processing (transform millions of records), Etl Pipelines, report generation, and data validation all use streams. Spring Data JPA returns streams from database queries.
Creating Streams
// From collections
List<String> list = List.of("a", "b", "c");
Stream<String> stream = list.stream();
Stream<String> parallelStream = list.parallelStream();
// From arrays
int[] numbers = {1, 2, 3};
IntStream intStream = Arrays.stream(numbers);
Stream<String> stringStream = Stream.of("a", "b", "c");
// From values
Stream<Integer> values = Stream.of(1, 2, 3);
Stream<Object> empty = Stream.empty();
// Infinite streams
Stream<Integer> naturals = Stream.iterate(0, n -> n + 1);
Stream<Double> randoms = Stream.generate(Math::random);
Primitive Streams
IntStream.range(1, 10); // 1..9
IntStream.rangeClosed(1, 10); // 1..10
LongStream.range(0, 100);
DoubleStream.generate(Math::random);
Intermediate Operations
Intermediate operations return a new stream. They are lazy — nothing happens until a terminal operation is invoked.
filter
List<String> names = List.of("Alice", "Bob", "Charlie", "David");
names.stream()
.filter(name -> name.length() > 4)
.forEach(System.out::println);
// Alice
// Charlie
map
names.stream()
.map(String::toUpperCase)
.forEach(System.out::println);
// ALICE, BOB, CHARLIE, DAVID
flatMap
Flattens nested structures:
List<List<Integer>> nested = List.of(
List.of(1, 2),
List.of(3, 4, 5),
List.of(6)
);
List<Integer> flat = nested.stream()
.flatMap(Collection::stream)
.toList();
// [1, 2, 3, 4, 5, 6]
distinct
List<Integer> withDups = List.of(1, 2, 2, 3, 3, 3);
List<Integer> unique = withDups.stream()
.distinct()
.toList();
// [1, 2, 3]
sorted
names.stream()
.sorted(Comparator.comparingInt(String::length))
.forEach(System.out::println);
// Bob, Alice, David, Charlie
peek (Debugging)
long count = names.stream()
.peek(System.out::println)
.count();
limit and skip
IntStream.range(0, 100)
.skip(10)
.limit(5)
.forEach(System.out::print); // 10 11 12 13 14
Terminal Operations
Terminal operations produce a result or side effect. After a terminal operation, the stream is consumed.
collect
List<String> result = stream.collect(Collectors.toList());
Set<String> set = stream.collect(Collectors.toSet());
String joined = stream.collect(Collectors.joining(", "));
toList() (Java 16+)
List<String> result = stream.toList(); // immutable list
reduce
int sum = IntStream.range(1, 6)
.reduce(0, (a, b) -> a + b);
// 15
Optional<Integer> sumOpt = stream.reduce(Integer::sum);
count
long count = stream.count();
anyMatch, allMatch, noneMatch
boolean hasLong = names.stream().anyMatch(name -> name.length() > 5);
boolean allShort = names.stream().allMatch(name -> name.length() < 10);
boolean noEmpty = names.stream().noneMatch(String::isEmpty);
findFirst, findAny
Optional<String> first = names.stream()
.filter(n -> n.startsWith("A"))
.findFirst();
forEach
stream.forEach(System.out::println);
Pipeline Example
List<Transaction> transactions = getTransactions();
List<String> highValueCustomerNames = transactions.stream()
.filter(t -> t.getAmount() > 1000)
.filter(t -> t.getType() == TransactionType.CREDIT)
.map(Transaction::getCustomerName)
.distinct()
.sorted()
.toList();
Lazy Evaluation
Intermediate operations are not executed until a terminal operation is added:
Stream<String> stream = names.stream()
.filter(name -> {
System.out.println("Filtering: " + name);
return name.length() > 3;
})
.map(name -> {
System.out.println("Mapping: " + name);
return name.toUpperCase();
});
// Nothing printed yet — lazy
stream.forEach(System.out::println);
// Filtering: Alice
// Mapping: Alice
// ALICE
// Filtering: Bob
// Filtering: Charlie
// Mapping: Charlie
// CHARLIE
Each element passes through the pipeline vertically (filter -> map -> forEach) rather than horizontally (all filters, then all maps).
Parallel Streams
long sum = LongStream.rangeClosed(0, 10_000_000)
.parallel()
.sum();
When to Use
- Large datasets (thousands of elements)
- CPU-intensive operations
- Independent elements (no shared mutable state)
When to Avoid
- Small datasets (parallel overhead outweighs benefits)
- I/O-bound operations (blocking threads)
- Non-thread-safe shared state (race conditions)
- Ordered operations that require encounter order
Common Mistakes
- Reusing a stream after terminal operation. Streams are consumed after one terminal operation. Calling a second terminal operation throws
IllegalStateException. - Modifying the source while streaming. If the backing collection is modified during streaming,
ConcurrentModificationExceptionmay be thrown. - Using
parallel()without considering Thread Safety. Shared mutable state requires synchronization. - Assuming
findFirst()is faster thanfindAny()with parallel streams.findAny()is more parallel-friendly. - Using
forEach()whencollect()is more appropriate.forEach()is for side effects;collect()is for reducing to a result.
Practice Questions
1. What is the difference between intermediate and terminal operations?
Intermediate operations are lazy and return a new stream. Terminal operations produce a result or side effect and consume the stream.
2. How does flatMap differ from map?
map transforms each element to another object (1-to-1). flatMap transforms each element to a stream and flattens the result (1-to-many).
3. What does Stream.of(1, 2, 3).toList() return?
An immutable List<Integer> containing [1, 2, 3] (Java 16+).
4. When should you use parallel streams?
For large datasets with CPU-intensive, independent operations. Avoid for small datasets, I/O operations, or when order matters.
5. Can a stream be reused?
No. A stream can have only one terminal operation. After that, the stream is consumed.
Challenge Question:
Write a method Map<String, List<String>> groupByFirstLetter(List<String> words) that groups words by their first letter. Then use streams to find the most common first letter. Also write a method OptionalDouble median(int[] numbers) that finds the median using streams.
FAQ
Mini Project
Write a program StreamDemo.java that:
- Generates a list of 100 random
Personobjects (name, age, city) - Filters: people older than 18
- Maps: extract names and ages
- Groups by city using
Collectors.groupingBy() - Finds the average age per city using
averagingInt() - Finds the top 3 oldest people using
sorted()andlimit() - Processes the same data with parallel streams and measures time
- Uses
flatMapto extract all unique letters from all names - Collects results into various forms:
List,Set,Map,String
What's Next
Streams and collectors work together. Lesson 35 explores stream collectors in depth — toList, groupingBy, partitioningBy, mapping, teeing, and custom collectors for complex aggregations.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro