Spring Boot 6 min read

Java Streams API: From Basics to Advanced Patterns

Master Java Streams for clean, efficient data processing. Map, filter, reduce, collectors, parallel streams, and real-world patterns.

MR

Moshiour Rahman

Advertisement

The Problem: Loops Are Cluttering Your Code

You need to filter active users, map to DTOs, and collect to a list:

// Before Streams - imperative, verbose
List<UserDto> activeUsers = new ArrayList<>();
for (User user : users) {
    if (user.isActive()) {
        UserDto dto = new UserDto(user.getId(), user.getName());
        activeUsers.add(dto);
    }
}
// With Streams - declarative, clean
List<UserDto> activeUsers = users.stream()
    .filter(User::isActive)
    .map(u -> new UserDto(u.getId(), u.getName()))
    .toList();

Streams make data transformations readable and maintainable.

Quick Answer (TL;DR)

// Filter + Map + Collect
List<String> names = users.stream()
    .filter(u -> u.getAge() > 18)
    .map(User::getName)
    .toList();

// Group by
Map<Department, List<User>> byDept = users.stream()
    .collect(Collectors.groupingBy(User::getDepartment));

// Reduce
int totalAge = users.stream()
    .mapToInt(User::getAge)
    .sum();

// Find
Optional<User> admin = users.stream()
    .filter(u -> u.getRole() == Role.ADMIN)
    .findFirst();

Stream Pipeline

Every stream follows the same three-stage pattern: source, intermediate operations, and a terminal operation that triggers execution.

Java Stream Pipeline

Key insight: Streams are lazy - intermediate operations like filter() and map() don’t execute until a terminal operation (collect(), forEach()) triggers the pipeline.


Core Operations

filter() - Keep Matching Elements

// Keep active users over 18
List<User> result = users.stream()
    .filter(User::isActive)
    .filter(u -> u.getAge() >= 18)
    .toList();

// Multiple conditions in one filter
List<User> result = users.stream()
    .filter(u -> u.isActive() && u.getAge() >= 18)
    .toList();

map() - Transform Elements

// Extract names
List<String> names = users.stream()
    .map(User::getName)
    .toList();

// Transform to DTO
List<UserDto> dtos = users.stream()
    .map(u -> new UserDto(u.getId(), u.getName(), u.getEmail()))
    .toList();

// Chain maps
List<String> upperNames = users.stream()
    .map(User::getName)
    .map(String::toUpperCase)
    .toList();

flatMap() - Flatten Nested Collections

// Users have List<Order> orders
// Get all orders from all users
List<Order> allOrders = users.stream()
    .flatMap(u -> u.getOrders().stream())
    .toList();

// Get all unique products ordered
Set<String> products = users.stream()
    .flatMap(u -> u.getOrders().stream())
    .flatMap(o -> o.getItems().stream())
    .map(Item::getProductId)
    .collect(Collectors.toSet());

sorted() - Order Elements

// Natural order
List<String> sorted = names.stream()
    .sorted()
    .toList();

// Custom comparator
List<User> byAge = users.stream()
    .sorted(Comparator.comparingInt(User::getAge))
    .toList();

// Multiple fields
List<User> sorted = users.stream()
    .sorted(Comparator
        .comparing(User::getDepartment)
        .thenComparing(User::getName)
        .thenComparingInt(User::getAge).reversed())
    .toList();

distinct() - Remove Duplicates

// Unique names
List<String> uniqueNames = users.stream()
    .map(User::getDepartment)
    .distinct()
    .toList();

limit() and skip() - Pagination

// First 10
List<User> firstPage = users.stream()
    .limit(10)
    .toList();

// Skip first 10, take next 10 (page 2)
List<User> secondPage = users.stream()
    .skip(10)
    .limit(10)
    .toList();

Collectors - Gathering Results

toList(), toSet(), toMap()

List<String> list = stream.collect(Collectors.toList());
List<String> list = stream.toList();  // Java 16+ immutable

Set<String> set = stream.collect(Collectors.toSet());

Map<Long, User> byId = users.stream()
    .collect(Collectors.toMap(User::getId, Function.identity()));

// Handle duplicate keys
Map<String, User> byEmail = users.stream()
    .collect(Collectors.toMap(
        User::getEmail,
        Function.identity(),
        (existing, replacement) -> existing  // Keep first
    ));

groupingBy() - Group Elements

// Group by department
Map<Department, List<User>> byDept = users.stream()
    .collect(Collectors.groupingBy(User::getDepartment));

// Group and count
Map<Department, Long> countByDept = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment,
        Collectors.counting()
    ));

// Group and sum
Map<Department, Integer> salaryByDept = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment,
        Collectors.summingInt(User::getSalary)
    ));

// Group and map values
Map<Department, List<String>> namesByDept = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment,
        Collectors.mapping(User::getName, Collectors.toList())
    ));

// Nested grouping
Map<Department, Map<Role, List<User>>> nested = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment,
        Collectors.groupingBy(User::getRole)
    ));

partitioningBy() - Split into Two Groups

// Active vs inactive
Map<Boolean, List<User>> partitioned = users.stream()
    .collect(Collectors.partitioningBy(User::isActive));

List<User> active = partitioned.get(true);
List<User> inactive = partitioned.get(false);

joining() - Concatenate Strings

String names = users.stream()
    .map(User::getName)
    .collect(Collectors.joining(", "));
