Effective Java: Using Streams & Lambdas for Cleaner, More Expressive Code
Description: Java's Streams API combined with lambda expressions lets you express collection-processing logic in a declarative, concise way. Below are practical patterns and examples to help you adopt functional-style programming in day-to-day Java.
1. Quick refresher: Lambda & Method Reference
Lambda syntax:
// Lambda
Runnable r = () -> System.out.println("Hello");
// Method reference
Runnable r2 = System.out::println;
2. Common Stream Operations
Filter, map, and collect are the most frequently used operations:
import java.util.*;
import java.util.stream.*;
List names = List.of("Alice", "Bob", "Charlie", "David");
// Get uppercase names starting with 'A' or 'D'
List result = names.stream()
.filter(s -> s.length() > 3)
.map(String::toUpperCase)
.filter(s -> s.startsWith("A") || s.startsWith("D"))
.collect(Collectors.toList());
System.out.println(result); // [ALICE, DAVID]
3. Transforming to Maps & Grouping
Use collectors to build maps or group elements:
List people = List.of(
new Person("Alice", "Engineering"),
new Person("Bob", "HR"),
new Person("Charlie", "Engineering")
);
// Group by department
Map> byDept = people.stream()
.collect(Collectors.groupingBy(Person::getDepartment));
// Count per department
Map counts = people.stream()
.collect(Collectors.groupingBy(Person::getDepartment, Collectors.counting()));
4. Sorting with Streams
List sorted = people.stream()
.sorted(Comparator.comparing(Person::getName))
.collect(Collectors.toList());
5. Parallel Streams — When to Use (and When Not To)
Parallel streams can speed up CPU-bound, large-data workloads, but they have overhead and can cause surprising behavior with shared mutable state.
// Use for large, independent tasks
long count = IntStream.range(0, 1_000_000)
.parallel()
.filter(i -> isPrime(i))
.count();
Avoid parallel streams when:
- Operations are I/O bound
- Order matters and you rely on stable iteration
- You use non-thread-safe shared state
6. Practical Example: Processing CSV Rows
Process lines, filter, map to objects, then summarize:
Path path = Paths.get("data.csv");
try (Stream lines = Files.lines(path)) {
List records = lines.skip(1) // skip header
.map(line -> line.split(","))
.filter(cols -> cols.length >= 3)
.map(cols -> new Record(cols[0], Integer.parseInt(cols[1]), cols[2]))
.collect(Collectors.toList());
double avg = records.stream()
.mapToInt(Record::getValue)
.average()
.orElse(0.0);
System.out.println("Average: " + avg);
} catch (IOException e) {
e.printStackTrace();
}
7. Tips & Best Practices
- Prefer immutability: avoid modifying elements inside stream operations.
- Keep lambdas short: if logic gets complex, extract a method with a clear name.
- Use method references (`Class::method`) for readability when possible.
- Avoid side-effects — streams are more predictable when pure functions are used.
- Benchmark parallel streams before adopting them — they’re not always faster.
8. Small Utilities
// Safe parse helper for streams
public static OptionalInt safeParseInt(String s) {
try {
return OptionalInt.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return OptionalInt.empty();
}
}
// Usage in stream
List ints = Stream.of("1","x","3")
.map(JavaArticle::safeParseInt)
.flatMapToInt(opt -> opt.isPresent() ? IntStream.of(opt.getAsInt()) : IntStream.empty())
.boxed()
.collect(Collectors.toList());
Start small: convert a few loops to streams in a safe, test-backed way. You’ll get cleaner code and a better mental model for collection processing.