A Stream in Java is a pipeline that values flow through, not a place values are kept. You build the pipeline by chaining operations onto a source, then trigger it with a single terminal call that produces a result. This lesson covers what java.util.stream.Stream<T> is, how it differs from a Collection, the shape of a stream pipeline, why operations are lazy, why a stream can be consumed only once, the common ways to create a stream, and the three primitive stream specializations (IntStream, LongStream, DoubleStream).
Stream<T> Actually IsStream<T> lives in java.util.stream and was added in Java 8. A stream is a description of a computation over a sequence of values of type T. It does not store the values. It does not own them. It points at a source (a list, an array, a generator, a file's lines) and remembers what you want done with each value as it passes through.
A simple way to see this is to read a stream pipeline out loud. The code below takes the names of the items in a cart, keeps the ones that start with "P", and prints them.
Read it as English: "Take the cart, turn it into a stream, keep names that start with P, and for each remaining name, print it." The list itself is not changed. The stream is a one-time view of the list with two extra instructions stapled on.
The type of cart.stream() is Stream<String>, because the cart holds String items. The type parameter on Stream<T> works exactly like it does on List<T> or Optional<T>: it carries the element type through every operation in the pipeline. The lambda passed to filter receives a String, and the lambda passed to forEach receives a String, because the compiler tracks the element type from the source onward.
A stream is closer in spirit to an SQL query than to an array. The query says what you want, and the database engine figures out the work. Likewise, a stream pipeline says what you want, and the JDK runs it efficiently in one pass when the terminal operation fires.
A common starting point is treating a stream like a more functional version of a list. They look similar from the outside (both have an element type, both can be iterated), but they answer different questions.
A Collection is a data structure. It holds elements in memory, lets you ask its size, lets you index or look up by key, and lets you walk it as many times as you want. A Stream is a recipe for processing elements. It has no size you can query, no indexing, no key lookup, and it can be walked exactly once.
| Property | Collection | Stream |
|---|---|---|
| Stores elements? | Yes, in memory | No, refers to a source |
| Size known up front? | Yes, size() is constant time | Not necessarily, sources can be infinite |
| Random access? | Some types (ArrayList, arrays) | No, only sequential |
| Can iterate twice? | Yes, every iteration is fresh | No, one terminal operation consumes it |
| When does work run? | Eagerly, each method does its job now | Lazily, only when a terminal operation fires |
| Can be infinite? | No, must fit in memory | Yes, Stream.generate, Stream.iterate |
| Modify the source? | Yes, add, remove, etc. | No, streams do not modify their source |
The "no random access" point is worth keeping in mind. There's no stream.get(2). If you want the third element of a stream, you have to skip the first two and take one, which means walking the pipeline. The stream model is built around "process every element in order", not "jump to position N".
The "one terminal operation" point is the common source of confusion. Reusing a List is normal. Reusing a Stream is an error.
A concrete example shows the difference. Both snippets print the same numbers, but they get there in different ways.
The for-each loop owns its control flow. It pulls from the list, checks the condition, prints, and repeats. The stream version flips that: the pipeline describes the work, and the terminal operation drives it. For one filter and a print, the two are about equally readable. The stream version is worth using when the pipeline has several steps, because you'd otherwise have a deeply nested loop or several intermediate lists.
Every stream pipeline has three parts: a source, zero or more intermediate operations, and exactly one terminal operation. The source is where the elements come from. The intermediate operations describe transformations or filters. The terminal operation triggers the work and produces a result (or a side effect like printing).
The cyan box on the left is the source. The orange boxes in the middle are intermediate operations: each one returns a new Stream, so you can chain them. The green box on the right is the terminal operation, which either returns a value (like a count, a list, an Optional) or performs a side effect (like printing).
Intermediate operations don't change anything by themselves. They build up a plan. The plan stays dormant until a terminal operation comes along and says "go".
A short example that uses all three parts.
cart.stream() is the source, filter and map are intermediate operations, and count is the terminal operation. The terminal operation produces a long and ends the pipeline. After count returns, the stream is done; calling another method on it would fail.
You can have a pipeline with zero intermediate operations. cart.stream().count() works fine and just counts the elements. You cannot have a pipeline with zero terminal operations and still expect work to happen.
Intermediate operations are lazy. Calling filter does not actually filter anything. It returns a new Stream object that remembers the predicate. The same is true of map, sorted, distinct, and every other intermediate operation. The work only starts when a terminal operation pulls values through the pipeline.
peek is the easiest way to see this. peek is an intermediate operation that runs an action on each element as it flows through, without otherwise affecting the stream. It's mostly used for debugging. If you build a pipeline with peek but never terminate it, the action never runs.
No Peeked: lines appear. The peek lambda was never invoked, because nothing pulled elements through the pipeline. The intermediate operations built a plan that was discarded when the variable went out of scope.
Add a terminal operation and the picture changes:
Now count() drives the pipeline, and peek fires once per element. Laziness has a second benefit beyond "don't compute things you don't use": it lets the JDK fuse operations. With one filter and one map, the engine walks each element once and applies both. It does not build an intermediate list of filtered values, then build another intermediate list of mapped values. That fusion is part of why streams are competitive with hand-written loops for many workloads.
A long chain of intermediate operations is not a long chain of intermediate collections. The JDK pulls one element at a time through the whole pipeline, so the per-element cost is roughly the sum of the per-step costs, not the product.
Laziness also enables short-circuiting. Operations like findFirst, anyMatch, and limit can stop pulling elements as soon as the answer is known. "Infinite source plus limit(5)" finishes, because the source is only asked for five values.
A stream can be used by exactly one terminal operation. Once a terminal operation fires, the stream is done. Calling another operation on it (intermediate or terminal) throws IllegalStateException. This applies even if the terminal operation didn't actually consume all the elements (like findFirst), because the engine considers the stream closed once it has produced a result.
The first count() walks the stream and returns 3. The second call, forEach, tries to walk the same stream again and the JDK refuses. Streams are designed for a single end-to-end pass, and the engine doesn't keep state that would let it restart.
What's wrong with this code?
The variable items holds a stream that's already been terminated by count(). The second call, items.forEach(...), throws IllegalStateException. Storing a stream in a variable and using it twice is the most common way to hit this error.
Fix:
Each terminal operation gets its own fresh stream built from the source. The source (cart, a List) can be turned into a new stream as many times as you want, because the list still has all its elements. The stream is the cheap, throwaway thing; the collection is the durable thing.
To reuse a stream, the answer is almost always "store the result of the first terminal operation in a variable and reuse that", or "build a fresh stream from the source for the second pass." Trying to make a Stream itself reusable goes against the design.
You can build a stream from many starting points. The common ones, in rough order of frequency:
From a collection. Every Collection<E> has .stream() and .parallelStream(). The first is sequential, the second uses the common fork-join pool to process elements in parallel.
From an array. Arrays.stream(arr) produces a stream over the array's elements. For an object array it returns Stream<T>; for a primitive array it returns the matching primitive stream (more on those below).
From explicit values. Stream.of(a, b, c) builds a stream from any list of arguments. Useful for quick experiments or small fixed inputs.
An empty stream. Stream.empty() returns a stream with zero elements. Useful as a default return value when you'd otherwise be tempted to return null.
An infinite stream from a supplier. Stream.generate(Supplier<T>) calls the supplier repeatedly to produce elements. It's infinite, so pair it with a short-circuiting intermediate operation like limit or the program will run forever.
An infinite stream from a seed and a function. Stream.iterate(seed, fn) starts with seed and applies fn repeatedly to produce the next element. Java 9 added a three-argument overload, Stream.iterate(seed, hasNext, next), which adds a stopping predicate and saves you from threading limit through every call site.
A range of integers. IntStream.range(start, end) produces a stream of int values from start (inclusive) to end (exclusive). IntStream.rangeClosed(start, end) includes end. Both work well for generating IDs, indexes, or fixed-size iterations without writing a for loop.
A stream builder. Stream.builder() lets you push values onto a builder, then call build() to get the finished stream. It's verbose for tiny inputs, but useful when you build a stream conditionally across several branches.
For most everyday code, Collection.stream(), Arrays.stream(...), and Stream.of(...) cover roughly all of it. The generator and iterator forms come up when you need infinite or computed sequences. The builder is a niche tool for conditional construction.
Stream<T> only works with reference types. There is no Stream<int>. If you used Stream<Integer> for a million stock counts, every numeric operation would autobox each int into an Integer and autounbox on the way out. That's a lot of small allocations, and it sits right in the middle of a hot loop.
Java ships three specialized stream types for the most common primitives:
| Stream type | Element type | Methods like |
|---|---|---|
IntStream | int | sum, average, max, min, range |
LongStream | long | sum, average, max, min, range |
DoubleStream | double | sum, average, max, min |
Each one mirrors the Stream<T> API but works on the primitive directly. No boxing, no unboxing, and the methods include built-in numeric aggregates (sum, average) that don't exist on Stream<T>.
IntStream.sum() returns int. IntStream.average() returns OptionalDouble (empty if the stream had no elements), so the example calls orElse(0.0) to pull out a double. Neither call autoboxes any int along the way.
You can move between primitive and reference streams when you need to. mapToInt, mapToLong, mapToDouble go from a Stream<T> to a primitive stream, and boxed() goes the other way.
prices.stream() is Stream<Double>, which doesn't expose sum() directly. mapToDouble converts each Double to a double and produces a DoubleStream, where sum() is a one-call answer. The unboxing happens once per element inside mapToDouble, instead of once per arithmetic step.
Going the other way, you might want to feed a primitive stream into a Collectors.toList() or any other API that expects Stream<T>. boxed() turns each primitive into its wrapper:
rangeClosed(1, 3) is an IntStream. boxed() lifts each int into an Integer and produces Stream<Integer>, which Collectors.toList() accepts.
Stream<Integer> of one million stock counts pays for one million Integer allocations on creation plus one unbox per arithmetic operation. IntStream over the same data does zero of either. For large numeric workloads, use the primitive specialization first; for a list of five prices, the boxing cost is irrelevant.
A short program that uses several sources, an intermediate operation, a primitive stream, and one terminal operation per pipeline.
Four independent pipelines, four different sources, four terminal operations. None of the streams is reused. The list and the array stay around and could feed more streams later if needed. Streams are cheap to build and cheap to throw away, and the source decides what kind of stream you get.
10 quizzes