External versus Internal Iteration
External iteration
In traditional Java (pre-streams), iteration is external: the caller explicitly drives each step — “get the next element, process it, repeat.” The caller interleaves what to do with how to iterate.
List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");
List<String> result = new ArrayList<>();
for (String name : names) {
if (name.length() > 3) {
result.add(name.toUpperCase());
}
}
The caller manages:
- The iteration variable (
name) - The loop mechanics (enhanced for-each)
- The accumulator (
result) - The ordering of operations
- The flow control (if-check inside loop)
Problems with external iteration:
- What and how are intertwined — the filtering logic (
length > 3) and the transformation logic (toUpperCase()) are mixed with the iteration mechanics (for,add). - No optimisation opportunity — the loop runs sequentially, element by element. The library cannot fuse operations or parallelise.
- Mutable accumulator — the result list must be created and mutated. This can be error-prone and is not thread-safe.
- Sequential by nature — switching to parallel execution requires rewriting the entire block.
Internal iteration
With streams, the caller declares what transformation or aggregation they want; the library drives iteration internally.
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
The caller specifies:
- What to keep (
filterwith a predicate) - What to transform (
mapwith a function) - What to produce (
collectinto a list)
The library handles:
- Iteration order
- Element-by-element traversal
- Accumulator management
- Potential optimisation
Key benefits of internal iteration
1. Laziness
Streams are lazy — intermediate operations like filter and map do not execute immediately. They build a pipeline plan. No data flows until a terminal operation (collect, forEach, count) triggers execution. This enables optimisation across the whole pipeline.
2. Potential parallelism
The same stream pipeline can run in parallel with a single method change:
// Sequential
List<String> result = names.stream()
.filter(...).map(...).collect(...);
// Parallel — same pipeline, different execution
List<String> result = names.parallelStream()
.filter(...).map(...).collect(...);
The library splits the source collection into substreams, processes them in the fork-join pool, and merges results. This works because the pipeline is a declarative description of what to do, not how to iterate.
3. Operation fusion
The stream library can optimise the pipeline as a whole:
- Combine
filter→mapinto a single pass over the data. - Short-circuit early:
findFirst()afterfilterstops as soon as a match is found.
With external iteration, these optimisations would need to be manually coded into each loop.
What is a Stream?
A Stream<T> represents a lazy, single-use pipeline of data from a source. It is NOT a data structure — it does not store elements; it carries elements from a source through a pipeline of operations.
Key properties:
- Lazy — nothing happens until a terminal operation.
- Single-use — after a terminal operation, the stream is exhausted and cannot be reused.
- Non-mutating — stream operations do not modify the source collection.
- Potentially infinite — streams can be unbounded (
Stream.iterate(),Stream.generate()), relying on short-circuiting operations to terminate.
Stream sources
// Collections
list.stream()
set.stream()
// Arrays
Arrays.stream(array)
IntStream.of(1, 2, 3)
// Ranges (primitive streams)
IntStream.range(0, 100) // 0 to 99
IntStream.rangeClosed(1, 100) // 1 to 100
// Files (lines as a stream)
Files.lines(Path.of("file.txt"))
// Infinite generators
Stream.iterate(0, n -> n + 1) // 0, 1, 2, 3, ...
Stream.generate(Math::random) // infinite random numbers
Stream.iterate(0, n -> n < 100, n -> n + 2) // finite with predicate (Java 9+)
// From values
Stream.of("a", "b", "c")
Sequential vs parallel streams
collection.stream() // sequential (default)
collection.parallelStream() // parallel
stream.sequential() // switch to sequential (only last call before terminal wins)
stream.parallel() // switch to parallel
You should generally prefer sequential streams unless you have:
- A CPU-intensive pipeline
- Stateless, non-interfering operations
- A sufficiently large dataset (parallelism overhead is significant for small data)
- No synchronisation or shared mutable state inside operations
Tripos traps
- Stream is not a collection — A stream is a pipeline, not a data store. You cannot index into it, get its size without consuming it, or reuse it after a terminal operation.
- Source not mutated — Stream operations produce new streams; they do not modify the source. If you need to modify the source, use an explicit loop or a
Collectionmethod. - Stateful lambdas — Avoid mutable state in stream operations. A lambda like
s -> { counter++; return s; }is not pure and breaks the functional model. It also makes parallelisation unsafe.