Modern Java Guide: Streams, Records, Pattern Matching & Virtual Threads

A complete reference to Java Streams combined with modern features like records, pattern matching, and virtual threads.

🌱 Seedling·created: ·category:Java

A complete reference covering Java Streams API with all major use cases, and how it combines with modern Java features (Records, Pattern Matching, Virtual Threads, Sealed Classes) introduced in Java 14–21+.


Table of Contents

  1. Java Streams - Fundamentals
  2. Stream Creation
  3. Intermediate Operations
  4. Terminal Operations
  5. Collectors - Deep Dive
  6. Primitive Streams
  7. Parallel Streams
  8. Records
  9. Pattern Matching
  10. Sealed Classes + Exhaustive Pattern Matching
  11. Virtual Threads
  12. Structured Concurrency
  13. Combining Everything - Real-World Patterns

1. Java Streams - Fundamentals

A Stream represents a sequence of elements supporting functional-style operations. Streams are:

  • Lazy: intermediate operations don’t execute until a terminal operation is invoked.
  • Non-reusable: once consumed, a stream cannot be reused.
  • Not a data structure: it doesn’t store elements, it computes them on demand.
List<String> names = List.of("Ali", "Veli", "Ayşe", "Mert");
long count = names.stream()
                   .filter(n -> n.length() > 3)
                   .count();

2. Stream Creation

// From a collection
List<Integer> list = List.of(1, 2, 3);
Stream<Integer> s1 = list.stream();

// From values
Stream<String> s2 = Stream.of("a", "b", "c");

// Empty stream
Stream<String> s3 = Stream.empty();

// Infinite stream (must be limited)
Stream<Integer> s4 = Stream.iterate(0, n -> n + 2).limit(5); // 0,2,4,6,8

// Java 9+: iterate with predicate (bounded)
Stream<Integer> s5 = Stream.iterate(0, n -> n < 10, n -> n + 2);

// Generate (supplier based, infinite)
Stream<Double> s6 = Stream.generate(Math::random).limit(3);

// From arrays
Integer[] arr = {1, 2, 3};
Stream<Integer> s7 = Arrays.stream(arr);

// From a file (I/O backed stream, must be closed)
try (Stream<String> lines = Files.lines(Path.of("data.txt"))) {
    lines.forEach(System.out::println);
}

// Builder
Stream<String> s8 = Stream.<String>builder().add("x").add("y").build();

3. Intermediate Operations

Intermediate operations return a new stream and are lazily evaluated.

List<String> words = List.of("apple", "banana", "kiwi", "fig", "apple");

// filter
words.stream().filter(w -> w.length() > 3).forEach(System.out::println);

// map
words.stream().map(String::toUpperCase).forEach(System.out::println);

// flatMap - flattening nested structures
List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3, 4));
List<Integer> flat = nested.stream()
                            .flatMap(List::stream)
                            .toList();

// distinct
words.stream().distinct().forEach(System.out::println);

// sorted (natural or custom comparator)
words.stream().sorted().forEach(System.out::println);
words.stream().sorted(Comparator.comparingInt(String::length).reversed())
              .forEach(System.out::println);

// limit / skip
words.stream().skip(1).limit(2).forEach(System.out::println);

// peek - mainly for debugging side effects
words.stream()
     .peek(w -> System.out.println("Processing: " + w))
     .map(String::toUpperCase)
     .toList();

// takeWhile / dropWhile (Java 9+)
List<Integer> nums = List.of(1, 2, 3, 10, 4, 5);
nums.stream().takeWhile(n -> n < 5).forEach(System.out::println); // 1,2,3
nums.stream().dropWhile(n -> n < 5).forEach(System.out::println); // 10,4,5

4. Terminal Operations

Terminal operations trigger the actual processing and produce a result or side-effect.

List<Integer> nums = List.of(1, 2, 3, 4, 5);

// forEach
nums.stream().forEach(System.out::println);

// collect (see section 5)
List<Integer> evens = nums.stream().filter(n -> n % 2 == 0).toList(); // Java 16+ shortcut

// reduce
Optional<Integer> sum = nums.stream().reduce(Integer::sum);
int sumWithIdentity = nums.stream().reduce(0, Integer::sum);
int product = nums.stream().reduce(1, (a, b) -> a * b);

// count
long count = nums.stream().filter(n -> n > 2).count();

// anyMatch / allMatch / noneMatch
boolean anyEven = nums.stream().anyMatch(n -> n % 2 == 0);
boolean allPositive = nums.stream().allMatch(n -> n > 0);
boolean noneNegative = nums.stream().noneMatch(n -> n < 0);

