Back to blog

Deep Dive: Java Streams and Functional Programming

javastreamsfunctional-programminglambdabackend
Deep Dive: Java Streams and Functional Programming

Java Streams and functional programming transformed how we write Java code. Introduced in Java 8 and enhanced in modern versions, the Streams API enables declarative, concise, and parallelizable data processing. This deep dive covers everything from lambda basics to advanced stream patterns.

Why Functional Programming in Java?

Before Java 8 (Imperative Style):

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
List<String> filtered = new ArrayList<>();
for (String name : names) {
    if (name.length() > 3) {
        filtered.add(name.toUpperCase());
    }
}
Collections.sort(filtered);

After Java 8 (Functional Style):

List<String> filtered = names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .sorted()
    .toList(); // Java 16+

Benefits:

  • Declarative: Express what you want, not how to do it
  • Composable: Chain operations naturally
  • Parallelizable: Easy parallel processing with .parallel()
  • Lazy Evaluation: Operations execute only when needed

Lambda Expressions

Lambda expressions are anonymous functions that implement functional interfaces.

Syntax

// Full syntax
(parameters) -> { statements; return value; }
 
// Simplified
(x, y) -> x + y                    // Multiple parameters
x -> x * 2                         // Single parameter (no parens)
() -> System.out.println("Hi")     // No parameters
x -> { return x * 2; }             // Explicit return (multi-statement)

Practical Examples

// Comparator
List<String> names = List.of("Charlie", "Alice", "Bob");
names.sort((a, b) -> a.compareTo(b));
// Or simpler:
names.sort(String::compareTo);
 
// Runnable
Thread thread = new Thread(() -> {
    System.out.println("Running in thread: " + Thread.currentThread().getName());
});
 
// Custom functional interface
@FunctionalInterface
interface MathOperation {
    int operate(int a, int b);
}
 
MathOperation add = (a, b) -> a + b;
MathOperation multiply = (a, b) -> a * b;
System.out.println(add.operate(5, 3));      // 8
System.out.println(multiply.operate(5, 3));  // 15

Method References

Method references (::) are shorthand for lambdas that call a single method.

Types of Method References

1. Static Method Reference

// Lambda:  x -> Math.sqrt(x)
// Method ref: Math::sqrt
List<Double> numbers = List.of(4.0, 9.0, 16.0);
List<Double> roots = numbers.stream()
    .map(Math::sqrt)
    .toList();

2. Instance Method Reference (on specific object)

String prefix = "Hello, ";
// Lambda:  name -> prefix.concat(name)
// Method ref: prefix::concat
List<String> greetings = names.stream()
    .map(prefix::concat)
    .toList();

3. Instance Method Reference (on arbitrary object)

// Lambda:  str -> str.toLowerCase()
// Method ref: String::toLowerCase
List<String> lower = names.stream()
    .map(String::toLowerCase)
    .toList();

4. Constructor Reference

// Lambda:  len -> new String[len]
// Method ref: String[]::new
String[] array = names.stream()
    .toArray(String[]::new);
 
// Object construction
record Person(String name) {}
List<Person> people = names.stream()
    .map(Person::new)
    .toList();

Built-in Functional Interfaces

Java provides standard functional interfaces in java.util.function.

Core Interfaces

import java.util.function.*;
 
// Predicate<T>: T -> boolean (test condition)
Predicate<String> isLong = s -> s.length() > 5;
System.out.println(isLong.test("Hello"));  // false
 
// Function<T, R>: T -> R (transform)
Function<String, Integer> length = String::length;
System.out.println(length.apply("Hello"));  // 5
 
// Consumer<T>: T -> void (side effect)
Consumer<String> printer = System.out::println;
printer.accept("Hello");
 
// Supplier<T>: () -> T (produce value)
Supplier<Double> random = Math::random;
System.out.println(random.get());
 
// BiFunction<T, U, R>: (T, U) -> R
BiFunction<Integer, Integer, Integer> max = Math::max;
System.out.println(max.apply(5, 3));  // 5
 
