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.
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
- Java Streams - Fundamentals
- Stream Creation
- Intermediate Operations
- Terminal Operations
- Collectors - Deep Dive
- Primitive Streams
- Parallel Streams
- Records
- Pattern Matching
- Sealed Classes + Exhaustive Pattern Matching
- Virtual Threads
- Structured Concurrency
- 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()orForkJoinPoolfor that. - Blocking calls (
Thread.sleep, blocking I/O,synchronizedin some cases) are automatically “unmounted” from the carrier platform thread, freeing it for other virtual threads. - Avoid pinning: heavy use of
synchronizedblocks can pin the virtual thread to its carrier thread - preferReentrantLockin 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
| Feature | Java Version | Purpose |
|---|---|---|
| Streams API | 8 | Functional-style data processing pipelines |
takeWhile/dropWhile/iterate (bounded) | 9 | Refined stream creation/short-circuiting |
teeing collector | 12 | Combine two collectors into one result |
| Switch expressions | 14 | Concise, expression-based branching |
| Records | 16 | Immutable data carriers |
instanceof pattern matching | 16 | Type check + cast in one step |
| Sealed classes | 17 | Restricted class hierarchies |
| Virtual Threads | 21 | Lightweight concurrency for I/O-bound tasks |
| Pattern matching for switch | 21 | Type + record deconstruction in switch |
| Record patterns | 21 | Destructure records directly |
| Structured Concurrency | 21 (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.