// findFirst / findAny
Optional<Integer> first = nums.stream().filter(n -> n > 3).findFirst();
Optional<Integer> any = nums.stream().findAny(); // useful with parallel streams

// min / max
Optional<Integer> max = nums.stream().max(Integer::compareTo);

// toArray
Integer[] array = nums.stream().toArray(Integer[]::new);

5. Collectors - Deep Dive

record Person(String name, int age, String city) {}

List<Person> people = List.of(
    new Person("Ali", 25, "Istanbul"),
    new Person("Veli", 30, "Ankara"),
    new Person("Ayşe", 25, "Istanbul"),
    new Person("Mert", 35, "Izmir")
);

// toList / toSet / toUnmodifiableList
List<String> names = people.stream().map(Person::name).collect(Collectors.toList());
Set<String> cities = people.stream().map(Person::city).collect(Collectors.toSet());

// toMap
Map<String, Integer> nameToAge = people.stream()
        .collect(Collectors.toMap(Person::name, Person::age));

// toMap with merge function (handling duplicate keys)
Map<String, Integer> cityMaxAge = people.stream()
        .collect(Collectors.toMap(Person::city, Person::age, Integer::max));

// groupingBy - classic grouping
Map<String, List<Person>> byCity = people.stream()
        .collect(Collectors.groupingBy(Person::city));

// groupingBy + downstream collector
Map<String, Long> countByCity = people.stream()
        .collect(Collectors.groupingBy(Person::city, Collectors.counting()));

Map<String, Double> avgAgeByCity = people.stream()
        .collect(Collectors.groupingBy(Person::city, Collectors.averagingInt(Person::age)));

Map<String, List<String>> namesByCity = people.stream()
        .collect(Collectors.groupingBy(Person::city, Collectors.mapping(Person::name, Collectors.toList())));

// partitioningBy - binary split
Map<Boolean, List<Person>> partitioned = people.stream()
        .collect(Collectors.partitioningBy(p -> p.age() >= 30));

// joining
String joinedNames = people.stream().map(Person::name).collect(Collectors.joining(", ", "[", "]"));

// summarizing
IntSummaryStatistics stats = people.stream().collect(Collectors.summarizingInt(Person::age));
System.out.println(stats.getAverage() + " " + stats.getMax() + " " + stats.getMin());

// teeing (Java 12+) - combine two collectors into one result
record MinMax(int min, int max) {}
MinMax minMax = people.stream()
        .collect(Collectors.teeing(
                Collectors.minBy(Comparator.comparingInt(Person::age)),
                Collectors.maxBy(Comparator.comparingInt(Person::age)),
                (min, max) -> new MinMax(min.get().age(), max.get().age())
        ));

// reducing collector
Optional<Integer> totalAge = people.stream()
        .collect(Collectors.reducing((p1, p2) -> null)); // rarely used directly on objects

6. Primitive Streams

Avoid boxing overhead with IntStream, LongStream, DoubleStream.

IntStream.range(1, 5).forEach(System.out::println);      // 1,2,3,4
IntStream.rangeClosed(1, 5).forEach(System.out::println); // 1,2,3,4,5

int sum = IntStream.rangeClosed(1, 100).sum();
OptionalDouble avg = IntStream.of(1, 2, 3, 4).average();
IntSummaryStatistics stats = IntStream.of(1, 5, 3).summaryStatistics();

// Boxing / unboxing conversions
Stream<Integer> boxed = IntStream.range(1, 5).boxed();
IntStream unboxed = boxed.mapToInt(Integer::intValue);

// mapToObj
List<String> labels = IntStream.range(0, 3)
        .mapToObj(i -> "item-" + i)
        .toList();

7. Parallel Streams

List<Integer> bigList = IntStream.rangeClosed(1, 10_000_000).boxed().toList();

long sum = bigList.parallelStream()
                   .mapToLong(Integer::longValue)
                   .sum();

// Explicit conversion
Stream<Integer> parallel = bigList.stream().parallel();
Stream<Integer> sequential = parallel.sequential();

When to use: CPU-bound, large datasets, stateless & associative operations. Avoid for I/O-bound tasks - use Virtual Threads instead (see section 11).


8. Records

Records (Java 16+) are immutable data carriers with auto-generated constructor, accessors, equals(), hashCode(), toString().

// Basic record
record Point(int x, int y) {}

Point p = new Point(3, 4);
System.out.println(p.x() + ", " + p.y()); // accessor: x(), not getX()
System.out.println(p); // Point[x=3, y=4]