// UnaryOperator<T>: T -> T (specialized Function)
UnaryOperator<String> toUpper = String::toUpperCase;
 
// BinaryOperator<T>: (T, T) -> T (specialized BiFunction)
BinaryOperator<Integer> sum = Integer::sum;

Composition

// Predicate composition
Predicate<String> isLong = s -> s.length() > 5;
Predicate<String> startsWithA = s -> s.startsWith("A");
Predicate<String> combined = isLong.and(startsWithA);
 
// Function composition
Function<Integer, Integer> multiplyBy2 = x -> x * 2;
Function<Integer, Integer> add3 = x -> x + 3;
Function<Integer, Integer> composed = multiplyBy2.andThen(add3);
System.out.println(composed.apply(5));  // (5 * 2) + 3 = 13

Creating Streams

From Collections

List<String> list = List.of("a", "b", "c");
Stream<String> stream1 = list.stream();
Stream<String> parallelStream = list.parallelStream();

From Arrays

String[] array = {"a", "b", "c"};
Stream<String> stream2 = Arrays.stream(array);
Stream<String> partial = Arrays.stream(array, 1, 3); // indices 1-2

From Values

Stream<String> stream3 = Stream.of("a", "b", "c");
Stream<String> empty = Stream.empty();

From Generators

// Infinite streams (use with limit!)
Stream<Double> randoms = Stream.generate(Math::random);
Stream<Integer> naturals = Stream.iterate(0, n -> n + 1);
 
// Java 9+: iterate with condition
Stream<Integer> finite = Stream.iterate(0, n -> n < 10, n -> n + 1);

From Files

try (Stream<String> lines = Files.lines(Paths.get("data.txt"))) {
    lines.filter(line -> !line.isEmpty())
         .forEach(System.out::println);
}

From Other Sources

// IntStream, LongStream, DoubleStream
IntStream.range(1, 5);          // 1, 2, 3, 4
IntStream.rangeClosed(1, 5);    // 1, 2, 3, 4, 5
 
// From String
"Hello".chars()  // IntStream of char codes
    .mapToObj(c -> (char) c)
    .forEach(System.out::print);

Intermediate Operations

Intermediate operations are lazy and return a new stream. They execute only when a terminal operation is called.

filter

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8);
 
// Keep only even numbers
List<Integer> evens = numbers.stream()
    .filter(n -> n % 2 == 0)
    .toList();  // [2, 4, 6, 8]

map

record Person(String name, int age) {}
List<Person> people = List.of(
    new Person("Alice", 30),
    new Person("Bob", 25)
);
 
// Extract names
List<String> names = people.stream()
    .map(Person::name)
    .toList();
 
// Transform to uppercase
List<String> upper = names.stream()
    .map(String::toUpperCase)
    .toList();

flatMap

Flattens nested structures (stream of streams → single stream).

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> flat = nested.stream()
    .flatMap(Collection::stream)
    .toList();  // [1, 2, 3, 4, 5, 6, 7, 8, 9]
 
// Real-world: Get all words from lines
List<String> lines = List.of("Hello world", "Java streams");
List<String> words = lines.stream()
    .flatMap(line -> Arrays.stream(line.split(" ")))
    .toList();  // ["Hello", "world", "Java", "streams"]

sorted

// Natural order
List<Integer> sorted = numbers.stream()
    .sorted()
    .toList();
 
// Custom comparator
List<String> byLength = names.stream()
    .sorted(Comparator.comparing(String::length))
    .toList();
 
// Reverse order
List<Integer> reversed = numbers.stream()
    .sorted(Comparator.reverseOrder())
    .toList();
 
// Multiple criteria
people.stream()
    .sorted(Comparator.comparing(Person::age)
                      .thenComparing(Person::name))
    .toList();

distinct

List<Integer> numbers = List.of(1, 2, 2, 3, 3, 3, 4);
List<Integer> unique = numbers.stream()
    .distinct()
    .toList();  // [1, 2, 3, 4]

