Collections & Streams #2 — The Streams API
Replace imperative loops with expressive pipelines. filter, map, flatMap, reduce, collect, and groupingBy — the operations you'll use every day.
Series
java-basics-to-advanced
What Is a Stream?
A Stream is a pipeline for processing data. It doesn't store data — it processes it lazily, operation by operation.
Source → [intermediate ops] → terminal op
List<String> names = List.of("Alice", "Bob", "Charlie", "Dave", "Eve");
// Imperative (old way)
List<String> result = new ArrayList<>();
for (String name : names) {
if (name.length() > 3) {
result.add(name.toUpperCase());
}
}
// Stream (new way)
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
// [ALICE, CHARLIE, DAVE]
Lambda Expressions
Streams rely on lambdas — anonymous functions you pass as arguments:
// Full syntax
Runnable r = () -> { System.out.println("Hello"); };
// Single expression (no braces or return needed)
Predicate<String> isLong = s -> s.length() > 5;
// Two parameters
Comparator<String> byLength = (a, b) -> a.length() - b.length();
// Method reference — shorthand when you're just calling one method
names.stream().map(String::toUpperCase) // same as s -> s.toUpperCase()
names.stream().forEach(System.out::println) // same as s -> System.out.println(s)
Intermediate Operations (lazy — don't run until terminal op)
filter — keep elements matching a predicate:
stream.filter(n -> n > 0) // only positives
stream.filter(s -> s.startsWith("A")) // only names starting with A
map — transform each element:
stream.map(String::length) // String → Integer (lengths)
stream.map(User::getEmail) // User → String
stream.map(s -> s.trim().toLowerCase())
flatMap — flatten a stream of collections into one stream:
List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3, 4), List.of(5));
List<Integer> flat = nested.stream()
.flatMap(List::stream) // [[1,2],[3,4],[5]] → [1,2,3,4,5]
.collect(Collectors.toList());
sorted — sort elements:
stream.sorted() // natural order
stream.sorted(Comparator.reverseOrder()) // reverse
stream.sorted(Comparator.comparing(User::getName)) // by field
distinct — remove duplicates:
List<Integer> deduped = List.of(1, 2, 2, 3, 3, 3).stream()
.distinct()
.collect(Collectors.toList()); // [1, 2, 3]
limit / skip:
stream.limit(5) // take first 5
stream.skip(10) // skip first 10
peek — for debugging (runs action without modifying stream):
stream
.filter(n -> n > 0)
.peek(n -> System.out.println("after filter: " + n))
.map(n -> n * 2)
.collect(Collectors.toList());
Terminal Operations (trigger execution)
collect — gather results into a collection:
List<String> list = stream.collect(Collectors.toList());
Set<String> set = stream.collect(Collectors.toSet());
String joined = stream.collect(Collectors.joining(", "));
String joined2 = stream.collect(Collectors.joining(", ", "[", "]"));
forEach:
stream.forEach(System.out::println);
count / sum / average / min / max:
long count = stream.count();
int sum = intStream.sum();
OptionalDouble avg = intStream.average();
Optional<String> min = stream.min(Comparator.naturalOrder());
Optional<String> max = stream.max(Comparator.naturalOrder());
reduce — combine all elements into one:
int sum = List.of(1, 2, 3, 4, 5).stream()
.reduce(0, Integer::sum); // 15
Optional<String> longest = names.stream()
.reduce((a, b) -> a.length() >= b.length() ? a : b);
findFirst / findAny:
Optional<String> first = stream.filter(s -> s.startsWith("A")).findFirst();
first.ifPresent(System.out::println);
anyMatch / allMatch / noneMatch:
boolean anyAdult = users.stream().anyMatch(u -> u.getAge() >= 18);
boolean allAdult = users.stream().allMatch(u -> u.getAge() >= 18);
boolean noneUnder = users.stream().noneMatch(u -> u.getAge() < 0);
Collectors.groupingBy — Extremely Useful
List<User> users = getUsers();
// Group by role
Map<String, List<User>> byRole = users.stream()
.collect(Collectors.groupingBy(User::getRole));
// Count by role
Map<String, Long> countByRole = users.stream()
.collect(Collectors.groupingBy(User::getRole, Collectors.counting()));
// Average age by role
Map<String, Double> avgAgeByRole = users.stream()
.collect(Collectors.groupingBy(
User::getRole,
Collectors.averagingInt(User::getAge)
));
Optional — Handle Absence Safely
Optional<User> user = users.stream()
.filter(u -> u.getId() == targetId)
.findFirst();
// Bad — throws NoSuchElementException if empty
User u = user.get();
// Good ways to use Optional
user.ifPresent(u -> System.out.println(u.getName()));
User u = user.orElse(new User("guest"));
User u = user.orElseGet(() -> createDefaultUser());
User u = user.orElseThrow(() -> new NotFoundException("User not found"));
String name = user.map(User::getName).orElse("Anonymous");
Calling .get() on an empty Optional throws NoSuchElementException. Always use orElse, orElseGet, orElseThrow, or ifPresent.
Real-World Example
record Order(String customer, String product, double amount, boolean paid) {}
List<Order> orders = getOrders();
// Total revenue from paid orders, grouped by customer
Map<String, Double> revenueByCustomer = orders.stream()
.filter(Order::paid)
.collect(Collectors.groupingBy(
Order::customer,
Collectors.summingDouble(Order::amount)
));
// Top 3 customers by revenue
revenueByCustomer.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(3)
.forEach(e -> System.out.printf("%s: $%.2f%n", e.getKey(), e.getValue()));
Streams add clarity but don't automatically make your code faster. For simple loops over small collections, a plain for loop is fine. Use streams when they make the intent clearer, not just to look modern.
What's Next?
Spring Boot #1 — we're moving to the framework. We'll bootstrap a REST API from scratch using Spring Initializr and understand how Spring Boot's auto-configuration magic works.
✦ Enjoyed this post?
Get posts like this in your inbox
No spam, just real tutorials when they're ready.
Discussion
Powered by GitHubComments use GitHub Discussions — no separate account needed if you have GitHub.