// Compact constructor - validation / normalization
record Range(int min, int max) {
    Range {
        if (min > max) throw new IllegalArgumentException("min > max");
    }
}

// Custom methods
record Circle(double radius) {
    double area() {
        return Math.PI * radius * radius;
    }
}

// Static factory methods
record User(String email) {
    static User of(String email) {
        return new User(email.toLowerCase());
    }
}

// Implementing interfaces
interface Shape { double area(); }
record Square(double side) implements Shape {
    public double area() { return side * side; }
}

// Records in Streams - very common combo
record Employee(String name, String dept, double salary) {}

List<Employee> employees = List.of(
    new Employee("Ali", "IT", 15000),
    new Employee("Veli", "HR", 12000)
);

Map<String, Double> totalSalaryByDept = employees.stream()
        .collect(Collectors.groupingBy(Employee::dept, Collectors.summingDouble(Employee::salary)));

9. Pattern Matching

9.1 instanceof Pattern Matching (Java 16+)

Object obj = "Hello";

// Old way
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// Pattern matching way
if (obj instanceof String s) {
    System.out.println(s.length());
}

// With additional condition
if (obj instanceof String s && s.length() > 3) {
    System.out.println("Long string: " + s);
}

9.2 Switch Expressions (Java 14+)

int day = 3;
String name = switch (day) {
    case 1, 7 -> "Weekend";
    case 2, 3, 4, 5, 6 -> "Weekday";
    default -> "Unknown";
};

// yield for block bodies
String result = switch (day) {
    case 1 -> "Monday";
    default -> {
        String computed = "Day-" + day;
        yield computed;
    }
};

9.3 Pattern Matching for switch (Java 21+)

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

static double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t -> 0.5 * t.base() * t.height();
    };
}

9.4 Record Patterns (Java 21+) - Deconstruction

record Point(int x, int y) {}
record Line(Point start, Point end) {}

static String describe(Object obj) {
    return switch (obj) {
        case Point(int x, int y) when x == y -> "Diagonal point (%d,%d)".formatted(x, y);
        case Point(int x, int y) -> "Point (%d,%d)".formatted(x, y);
        case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
                "Line from (%d,%d) to (%d,%d)".formatted(x1, y1, x2, y2);
        case null -> "null value";
        default -> "Unknown";
    };
}

9.5 Guarded Patterns (when clause)

static String categorize(Object obj) {
    return switch (obj) {
        case Integer i when i < 0 -> "Negative integer";
        case Integer i when i == 0 -> "Zero";
        case Integer i -> "Positive integer";
        case String s when s.isBlank() -> "Blank string";
        case String s -> "Non-blank string: " + s;
        default -> "Other";
    };
}

10. Sealed Classes + Exhaustive Pattern Matching

Sealed classes (Java 17+) restrict which classes may implement/extend them, enabling exhaustive switch expressions without a default branch.

sealed interface PaymentMethod permits CreditCard, BankTransfer, Crypto {}
record CreditCard(String number, String cvv) implements PaymentMethod {}
record BankTransfer(String iban) implements PaymentMethod {}
record Crypto(String walletAddress) implements PaymentMethod {}

static String process(PaymentMethod method) {
    // No default needed - compiler verifies exhaustiveness
    return switch (method) {
        case CreditCard cc -> "Charging card ending in " + cc.number().substring(cc.number().length() - 4);
        case BankTransfer bt -> "Transferring via IBAN " + bt.iban();
        case Crypto c -> "Sending crypto to " + c.walletAddress();
    };
}

If a new payment method (e.g., record Wallet(...) implements PaymentMethod) is added, the compiler forces you to update every switch - a huge maintainability win over traditional inheritance.


11. Virtual Threads

Virtual Threads (Java 21+, JEP 444) are lightweight threads managed by the JVM, ideal for I/O-bound, high-concurrency workloads (thousands/millions of concurrent tasks).

// Creating a single virtual thread
Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("Running in: " + Thread.currentThread());
});
vThread.join();

// Virtual thread per task executor - the recommended pattern
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = IntStream.range(0, 10_000)
            .mapToObj(i -> executor.submit(() -> {
                Thread.sleep(Duration.ofMillis(100)); // simulate I/O
                return "Task " + i + " done";
            }))
            .toList();

    futures.forEach(f -> {
        try {
            System.out.println(f.get());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    });
}

// Named virtual threads (useful for debugging/logging)
Thread.Builder builder = Thread.ofVirtual().name("worker-", 0);
Thread t1 = builder.start(() -> System.out.println("Hello from " + Thread.currentThread()));