limit & skip

// First 3 elements
List<Integer> first3 = numbers.stream()
    .limit(3)
    .toList();
 
// Skip first 2, take next 3
List<Integer> page = numbers.stream()
    .skip(2)
    .limit(3)
    .toList();

peek

Useful for debugging (side effects without consuming stream).

List<String> result = names.stream()
    .peek(name -> System.out.println("Before: " + name))
    .map(String::toUpperCase)
    .peek(name -> System.out.println("After: " + name))
    .toList();

Terminal Operations

Terminal operations trigger stream execution and produce a result.

collect

// To List (Java 16+)
List<String> list = stream.toList();
 
// To mutable List
List<String> mutableList = stream.collect(Collectors.toList());
 
// To Set
Set<String> set = stream.collect(Collectors.toSet());
 
// To specific collection
LinkedList<String> linked = stream.collect(Collectors.toCollection(LinkedList::new));

reduce

Combines elements into a single result.

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
 
// Sum
int sum = numbers.stream()
    .reduce(0, Integer::sum);  // 15
 
// Product
int product = numbers.stream()
    .reduce(1, (a, b) -> a * b);  // 120
 
// Max
Optional<Integer> max = numbers.stream()
    .reduce(Integer::max);
 
// String concatenation
String joined = List.of("a", "b", "c").stream()
    .reduce("", (acc, s) -> acc + s);  // "abc"

forEach

names.stream()
    .filter(name -> name.startsWith("A"))
    .forEach(System.out::println);
 
// forEachOrdered (preserves order in parallel streams)
names.parallelStream()
    .forEachOrdered(System.out::println);

count

long count = names.stream()
    .filter(name -> name.length() > 5)
    .count();

min / max

Optional<Integer> min = numbers.stream()
    .min(Integer::compareTo);
 
Optional<Person> youngest = people.stream()
    .min(Comparator.comparing(Person::age));

anyMatch / allMatch / noneMatch

boolean hasLong = names.stream()
    .anyMatch(name -> name.length() > 10);
 
boolean allLong = names.stream()
    .allMatch(name -> name.length() > 2);
 
boolean noneEmpty = names.stream()
    .noneMatch(String::isEmpty);

findFirst / findAny

Optional<String> first = names.stream()
    .filter(name -> name.startsWith("A"))
    .findFirst();
 
// findAny (faster for parallel streams)
Optional<String> any = names.parallelStream()
    .filter(name -> name.startsWith("A"))
    .findAny();

Advanced Collectors

Collectors provide powerful data aggregation.

Joining Strings

String csv = names.stream()
    .collect(Collectors.joining(", "));
// "Alice, Bob, Charlie"
 
String quoted = names.stream()
    .collect(Collectors.joining(", ", "[", "]"));
// "[Alice, Bob, Charlie]"

Grouping

record Person(String name, String city, int age) {}
List<Person> people = List.of(
    new Person("Alice", "NYC", 30),
    new Person("Bob", "NYC", 25),
    new Person("Charlie", "LA", 35)
);
 
// Group by city
Map<String, List<Person>> byCity = people.stream()
    .collect(Collectors.groupingBy(Person::city));
// {NYC=[Alice, Bob], LA=[Charlie]}
 
// Count by city
Map<String, Long> countByCity = people.stream()
    .collect(Collectors.groupingBy(
        Person::city,
        Collectors.counting()
    ));
// {NYC=2, LA=1}
 
// Average age by city
Map<String, Double> avgAgeByCity = people.stream()
    .collect(Collectors.groupingBy(
        Person::city,
        Collectors.averagingInt(Person::age)
    ));

Partitioning

// Split into two groups based on predicate
Map<Boolean, List<Person>> partition = people.stream()
    .collect(Collectors.partitioningBy(p -> p.age() >= 30));
// {false=[Bob], true=[Alice, Charlie]}

