A stream is a pipeline for processing a sequence of elements. Instead of writing how to loop (“create a list, loop, check, add”), you describe what you want (“orders that are new and over 100, as IDs”). The result is shorter, easier to read and harder to get wrong.
List<String> bigNewOrders = orders.stream()
.filter(o -> o.status().equals("NEW"))
.filter(o -> o.amount() > 100)
.map(Order::id)
.toList();
The same with a loop:
List<String> bigNewOrders = new ArrayList<>();
for (Order o : orders) {
if (o.status().equals("NEW") && o.amount() > 100) {
bigNewOrders.add(o.id());
}
}
The three parts of every pipeline
- Source — where elements come from:
list.stream(),Stream.of(...),IntStream.range(0, 10),Arrays.stream(array),Files.lines(path). - Intermediate operations — transform the stream and return a new stream:
filter,map,sorted,distinct,limit,skip,peek,flatMap. - Terminal operation — produces a result and ends the stream:
toList,collect,count,forEach,findFirst,anyMatch,reduce,sum.
Everyday intermediate operations
orders.stream()
.filter(o -> o.amount() > 50) // keep matching elements
.map(Order::customer) // transform each element
.distinct() // remove duplicates
.sorted() // natural order
.limit(10) // at most 10
.toList();
orders.stream()
.sorted(Comparator.comparingDouble(Order::amount).reversed()) // custom order
.limit(2)
.forEach(o -> System.out.println(o.id()));
Everyday terminal operations
long n = orders.stream().filter(o -> o.status().equals("NEW")).count();
boolean any = orders.stream().anyMatch(o -> o.status().equals("CANCELLED"));
boolean all = orders.stream().allMatch(o -> o.amount() > 0);
boolean none = orders.stream().noneMatch(o -> o.amount() < 0);
Optional<Order> first = orders.stream().filter(o -> o.customer().equals("Acme")).findFirst();
List<String> ids = orders.stream().map(Order::id).toList(); // Java 16+
findFirst returns an Optional — because there might be no match.
Number streams
mapToInt, mapToLong and mapToDouble give you streams of primitives with handy maths:
double total = orders.stream().mapToDouble(Order::amount).sum();
OptionalDouble avg = orders.stream().mapToDouble(Order::amount).average();
int max = IntStream.of(3, 9, 4).max().getAsInt();
IntStream.rangeClosed(1, 5).map(n -> n * n).forEach(System.out::println); // 1 4 9 16 25
Two rules that surprise beginners
Streams are lazy
Intermediate operations don’t run until a terminal operation asks for results — and then only as much as needed:
Stream<String> lazy = orders.stream().map(o -> {
System.out.println("mapping " + o.id());
return o.id();
});
System.out.println("Nothing mapped yet...");
lazy.findFirst(); // maps only the first order
This makes filter(...).findFirst() efficient even on huge inputs.
A stream can be used only once
After a terminal operation the stream is closed; calling another throws IllegalStateException. Create a new stream from the source each time.
Keep pipelines clean
- No side effects in
maporfilter. Don’t add to outside lists or change objects inside them — collect the result instead. - One operation per line makes pipelines easy to read and debug.
- Extract complex lambdas into well-named methods (
.filter(this::isEligible)). - Loops are still fine for simple cases, or when you need to
breakearly with complex logic.
The full example
// StreamsBasics.java — creating streams, filter/map/sorted, terminal operations.
import java.util.*;
import java.util.stream.*;
public class StreamsBasics {
record Order(String id, String customer, double amount, String status) { }
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("PO-1", "Acme", 250.0, "SHIPPED"),
new Order("PO-2", "Globex", 90.0, "NEW"),
new Order("PO-3", "Acme", 1200.0, "NEW"),
new Order("PO-4", "Initech", 40.0, "CANCELLED"));
// source -> intermediate operations -> terminal operation
List<String> bigNewOrders = orders.stream()
.filter(o -> o.status().equals("NEW")) // keep some
.filter(o -> o.amount() > 100)
.map(Order::id) // transform each
.toList(); // collect (Java 16+)
System.out.println(bigNewOrders); // [PO-3]
// Sorting and limiting
orders.stream()
.sorted(Comparator.comparingDouble(Order::amount).reversed())
.limit(2)
.forEach(o -> System.out.println(o.id() + " " + o.amount()));
// Numbers: mapToDouble gives sum/average/max without boxing
double total = orders.stream().mapToDouble(Order::amount).sum();
OptionalDouble avg = orders.stream().mapToDouble(Order::amount).average();
System.out.printf("Total %.1f, average %.1f%n", total, avg.orElse(0));
// Matching and finding
boolean anyCancelled = orders.stream().anyMatch(o -> o.status().equals("CANCELLED"));
boolean allPositive = orders.stream().allMatch(o -> o.amount() > 0);
Optional<Order> firstAcme = orders.stream().filter(o -> o.customer().equals("Acme")).findFirst();
System.out.println(anyCancelled + " " + allPositive + " " + firstAcme.map(Order::id).orElse("-"));
// distinct, count
long customers = orders.stream().map(Order::customer).distinct().count();
System.out.println(customers + " customers"); // 3 customers
// Other ways to create streams
Stream.of("a", "b", "c").map(String::toUpperCase).forEach(System.out::print);
System.out.println();
IntStream.rangeClosed(1, 5).map(n -> n * n).forEach(n -> System.out.print(n + " "));
System.out.println();
Arrays.stream(new int[] {3, 1, 2}).sorted().forEach(System.out::print);
System.out.println();
// Streams are lazy: nothing runs until the terminal operation
Stream<String> lazy = orders.stream().map(o -> {
System.out.println(" mapping " + o.id());
return o.id();
});
System.out.println("Nothing mapped yet...");
System.out.println(lazy.findFirst().orElse("")); // maps only PO-1
// A stream can be used only once
Stream<Order> once = orders.stream();
once.count();
try {
once.count();
} catch (IllegalStateException e) {
System.out.println("Streams can't be reused: " + e.getMessage());
}
}
}
Try it yourself
From a list of strings like "AX-200:24", "BX-310:0", "CX-420:7" (SKU and stock), produce the SKUs that are in stock, sorted alphabetically.
Show a solution
List<String> rows = List.of("CX-420:7", "BX-310:0", "AX-200:24");
List<String> inStock = rows.stream()
.map(r -> r.split(":"))
.filter(parts -> Integer.parseInt(parts[1]) > 0)
.map(parts -> parts[0])
.sorted()
.toList();
System.out.println(inStock); // [AX-200, CX-420]
Key takeaways
- Every stream is source → intermediate operations → terminal operation.
filter,map,sorted,distinct,limitshape the data;toList,count,anyMatch,findFirstfinish it.- Use
mapToInt/mapToDoublefor sums and averages. - Streams are lazy and single-use; keep operations free of side effects.