Key points:

  • Virtual threads are cheap - you can create millions without exhausting memory.
  • They are NOT for CPU-bound work - use parallelStream() or ForkJoinPool for that.
  • Blocking calls (Thread.sleep, blocking I/O, synchronized in some cases) are automatically “unmounted” from the carrier platform thread, freeing it for other virtual threads.
  • Avoid pinning: heavy use of synchronized blocks can pin the virtual thread to its carrier thread - prefer ReentrantLock in high-throughput code paths.

12. Structured Concurrency

Structured Concurrency (Java 21+ preview, StructuredTaskScope) treats a group of related tasks running on virtual threads as a single unit of work.

import java.util.concurrent.StructuredTaskScope;

record UserData(String profile, String orders) {}

UserData fetchUserData(String userId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<String> profileTask = scope.fork(() -> fetchProfile(userId))::get;
        Supplier<String> ordersTask = scope.fork(() -> fetchOrders(userId))::get;

        scope.join();           // wait for both subtasks
        scope.throwIfFailed();  // propagate failure if any subtask failed

        return new UserData(profileTask.get(), ordersTask.get());
    }
}

If either subtask fails, ShutdownOnFailure cancels the sibling automatically - no orphaned threads, no leaked resources.


13. Combining Everything - Real-World Patterns

Pattern A: Fetching data concurrently with Virtual Threads, modeling with Records, processing with Streams

record Order(String id, String customerId, double amount, String status) {}

List<String> orderIds = List.of("O1", "O2", "O3", "O4", "O5");

List<Order> orders;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    orders = orderIds.stream()
            .map(id -> executor.submit(() -> fetchOrderFromApi(id))) // I/O bound calls
            .toList()
            .stream()
            .map(future -> {
                try {
                    return future.get();
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            })
            .toList();
}

// Stream + Pattern Matching + Records together
Map<String, Double> totalByStatus = orders.stream()
        .collect(Collectors.groupingBy(Order::status, Collectors.summingDouble(Order::amount)));

String summary = orders.stream()
        .map(order -> switch (order) {
            case Order(var id, var cust, var amt, var status) when amt > 1000 ->
                    "High value order %s (%s): %.2f".formatted(id, status, amt);
            case Order(var id, var cust, var amt, var status) ->
                    "Order %s (%s): %.2f".formatted(id, status, amt);
        })
        .collect(Collectors.joining("\n"));

Pattern B: Sealed hierarchy + exhaustive switch + Stream transformation

sealed interface Event permits OrderPlaced, OrderCancelled, PaymentReceived {}
record OrderPlaced(String orderId, double amount) implements Event {}
record OrderCancelled(String orderId, String reason) implements Event {}
record PaymentReceived(String orderId, double amount) implements Event {}

List<Event> events = List.of(
    new OrderPlaced("O1", 250.0),
    new PaymentReceived("O1", 250.0),
    new OrderCancelled("O2", "Out of stock")
);

List<String> log = events.stream()
        .map(e -> switch (e) {
            case OrderPlaced(var id, var amt) -> "Order " + id + " placed: " + amt;
            case OrderCancelled(var id, var reason) -> "Order " + id + " cancelled: " + reason;
            case PaymentReceived(var id, var amt) -> "Payment for " + id + " received: " + amt;
        })
        .toList();

Pattern C: High-throughput concurrent processing with Structured Concurrency + Streams

List<String> urls = List.of("https://a.com", "https://b.com", "https://c.com");

List<String> results = new ArrayList<>();
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    List<Supplier<String>> tasks = urls.stream()
            .map(url -> (Supplier<String>) scope.fork(() -> fetchUrl(url))::get)
            .toList();

    scope.join();
    scope.throwIfFailed();

    tasks.stream().map(Supplier::get).forEach(results::add);
}

Summary Table

FeatureJava VersionPurpose
Streams API8Functional-style data processing pipelines
takeWhile/dropWhile/iterate (bounded)9Refined stream creation/short-circuiting
teeing collector12Combine two collectors into one result
Switch expressions14Concise, expression-based branching
Records16Immutable data carriers
instanceof pattern matching16Type check + cast in one step
Sealed classes17Restricted class hierarchies
Virtual Threads21Lightweight concurrency for I/O-bound tasks
Pattern matching for switch21Type + record deconstruction in switch
Record patterns21Destructure records directly
Structured Concurrency21 (preview)Manage related concurrent tasks as one unit

This guide reflects features up to Java 21 LTS. Some APIs (Structured Concurrency, certain pattern matching refinements) may still be preview features depending on your JDK version - check with --enable-preview if needed.

This note is part of the Digital Garden — a collection of connected, evolving thoughts.