Custom Collector

// Collect to Map
Map<String, Integer> nameToAge = people.stream()
    .collect(Collectors.toMap(
        Person::name,
        Person::age
    ));
 
// Handle duplicate keys
Map<String, Person> byName = people.stream()
    .collect(Collectors.toMap(
        Person::name,
        p -> p,
        (existing, replacement) -> existing  // Keep first
    ));
 
// Use specific Map implementation
TreeMap<String, Integer> sorted = people.stream()
    .collect(Collectors.toMap(
        Person::name,
        Person::age,
        (a, b) -> a,
        TreeMap::new
    ));

Statistics

IntSummaryStatistics stats = people.stream()
    .collect(Collectors.summarizingInt(Person::age));
 
System.out.println("Count: " + stats.getCount());
System.out.println("Sum: " + stats.getSum());
System.out.println("Min: " + stats.getMin());
System.out.println("Max: " + stats.getMax());
System.out.println("Avg: " + stats.getAverage());

Parallel Streams

Parallel streams automatically split work across multiple threads.

When to Use

// Sequential
long count = hugeList.stream()
    .filter(expensivePredicate)
    .count();
 
// Parallel
long count = hugeList.parallelStream()
    .filter(expensivePredicate)
    .count();

Performance Considerations

✅ Good for parallel:

  • Large datasets (10,000+ elements)
  • CPU-intensive operations
  • Independent operations (no shared state)
  • Operations that take time (file I/O, computations)

❌ Not good for parallel:

  • Small datasets (overhead > benefit)
  • Already fast operations
  • Operations with side effects on shared state
  • Ordered operations (limit, skip, findFirst)

Example: Parallel vs Sequential

List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
    .boxed()
    .toList();
 
// Sequential
long start = System.currentTimeMillis();
int sum = numbers.stream()
    .mapToInt(n -> n * 2)
    .sum();
System.out.println("Sequential: " + (System.currentTimeMillis() - start) + "ms");
 
// Parallel
start = System.currentTimeMillis();
sum = numbers.parallelStream()
    .mapToInt(n -> n * 2)
    .sum();
System.out.println("Parallel: " + (System.currentTimeMillis() - start) + "ms");

Avoiding Race Conditions

// ❌ BAD: Shared mutable state
List<Integer> results = new ArrayList<>();
numbers.parallelStream()
    .forEach(results::add);  // Race condition!
 
// ✅ GOOD: Use collector
List<Integer> results = numbers.parallelStream()
    .collect(Collectors.toList());

Optional for Null Safety

Optional<T> represents a value that may or may not be present.

Creating Optional

Optional<String> full = Optional.of("Hello");
Optional<String> nullable = Optional.ofNullable(null);
Optional<String> empty = Optional.empty();

Consuming Optional

// Check if present
if (optional.isPresent()) {
    String value = optional.get();
}
 
// ifPresent (better)
optional.ifPresent(value -> System.out.println(value));
 
// ifPresentOrElse (Java 9+)
optional.ifPresentOrElse(
    value -> System.out.println("Found: " + value),
    () -> System.out.println("Not found")
);

Default Values

// orElse: Always evaluated
String value = optional.orElse("default");
 
// orElseGet: Evaluated only if empty (preferred)
String value = optional.orElseGet(() -> expensiveDefault());
 
// orElseThrow
String value = optional.orElseThrow();
String value = optional.orElseThrow(() -> new IllegalStateException("Missing value"));

Transforming Optional

Optional<String> name = Optional.of("Alice");
 
// map
Optional<Integer> length = name.map(String::length);
 
// flatMap (when mapper returns Optional)
Optional<String> upper = name.flatMap(n -> 
    n.isEmpty() ? Optional.empty() : Optional.of(n.toUpperCase())
);
 
// filter
Optional<String> longName = name.filter(n -> n.length() > 5);

Real-World Example

record User(String id, Optional<String> email) {}
 