// "Alice, Bob, Charlie"

String csv = users.stream()
    .map(User::getName)
    .collect(Collectors.joining(",", "[", "]"));
// "[Alice,Bob,Charlie]"

Statistics

IntSummaryStatistics stats = users.stream()
    .collect(Collectors.summarizingInt(User::getAge));

stats.getCount();    // 100
stats.getSum();      // 3500
stats.getMin();      // 18
stats.getMax();      // 65
stats.getAverage(); // 35.0

Terminal Operations

reduce() - Combine Elements

// Sum
int total = numbers.stream()
    .reduce(0, Integer::sum);

// Max
Optional<Integer> max = numbers.stream()
    .reduce(Integer::max);

// Custom reduction
String combined = strings.stream()
    .reduce("", (a, b) -> a + b);

findFirst() and findAny()

// First matching
Optional<User> firstAdmin = users.stream()
    .filter(u -> u.getRole() == Role.ADMIN)
    .findFirst();

// Any matching (better for parallel)
Optional<User> anyAdmin = users.parallelStream()
    .filter(u -> u.getRole() == Role.ADMIN)
    .findAny();

anyMatch(), allMatch(), noneMatch()

boolean hasAdmin = users.stream()
    .anyMatch(u -> u.getRole() == Role.ADMIN);

boolean allActive = users.stream()
    .allMatch(User::isActive);

boolean noMinors = users.stream()
    .noneMatch(u -> u.getAge() < 18);

count(), min(), max()

long count = users.stream()
    .filter(User::isActive)
    .count();

Optional<User> oldest = users.stream()
    .max(Comparator.comparingInt(User::getAge));

Optional<User> youngest = users.stream()
    .min(Comparator.comparingInt(User::getAge));

Primitive Streams

// IntStream, LongStream, DoubleStream - avoid boxing
int sum = users.stream()
    .mapToInt(User::getAge)
    .sum();

double avg = users.stream()
    .mapToInt(User::getSalary)
    .average()
    .orElse(0);

// Range
IntStream.range(0, 10).forEach(System.out::println);      // 0-9
IntStream.rangeClosed(1, 10).forEach(System.out::println); // 1-10

// Generate
IntStream.generate(() -> random.nextInt(100))
    .limit(10)
    .toArray();

Real-World Patterns

DTO Transformation

public List<OrderSummaryDto> getOrderSummaries(Long userId) {
    return orderRepository.findByUserId(userId).stream()
        .filter(o -> o.getStatus() != OrderStatus.CANCELLED)
        .sorted(Comparator.comparing(Order::getCreatedAt).reversed())
        .map(o -> new OrderSummaryDto(
            o.getId(),
            o.getTotalAmount(),
            o.getStatus(),
            o.getCreatedAt()
        ))
        .toList();
}

Aggregation Report

public DepartmentReport generateReport() {
    Map<Department, DoubleSummaryStatistics> salaryStats = employees.stream()
        .collect(Collectors.groupingBy(
            Employee::getDepartment,
            Collectors.summarizingDouble(Employee::getSalary)
        ));

    return salaryStats.entrySet().stream()
        .map(e -> new DeptStats(
            e.getKey(),
            e.getValue().getCount(),
            e.getValue().getAverage(),
            e.getValue().getSum()
        ))
        .sorted(Comparator.comparing(DeptStats::totalSalary).reversed())
        .toList();
}

Null-Safe Processing

// Filter nulls
List<String> names = users.stream()
    .map(User::getName)
    .filter(Objects::nonNull)
    .toList();

// Optional chain
Optional<String> managerName = Optional.ofNullable(employee)
    .map(Employee::getDepartment)
    .map(Department::getManager)
    .map(Manager::getName);

Batching

public <T> List<List<T>> batch(List<T> items, int batchSize) {
    return IntStream.range(0, (items.size() + batchSize - 1) / batchSize)
        .mapToObj(i -> items.subList(
            i * batchSize,
            Math.min((i + 1) * batchSize, items.size())
        ))
        .toList();
}

Parallel Streams

// Parallel processing
long count = users.parallelStream()
    .filter(this::expensiveCheck)
    .count();

// When to use parallel:
// ✓ Large datasets (>10,000 elements)
// ✓ CPU-intensive operations
// ✓ No shared mutable state
// ✗ Small datasets (overhead > benefit)
// ✗ I/O operations (use virtual threads instead)
// ✗ Order matters (unless you sort after)

Common Pitfalls

PitfallProblemFix
Reusing streamsIllegalStateExceptionCreate new stream
Side effects in mapUnpredictableUse forEach for side effects
Null in streamNullPointerExceptionFilter nulls first
Parallel + orderResults scrambledUse sequential or sort after
Infinite streamsOOMAlways use limit()
// BAD - reusing stream
var stream = users.stream();
stream.filter(User::isActive).count();
stream.map(User::getName).toList();  // IllegalStateException!

// BAD - side effect in map
List<User> processed = new ArrayList<>();
users.stream()
    .map(u -> { processed.add(u); return u; })  // Don't!
    .toList();

// GOOD - use forEach for side effects
users.stream()
    .filter(User::isActive)
    .forEach(processed::add);

Code Repository

Complete stream examples:

GitHub: techyowls/techyowls-io-blog-public/java-streams-guide


Further Reading


Write clean data transformations. Follow TechyOwls for more practical guides.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.