A stream is a view over a collection that lets you describe a computation as a sequence of steps instead of writing the loops. A pipeline has three parts: a source that produces the data, a chain of intermediate operations that shape it, and one terminal operation that consumes the result. The important property is laziness - nothing happens until the terminal operation runs.
What are the three stages of a pipeline?
The source is where the data comes from: a collection, an array, a file, or one of the factory methods on the Stream class. Intermediate operations take a stream and return a new stream - filter keeps some elements, map transforms them, sorted puts them in order. The terminal operation ends the chain and produces a result: a list, a number, a boolean, or nothing at all if you consume the stream with forEach.
import java.util.List;
public class FirstPipeline {
public static void main(String[] args) {
List<String> names = List.of("ana", "bo", "cy", "dia", "eli");
List<String> longNames = names.stream()
.filter(name -> name.length() > 2)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println("result : " + longNames);
long count = names.stream().filter(name -> name.length() > 2).count();
System.out.println("count : " + count);
}
}result : [ANA, DIA, ELI]
count : 3Read the pipeline from the top. The source is a list of five names. filter keeps the ones longer than two characters. map upper-cases them. sorted puts them in order. toList is the terminal step that finally produces a list. Nothing in that chain is a loop - each step describes a transformation, and the library decides how to apply it.
Why does laziness matter?
Intermediate operations do not run when you call them. They record a plan. The work happens when a terminal operation asks for the data, and then each element travels through the whole chain, one at a time.
import java.util.List;
import java.util.stream.IntStream;
public class LazyPipeline {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
System.out.println("--- nothing consumed yet ---");
var stream = numbers.stream().peek(n -> System.out.println("peeked " + n));
System.out.println("--- pipeline assembled ---");
int total = stream.map(n -> n * 10).mapToInt(Integer::intValue).sum();
System.out.println("total : " + total);
int rangeSum = IntStream.rangeClosed(1, 5).sum();
System.out.println("range sum : " + rangeSum);
}
}--- nothing consumed yet ---
--- pipeline assembled ---
peeked 1
peeked 2
peeked 3
peeked 4
peeked 5
total : 150
range sum : 15The output is the proof. Nothing is printed between "nothing consumed yet" and "pipeline assembled" - the peek callback has not fired once. The moment sum is called, the entire chain runs, element by element, and the peek messages appear. This matters for two reasons: a stream over a huge source can stop early, and you can build a long chain without doing any work until you are ready.
Where can a stream come from?
Collections and arrays are the common sources, but the Stream class has factory methods for everything else.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
public class StreamSources {
public static void main(String[] args) {
Stream.of("a", "b").forEach(s -> System.out.println("of : " + s));
Arrays.asList("x", "y").stream().forEach(s -> System.out.println("list : " + s));
Map.of("k", 1).entrySet().stream()
.forEach(entry -> System.out.println("entry : " + entry.getKey() + "=" + entry.getValue()));
Stream.of(1, 2, 3).filter(n -> n > 1).forEach(n -> System.out.println("filtered : " + n));
List<String> empty = List.of();
System.out.println("empty count : " + empty.stream().count());
System.out.println("null safe : " + java.util.stream.Stream.ofNullable(null).count());
}
}of : a
of : b
list : x
list : y
entry : k=1
filtered : 2
filtered : 3
empty count : 0
null safe : 0Two of those are worth knowing by name. Stream.ofNullable turns a possibly-null value into a stream of zero or one elements, which is handy when you are chaining a lookup into a pipeline. And the various range methods on IntStream produce a stream of numbers without building a list first - useful for counters and indexes.
What changes with a parallel stream?
Adding parallel lets the runtime split the work across several threads. The result should be identical - that is the whole promise of the stream abstraction - but the order of side effects is no longer guaranteed, and the overhead of splitting only pays off when the work per element is substantial.
import java.util.List;
import java.util.stream.IntStream;
public class ParallelStream {
public static void main(String[] args) {
List<Integer> numbers = IntStream.rangeClosed(1, 1000).boxed().toList();
long start = System.nanoTime();
int sequential = numbers.stream().map(ParallelStream::work).mapToInt(Integer::intValue).sum();
long sequentialTime = System.nanoTime() - start;
start = System.nanoTime();
int parallel = numbers.parallelStream().map(ParallelStream::work).mapToInt(Integer::intValue).sum();
long parallelTime = System.nanoTime() - start;
System.out.println("sequential total : " + sequential);
System.out.println("parallel total : " + parallel);
System.out.println("same result : " + (sequential == parallel));
System.out.println("parallel used more threads : " + (parallelTime < sequentialTime));
}
static int work(int value) {
return value * 2;
}
}sequential total : 1001000
parallel total : 1001000
same result : true
parallel used more threads : falseDo not read too much into the timing line. It reflects one run on one machine, not a benchmark, and a trivial operation like multiplying by two will never be worth splitting. The rule of thumb is that parallel streams help with expensive, independent, CPU-bound work over a large data set, and hurt when the work is tiny, the data is small, or the operation has shared state.
What are the traps with streams?
- Reusing a stream. Once a terminal operation runs, the stream is consumed. A second terminal operation on the same stream throws.
- Order-dependent side effects.
forEachin a parallel stream gives no ordering guarantee. - Stateful operations.
distinctandsortedare more expensive in a parallel pipeline because they need to see everything. - Null elements. Many operations throw on null; filter them out first if they are possible.
- Loops that are clearer as loops. A stream is not automatically better than a for loop.
The last point is the one experienced developers argue about most. Streams shine when you are transforming a sequence through several stages. If the work is a single pass with an index, a plain loop is shorter and easier to read.
How do you turn a stream back into a collection?
Every pipeline ends with a terminal operation, and the most common one is a conversion back into a collection. There are three that cover almost everything.
toList()produces an unmodifiable list in encounter order. It is the default choice.collect(Collectors.toSet())produces a set, which removes duplicates and gives you constant-time membership tests.toArray()produces an array, which is what many older APIs expect as a parameter.
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class BackToCollection {
public static void main(String[] args) {
List<String> words = List.of("a", "b", "a", "c", "b");
List<String> listed = words.stream().filter(w -> w.length() > 0).toList();
Set<String> unique = words.stream().collect(Collectors.toSet());
String[] array = words.stream().toArray(String[]::new);
System.out.println("list : " + listed);
System.out.println("set : " + unique);
System.out.println("array : " + array.length + " elements");
try {
listed.add("d");
} catch (UnsupportedOperationException e) {
System.out.println("the list is unmodifiable");
}
}
}list : [a, b, a, c, b]
set : [a, b, c]
array : 5 elements
the list is unmodifiableTwo details are worth remembering. The result of toList() cannot be modified - if you need a mutable list, build one with new ArrayList<>(stream.toList()). And the array version takes a generator such as String[]::new, which tells the runtime how to create the array it is about to fill.
The habits worth keeping
- End every pipeline with exactly one terminal operation.
- Keep the chain flat and readable - one operation per line when it gets long.
- Use method references when the lambda is only forwarding to a method.
- Do not reach for
paralleluntil the sequential version is measured and slow. - Remember that the source is not copied - the stream reads from the original collection.
Try it yourself: take a list of transaction records and build a pipeline that keeps the ones over a threshold, converts the amount to a formatted string, sorts by amount and collects the results into a list. Then write the same logic as a plain for loop, and compare the two side by side to decide which one you would rather maintain.