Introduction
The Stream API, introduced in Java 8, revolutionized how we process collections. Instead of writing imperative loops, streams let us express data transformations in a declarative, functional style.
This comprehensive guide takes you from stream basics to advanced techniques, with practical examples you can use in real projects.
Why Streams Matter
Streams offer several advantages over traditional loops:
-
Readability: Code reads like a description of what you want, not how to get it
-
Composability: Chain operations to build complex transformations
-
Lazy evaluation: Process only what’s needed
-
Parallel processing: Easy parallelization for performance
-
Functional style: Encourages immutability and pure functions
Stream Basics
Creating Streams
There are many ways to create streams:
// From a collection
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
Stream<String> stream1 = names.stream();
// From an array
String[] array = {"one", "two", "three"};
Stream<String> stream2 = Arrays.stream(array);
// Using Stream.of()
Stream<String> stream3 = Stream.of("apple", "banana", "cherry");
// Empty stream
Stream<String> empty = Stream.empty();
// Infinite streams
Stream<Integer> infinite = Stream.iterate(0, n -> n + 1);
Stream<Double> random = Stream.generate(Math::random);
// From a range
IntStream range = IntStream.range(1, 10); // 1 to 9
IntStream rangeClosed = IntStream.rangeClosed(1, 10); // 1 to 10
// From a string
IntStream chars = "Hello".chars();
// From a file (Java NIO)
try (Stream<String> lines = Files.lines(Paths.get("file.txt"))) {
// Process lines
}
Basic Operations
Stream operations are divided into intermediate (return a stream) and terminal (return a result) operations.
Filter
Select elements that match a predicate:
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Get even numbers
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
// Result: [2, 4, 6, 8, 10]
// Multiple filters
List<Integer> result = numbers.stream()
.filter(n -> n > 3)
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
// Result: [4, 6, 8, 10]
Map
Transform each element:
List<String> names = List.of("alice", "bob", "charlie");
// Convert to uppercase
List<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// Result: ["ALICE", "BOB", "CHARLIE"]
// Get lengths
List<Integer> lengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
// Result: [5, 3, 7]
// Chain transformations
List<String> formatted = names.stream()
.map(String::toUpperCase)
.map(s -> "Name: " + s)
.collect(Collectors.toList());
// Result: ["Name: ALICE", "Name: BOB", "Name: CHARLIE"]
FlatMap
Flatten nested structures:
// List of lists
List<List<Integer>> nested = List.of(
List.of(1, 2, 3),
List.of(4, 5),
List.of(6, 7, 8, 9)
);
// Flatten to single list
List<Integer> flattened = nested.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
// Result: [1, 2, 3, 4, 5, 6, 7, 8, 9]
// Split strings and flatten
List<String> sentences = List.of("Hello World", "Java Streams");
List<String> words = sentences.stream()
.flatMap(sentence -> Arrays.stream(sentence.split(" ")))
.collect(Collectors.toList());
// Result: ["Hello", "World", "Java", "Streams"]
Distinct
Remove duplicates:
List<Integer> numbers = List.of(1, 2, 2, 3, 3, 3, 4, 5, 5);
List<Integer> unique = numbers.stream()
.distinct()
.collect(Collectors.toList());
// Result: [1, 2, 3, 4, 5]
Sorted
Sort elements:
List<String> names = List.of("Charlie", "Alice", "Bob");
// Natural order
List<String> sorted = names.stream()
.sorted()
.collect(Collectors.toList());
// Result: ["Alice", "Bob", "Charlie"]
// Custom comparator
List<String> byLength = names.stream()
.sorted(Comparator.comparingInt(String::length))
.collect(Collectors.toList());
// Result: ["Bob", "Alice", "Charlie"]
// Reverse order
List<String> reversed = names.stream()
.sorted(Comparator.reverseOrder())
.collect(Collectors.toList());
// Result: ["Charlie", "Bob", "Alice"]
Limit and Skip
Control stream size:
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// First 5 elements
List<Integer> first5 = numbers.stream()
.limit(5)
.collect(Collectors.toList());
// Result: [1, 2, 3, 4, 5]
// Skip first 5, take rest
List<Integer> after5 = numbers.stream()
.skip(5)
.collect(Collectors.toList());
// Result: [6, 7, 8, 9, 10]
// Pagination: skip 10, take 5
List<Integer> page = numbers.stream()
.skip(10)
.limit(5)
.collect(Collectors.toList());
Terminal Operations
Collect
The most versatile terminal operation:
List<String> names = List.of("Alice", "Bob", "Charlie", "David");
// To List
List<String> list = names.stream()
.collect(Collectors.toList());
// To Set
Set<String> set = names.stream()
.collect(Collectors.toSet());
// To Map
Map<String, Integer> nameToLength = names.stream()
.collect(Collectors.toMap(
name -> name, // key
String::length // value
));
// Grouping
Map<Integer, List<String>> byLength = names.stream()
.collect(Collectors.groupingBy(String::length));
// Result: {3=[Bob], 5=[Alice, David], 7=[Charlie]}
// Joining strings
String joined = names.stream()
.collect(Collectors.joining(", "));
// Result: "Alice, Bob, Charlie, David"
String withBrackets = names.stream()
.collect(Collectors.joining(", ", "[", "]"));
// Result: "[Alice, Bob, Charlie, David]"
Reduce
Combine elements into a single result:
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
// Sum
int sum = numbers.stream()
.reduce(0, Integer::sum);
// Result: 15
// Product
int product = numbers.stream()
.reduce(1, (a, b) -> a * b);
// Result: 120
// Max
Optional<Integer> max = numbers.stream()
.reduce(Integer::max);
// Concatenate strings
List<String> words = List.of("Java", "Streams", "Are", "Powerful");
String sentence = words.stream()
.reduce("", (a, b) -> a + " " + b)
.trim();
// Result: "Java Streams Are Powerful"
Find and Match
Search operations:
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
// Find first
Optional<Integer> first = numbers.stream()
.filter(n -> n > 2)
.findFirst();
// Result: Optional[3]
// Find any (useful for parallel streams)
Optional<Integer> any = numbers.stream()
.filter(n -> n > 2)
.findAny();
// Check if any match
boolean hasEven = numbers.stream()
.anyMatch(n -> n % 2 == 0);
// Result: true
// Check if all match
boolean allPositive = numbers.stream()
.allMatch(n -> n > 0);
// Result: true
// Check if none match
boolean noneNegative = numbers.stream()
.noneMatch(n -> n < 0);
// Result: true
Count, Min, Max
Aggregation operations:
List<Integer> numbers = List.of(3, 1, 4, 1, 5, 9, 2, 6);
// Count elements
long count = numbers.stream()
.filter(n -> n > 3)
.count();
// Result: 4
// Find minimum
Optional<Integer> min = numbers.stream()
.min(Integer::compareTo);
// Result: Optional[1]
// Find maximum
Optional<Integer> max = numbers.stream()
.max(Integer::compareTo);
// Result: Optional[9]
Real-World Examples
Processing Business Objects
record Employee(String name, String department, double salary, int age) {}
List<Employee> employees = List.of(
new Employee("Alice", "Engineering", 95000, 30),
new Employee("Bob", "Sales", 75000, 35),
new Employee("Charlie", "Engineering", 85000, 28),
new Employee("David", "HR", 65000, 42),
new Employee("Eve", "Sales", 80000, 29)
);
// Average salary by department
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.averagingDouble(Employee::salary)
));
// Highest paid employee per department
Map<String, Optional<Employee>> topEarners = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.maxBy(Comparator.comparingDouble(Employee::salary))
));
// Total salary by department
Map<String, Double> totalSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.summingDouble(Employee::salary)
));
// Employees by age group
Map<String, List<Employee>> byAgeGroup = employees.stream()
.collect(Collectors.groupingBy(emp ->
emp.age() < 30 ? "Young" : emp.age() < 40 ? "Mid" : "Senior"
));
// Names of top 3 earners
List<String> topThree = employees.stream()
.sorted(Comparator.comparingDouble(Employee::salary).reversed())
.limit(3)
.map(Employee::name)
.collect(Collectors.toList());
Data Transformation Pipeline
record Order(String customerId, List<Item> items, LocalDate date) {}
record Item(String productId, int quantity, double price) {}
List<Order> orders = // ... load orders
// Calculate total revenue per customer
Map<String, Double> revenueByCustomer = orders.stream()
.collect(Collectors.groupingBy(
Order::customerId,
Collectors.summingDouble(order ->
order.items().stream()
.mapToDouble(item -> item.quantity() * item.price())
.sum()
)
));
// Find most popular products
Map<String, Long> productPopularity = orders.stream()
.flatMap(order -> order.items().stream())
.collect(Collectors.groupingBy(
Item::productId,
Collectors.counting()
));
// Orders in last 30 days
LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
List<Order> recentOrders = orders.stream()
.filter(order -> order.date().isAfter(thirtyDaysAgo))
.collect(Collectors.toList());
// Average order value by month
Map<YearMonth, Double> avgOrderByMonth = orders.stream()
.collect(Collectors.groupingBy(
order -> YearMonth.from(order.date()),
Collectors.averagingDouble(order ->
order.items().stream()
.mapToDouble(item -> item.quantity() * item.price())
.sum()
)
));
String Processing
List<String> logs = List.of(
"ERROR: Connection failed",
"INFO: Application started",
"ERROR: Null pointer exception",
"WARN: Deprecated API used",
"INFO: Request processed"
);
// Extract error messages
List<String> errors = logs.stream()
.filter(log -> log.startsWith("ERROR"))
.map(log -> log.substring(7))
.collect(Collectors.toList());
// Count by log level
Map<String, Long> countByLevel = logs.stream()
.map(log -> log.split(":")[0])
.collect(Collectors.groupingBy(
level -> level,
Collectors.counting()
));
// Parse CSV data
String csv = """
Alice,30,Engineer
Bob,35,Manager
Charlie,28,Developer
""";
record Person(String name, int age, String role) {}
List<Person> people = csv.lines()
.map(line -> line.split(","))
.map(parts -> new Person(
parts[0],
Integer.parseInt(parts[1]),
parts[2]
))
.collect(Collectors.toList());
Advanced Techniques
Custom Collectors
Create reusable collection logic:
// Custom collector for immutable list
public static <T> Collector<T, ?, List<T>> toImmutableList() {
return Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList
);
}
List<String> immutable = Stream.of("a", "b", "c")
.collect(toImmutableList());
// Custom collector for statistics
record Stats(long count, double sum, double avg, double min, double max) {}
public static Collector<Double, ?, Stats> toStats() {
return Collector.of(
DoubleSummaryStatistics::new,
DoubleSummaryStatistics::accept,
(s1, s2) -> {
s1.combine(s2);
return s1;
},
stats -> new Stats(
stats.getCount(),
stats.getSum(),
stats.getAverage(),
stats.getMin(),
stats.getMax()
)
);
}
Parallel Streams
Use multiple cores for processing:
List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
.boxed()
.collect(Collectors.toList());
// Sequential
long start = System.currentTimeMillis();
long sum = numbers.stream()
.mapToLong(Integer::longValue)
.sum();
long sequential = System.currentTimeMillis() - start;
// Parallel
start = System.currentTimeMillis();
sum = numbers.parallelStream()
.mapToLong(Integer::longValue)
.sum();
long parallel = System.currentTimeMillis() - start;
System.out.println("Sequential: " + sequential + "ms");
System.out.println("Parallel: " + parallel + "ms");
| Parallel streams have overhead. Only use for CPU-intensive operations on large datasets. Not suitable for I/O operations. |
Teeing Collector (Java 12+)
Apply two collectors simultaneously:
record MinMax(Integer min, Integer max) {}
List<Integer> numbers = List.of(3, 1, 4, 1, 5, 9, 2, 6);
MinMax result = numbers.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compareTo),
Collectors.maxBy(Integer::compareTo),
(min, max) -> new MinMax(
min.orElse(null),
max.orElse(null)
)
));
Partitioning
Split into two groups based on a predicate:
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Map<Boolean, List<Integer>> partitioned = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
List<Integer> evens = partitioned.get(true);
List<Integer> odds = partitioned.get(false);
Performance Tips
Use Primitive Streams
Avoid boxing overhead:
// Slow - boxing overhead
int sum = IntStream.range(0, 1_000_000)
.boxed()
.mapToInt(Integer::intValue)
.sum();
// Fast - stay primitive
int sum = IntStream.range(0, 1_000_000)
.sum();
Short-Circuit Operations
Use operations that can stop early:
// Stops at first match
boolean hasLargeNumber = Stream.iterate(1, n -> n + 1)
.anyMatch(n -> n > 1000);
// First 10 primes
List<Integer> primes = Stream.iterate(2, n -> n + 1)
.filter(StreamExamples::isPrime)
.limit(10)
.collect(Collectors.toList());
Common Pitfalls
Modifying Source During Stream
// BAD - modifies list while streaming
List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));
names.stream()
.forEach(name -> {
if (name.startsWith("A")) {
names.remove(name); // ConcurrentModificationException!
}
});
// GOOD - collect then modify
List<String> toRemove = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
names.removeAll(toRemove);
Reusing Streams
// BAD - stream already operated upon
Stream<String> stream = names.stream();
stream.forEach(System.out::println);
stream.forEach(System.out::println); // IllegalStateException!
// GOOD - create new stream
names.stream().forEach(System.out::println);
names.stream().forEach(System.out::println);
Side Effects in Stream Operations
// BAD - side effects in filter
List<String> processed = new ArrayList<>();
names.stream()
.filter(name -> {
processed.add(name); // Side effect!
return name.length() > 3;
})
.collect(Collectors.toList());
// GOOD - use peek for side effects
names.stream()
.peek(processed::add)
.filter(name -> name.length() > 3)
.collect(Collectors.toList());
Conclusion
Java Streams provide a powerful, expressive way to process collections. Key takeaways:
-
Use streams for declarative data transformations
-
Chain operations to build complex pipelines
-
Leverage collectors for flexible result aggregation
-
Consider parallel streams for CPU-intensive tasks
-
Watch out for side effects and stream reuse
Master streams and your Java code will become more readable, maintainable, and often more performant.
About the Author
Wei Li is a passionate software developer and technical writer specializing in Java and Spring Framework. With years of experience in building enterprise applications, Wei Li enjoys sharing knowledge through detailed tutorials and practical guides. For questions or feedback, feel free to reach out at weili.mail@gmail.com. |