- Understand how to create and use streams
- Master intermediate operations: filter, map, flatMap, sorted
- Learn terminal operations: collect, reduce, forEach
Stream API Basics
Before Java 8, processing collections meant loops—often nested, with conditionals scattered everywhere. It worked, but was verbose, error-prone, hard to parallelize. The Stream API changed everything with a functional, declarative approach.
Instead of telling Java how to process data (loop, check, add), streams let you describe what you want (filter, transform, collect). More readable, maintainable, often more efficient.
What is a Stream?
A stream is a sequence of elements supporting operations. Like a factory pipeline:
- Source: Data enters (collection, array, generator)
- Intermediate operations: Processing stations (filter, transform, sort)
- Terminal operation: Finished products (collected, counted, reduced)
Streams vs Collections
It's important to understand that streams are NOT collections:
| Collections | Streams |
|---|---|
| Store elements in memory | Don't store elements - they flow through |
| Can be accessed multiple times | Can only be consumed once |
| Eager - elements exist now | Lazy - computed on demand |
| You iterate externally (for loop) | Iteration happens internally |
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// Collection approach: external iteration
for (String name : names) {
if (name.length() > 3) {
System.out.println(name.toUpperCase());
}
}
// Stream approach: internal iteration, declarative
names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
Creating Streams
From Collections
The most common way to create a stream is from a collection:
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
// For parallel processing
Stream<String> parallelStream = list.parallelStream();
From Arrays
String[] array = {"x", "y", "z"};
Stream<String> stream = Arrays.stream(array);
Using Stream.of()
Stream<String> stream = Stream.of("one", "two", "three");
Generating Streams
For special cases, you can generate streams:
// Empty stream
Stream<String> empty = Stream.empty();
// Infinite stream of even numbers: 0, 2, 4, 6, ...
Stream<Integer> evens = Stream.iterate(0, n -> n + 2);
// Infinite stream of random numbers
Stream<Double> randoms = Stream.generate(Math::random);
// Iterate with condition (Java 9+)
Stream<Integer> limited = Stream.iterate(0, n -> n < 100, n -> n + 10);
// Produces: 0, 10, 20, 30, ..., 90
Primitive Streams
To avoid boxing overhead, Java provides specialized streams for primitives:
// IntStream for int values
IntStream numbers = IntStream.of(1, 2, 3, 4, 5);
IntStream range = IntStream.range(1, 10); // 1 to 9
IntStream rangeClosed = IntStream.rangeClosed(1, 10); // 1 to 10
// Useful for numeric operations
int sum = IntStream.rangeClosed(1, 100).sum(); // 5050
double avg = IntStream.rangeClosed(1, 100).average().getAsDouble(); // 50.5
Understanding Lazy Evaluation
A crucial concept in streams is lazy evaluation. Intermediate operations don't execute immediately - they only set up the pipeline. Actual processing happens only when a terminal operation is called.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
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();
});
System.out.println("Stream created, nothing printed yet!");
// Only when we call a terminal operation...
stream.forEach(System.out::println);
This laziness enables optimizations like short-circuiting - if you only need the first result, the stream won't process all elements.
Intermediate Operations
Intermediate operations transform a stream into another stream. They're always lazy.
filter() - Selecting Elements
filter() keeps only elements that match a condition (predicate):
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Keep only even numbers
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
// Result: [2, 4, 6, 8, 10]
// Multiple filters can be chained
List<Integer> result = numbers.stream()
.filter(n -> n > 3) // Greater than 3
.filter(n -> n < 8) // Less than 8
.collect(Collectors.toList());
// Result: [4, 5, 6, 7]
map() - Transforming Elements
map() transforms each element using a function:
List<String> names = Arrays.asList("alice", "bob", "charlie");
// Transform to uppercase
List<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// Result: [ALICE, BOB, CHARLIE]
// Transform to different type
List<Integer> lengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
// Result: [5, 3, 7]
// Chain transformations
List<String> result = names.stream()
.map(String::toUpperCase)
.map(s -> s + "!")
.collect(Collectors.toList());
// Result: [ALICE!, BOB!, CHARLIE!]
flatMap() - Flattening Nested Structures
When each element maps to multiple elements (or a stream), flatMap() flattens the result:
// Problem: map() creates Stream of Streams
List<List<Integer>> nested = Arrays.asList(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5),
Arrays.asList(6, 7, 8)
);
// flatMap flattens to a single stream
List<Integer> flat = nested.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
// Result: [1, 2, 3, 4, 5, 6, 7, 8]
// Practical example: words from sentences
List<String> sentences = Arrays.asList("Hello World", "Java Streams");
List<String> words = sentences.stream()
.flatMap(sentence -> Arrays.stream(sentence.split(" ")))
.collect(Collectors.toList());
// Result: [Hello, World, Java, Streams]
sorted() - Ordering Elements
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
// Natural order
List<String> sorted = names.stream()
.sorted()
.collect(Collectors.toList());
// Result: [Alice, Bob, Charlie]
// Reverse order
List<String> reversed = names.stream()
.sorted(Comparator.reverseOrder())
.collect(Collectors.toList());
// Result: [Charlie, Bob, Alice]
// Custom sorting - by length
List<String> byLength = names.stream()
.sorted(Comparator.comparing(String::length))
.collect(Collectors.toList());
// Result: [Bob, Alice, Charlie]
distinct(), limit(), and skip()
// Remove duplicates
List<Integer> unique = Arrays.asList(1, 2, 2, 3, 3, 3).stream()
.distinct()
.collect(Collectors.toList());
// Result: [1, 2, 3]
// Take first N elements
List<Integer> firstThree = Stream.iterate(1, n -> n + 1)
.limit(3)
.collect(Collectors.toList());
// Result: [1, 2, 3]
// Skip first N elements
List<Integer> skipTwo = Arrays.asList(1, 2, 3, 4, 5).stream()
.skip(2)
.collect(Collectors.toList());
// Result: [3, 4, 5]
Terminal Operations
Terminal operations trigger the processing and produce a result or side-effect.
forEach() - Performing Actions
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// Print each element
names.stream().forEach(System.out::println);
// With more complex action
names.stream().forEach(name -> {
System.out.println("Processing: " + name);
// do something else
});
Note: forEach is for side-effects. Prefer collect() when building a result.
collect() - Gathering Results
collect() is the most versatile terminal operation. The Collectors class provides many useful collectors:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Alice");
// To List
List<String> list = names.stream().collect(Collectors.toList());
// To Set (removes duplicates)
Set<String> set = names.stream().collect(Collectors.toSet());
// Join to String
String joined = names.stream().collect(Collectors.joining(", "));
// Result: "Alice, Bob, Charlie, Alice"
String withBrackets = names.stream()
.collect(Collectors.joining(", ", "[", "]"));
// Result: "[Alice, Bob, Charlie, Alice]"
// To Map
Map<String, Integer> nameLengths = names.stream()
.distinct()
.collect(Collectors.toMap(
name -> name, // key
String::length // value
));
// Result: {Alice=5, Bob=3, Charlie=7}
Grouping and Partitioning
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Anna", "Brian");
// Group by first letter
Map<Character, List<String>> byFirstLetter = names.stream()
.collect(Collectors.groupingBy(name -> name.charAt(0)));
// Result: {A=[Alice, Anna], B=[Bob, Brian], C=[Charlie]}
// Group by length
Map<Integer, List<String>> byLength = names.stream()
.collect(Collectors.groupingBy(String::length));
// Result: {3=[Bob], 4=[Anna], 5=[Alice, Brian], 7=[Charlie]}
// Partition (split into two groups)
Map<Boolean, List<String>> partition = names.stream()
.collect(Collectors.partitioningBy(name -> name.length() > 4));
// Result: {false=[Bob, Anna], true=[Alice, Brian, Charlie]}
reduce() - Combining Elements
reduce() combines all elements into a single result:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// Sum all numbers
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
// Starts with 0, adds each number
// Result: 15
// With method reference
int sum2 = numbers.stream()
.reduce(0, Integer::sum);
// Find maximum
Optional<Integer> max = numbers.stream()
.reduce(Integer::max);
// Result: Optional[5]
// Concatenate strings
List<String> words = Arrays.asList("Hello", " ", "World");
String sentence = words.stream()
.reduce("", String::concat);
// Result: "Hello World"
Finding and Matching
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
// Find first matching
Optional<Integer> firstEven = numbers.stream()
.filter(n -> n % 2 == 0)
.findFirst();
// Result: Optional[2]
// Find any matching (useful for parallel)
Optional<Integer> anyEven = numbers.stream()
.filter(n -> n % 2 == 0)
.findAny();
// Check if any match
boolean hasEven = numbers.stream()
.anyMatch(n -> n % 2 == 0); // true
// Check if all match
boolean allPositive = numbers.stream()
.allMatch(n -> n > 0); // true
// Check if none match
boolean noneNegative = numbers.stream()
.noneMatch(n -> n < 0); // true
// Count elements
long count = numbers.stream()
.filter(n -> n % 2 == 0)
.count(); // 3
A Complete Example
Let's put it all together with a real-world example:
class Employee {
String name;
String department;
double salary;
// constructor, getters...
}
List<Employee> employees = getEmployees();
// Find the average salary of IT department employees earning over 50000
double avgSalary = employees.stream()
.filter(e -> e.getDepartment().equals("IT"))
.filter(e -> e.getSalary() > 50000)
.mapToDouble(Employee::getSalary)
.average()
.orElse(0.0);
// Get names of top 3 highest paid employees
List<String> topEarners = employees.stream()
.sorted(Comparator.comparing(Employee::getSalary).reversed())
.limit(3)
.map(Employee::getName)
.collect(Collectors.toList());
// Group employees by department with their count
Map<String, Long> countByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.counting()
));
Streams don't store data—they process it. Intermediate operations are lazy—they don't execute until you call a terminal operation. Streams can only be consumed once. Use them for data transformation, loops for simple iterations with side effects. Benefits: readable declarative code, easy parallelization with parallelStream(), powerful operations like grouping and reducing, lazy evaluation for efficiency.