// Find user, get email, or use default
String email = userRepository.findById("123")
    .flatMap(User::email)
    .filter(e -> e.contains("@"))
    .orElse("no-reply@example.com");

Practical Examples

Example 1: Data Processing Pipeline

record Transaction(String id, String category, double amount, LocalDate date) {}
 
List<Transaction> transactions = getTransactions();
 
// Get top 5 categories by total spending in last 30 days
Map<String, Double> topCategories = transactions.stream()
    .filter(t -> t.date().isAfter(LocalDate.now().minusDays(30)))
    .collect(Collectors.groupingBy(
        Transaction::category,
        Collectors.summingDouble(Transaction::amount)
    ))
    .entrySet().stream()
    .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
    .limit(5)
    .collect(Collectors.toMap(
        Map.Entry::getKey,
        Map.Entry::getValue,
        (a, b) -> a,
        LinkedHashMap::new
    ));

Example 2: Nested Object Transformation

record Order(String id, List<OrderItem> items) {}
record OrderItem(String product, int quantity, double price) {}
 
List<Order> orders = getOrders();
 
// Get all unique products ordered in last month
Set<String> products = orders.stream()
    .filter(o -> isLastMonth(o.id()))
    .flatMap(order -> order.items().stream())
    .map(OrderItem::product)
    .collect(Collectors.toSet());
 
// Calculate total revenue per product
Map<String, Double> revenueByProduct = orders.stream()
    .flatMap(order -> order.items().stream())
    .collect(Collectors.groupingBy(
        OrderItem::product,
        Collectors.summingDouble(item -> item.quantity() * item.price())
    ));

Example 3: Complex Filtering & Mapping

record Employee(String name, String department, int salary, int age) {}
 
List<Employee> employees = getEmployees();
 
// Get names of employees in Engineering making > $80k, sorted by salary
List<String> highEarners = employees.stream()
    .filter(e -> "Engineering".equals(e.department()))
    .filter(e -> e.salary() > 80_000)
    .sorted(Comparator.comparing(Employee::salary).reversed())
    .map(Employee::name)
    .toList();
 
// Group by department, calculate average salary, filter departments with avg > $75k
Map<String, Double> highPayingDepts = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.averagingInt(Employee::salary)
    ))
    .entrySet().stream()
    .filter(entry -> entry.getValue() > 75_000)
    .collect(Collectors.toMap(
        Map.Entry::getKey,
        Map.Entry::getValue
    ));

Best Practices

✅ DO Use Streams For

  1. Declarative data processing

    // Clear intent
    List<String> result = names.stream()
        .filter(name -> name.length() > 5)
        .map(String::toUpperCase)
        .sorted()
        .toList();
  2. Chaining multiple operations

    double average = transactions.stream()
        .filter(t -> t.amount() > 100)
        .mapToDouble(Transaction::amount)
        .average()
        .orElse(0.0);
  3. Parallel processing of large datasets

    long count = hugeList.parallelStream()
        .filter(expensivePredicate)
        .count();

