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());

Avoid Unnecessary Operations

// Bad - redundant sorted()
list.stream()
    .sorted()
    .filter(x -> x > 0)
    .sorted()  // Redundant!
    .collect(Collectors.toList());

// Good
list.stream()
    .filter(x -> x > 0)
    .sorted()
    .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.

Further Reading

About the Author

Wei Li

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.