❌ DON'T Use Streams For

  1. Simple iteration

    // Bad: Unnecessary complexity
    names.stream().forEach(System.out::println);
     
    // Good: Direct loop
    for (String name : names) {
        System.out.println(name);
    }
  2. Indexed loops

    // Bad: Awkward with streams
    IntStream.range(0, list.size())
        .forEach(i -> process(list.get(i), i));
     
    // Good: Traditional for loop
    for (int i = 0; i < list.size(); i++) {
        process(list.get(i), i);
    }
  3. Checked exceptions

    // Bad: Requires wrapping
    files.stream()
        .map(file -> {
            try {
                return Files.readString(file);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        });
     
    // Good: Traditional try-catch
    for (Path file : files) {
        try {
            String content = Files.readString(file);
            process(content);
        } catch (IOException e) {
            handleError(e);
        }
    }
  4. Early termination with complex conditions

    // Bad: Hard to break early
    Optional<String> result = names.stream()
        .filter(complexCondition)
        .findFirst();
     
    // Good: Break when needed
    String result = null;
    for (String name : names) {
        if (complexCondition(name)) {
            result = name;
            break;
        }
    }

Performance Tips

  1. Avoid boxing/unboxing with primitive streams

    // Bad: Boxing overhead
    List<Integer> numbers = List.of(1, 2, 3, 4, 5);
    int sum = numbers.stream()
        .reduce(0, Integer::sum);
     
    // Good: Primitive stream
    int sum = IntStream.of(1, 2, 3, 4, 5)
        .sum();
  2. Order operations efficiently

    // Bad: Map everything, then filter
    list.stream()
        .map(expensiveTransform)
        .filter(result -> result.isValid())
        .toList();
     
    // Good: Filter first (fewer expensive operations)
    list.stream()
        .filter(item -> cheapCheck(item))
        .map(expensiveTransform)
        .toList();
  3. Use specialized methods

    // Bad: Generic reduce
    long count = stream.reduce(0L, (acc, item) -> acc + 1, Long::sum);
     
    // Good: Built-in count
    long count = stream.count();

Code Clarity

  1. Extract complex lambdas

    // Bad: Hard to read
    employees.stream()
        .filter(e -> e.department().equals("Engineering") && 
                     e.salary() > 80000 && 
                     e.age() < 40)
        .toList();
     
    // Good: Named predicate
    Predicate<Employee> isYoungHighPaidEngineer = e ->
        e.department().equals("Engineering") &&
        e.salary() > 80000 &&
        e.age() < 40;
     
    List<Employee> result = employees.stream()
        .filter(isYoungHighPaidEngineer)
        .toList();
  2. Keep streams short

    // Bad: Too many operations
    result = data.stream().filter(...).map(...).flatMap(...).sorted(...)
        .distinct().skip(...).limit(...).filter(...).map(...).toList();
     
    // Good: Break into logical steps
    Stream<Item> filtered = data.stream()
        .filter(predicate)
        .map(transformer);
     
    Stream<Item> processed = filtered
        .flatMap(nestedMapper)
        .sorted();
     
    List<Item> result = processed
        .distinct()
        .toList();

Connection to Spring Boot

Streams are essential in Spring Boot applications (covered in Phase 3 of the Java Roadmap):

@Service
public class UserService {
    
    // Repository returns Stream for memory efficiency
    @Transactional(readOnly = true)
    public List<UserDTO> getActiveUsers() {
        try (Stream<User> users = userRepository.findAllByActiveTrue()) {
            return users
                .filter(user -> user.getLastLoginDate()
                    .isAfter(LocalDate.now().minusMonths(6)))
                .map(this::convertToDTO)
                .toList();
        }
    }
    
    // Parallel processing for batch operations
    public void sendNotifications(List<User> users) {
        users.parallelStream()
            .forEach(user -> {
                notificationService.send(user);
            });
    }
}

Summary

Java Streams and functional programming enable:

  • Cleaner code: Declarative, readable data processing
  • Less boilerplate: No manual loops and temporary variables
  • Better performance: Easy parallelization for large datasets
  • Composability: Chain operations naturally

Key Takeaways:

  • Master lambda expressions and method references
  • Understand lazy evaluation and stream lifecycle
  • Use collectors for powerful aggregations
  • Leverage Optional for null safety
  • Know when NOT to use streams (simplicity over complexity)
  • Use parallel streams judiciously

Next Steps:

  • Build a data processing pipeline with streams
  • Refactor imperative code to functional style
  • Practice with LeetCode/HackerRank stream problems
  • Explore advanced collectors and custom implementations
  • Apply in Spring Boot REST APIs (Phase 3)

Start with simple transformations and gradually build complex pipelines. Functional programming isn't about using streams everywhere—it's about choosing the right tool for the job.


Part of the Java Learning Roadmap series

Back to: Phase 3: Core Java APIs

Related Deep Dives:

Next Step: Getting Started with Spring Boot

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.