What is Stream API in Java
This code compiles, but it blows up at runtime:
List<String> words = List.of("hello", "hola", "hallo");
Stream<String> stream = words.stream();
stream.forEach(System.out::println); // fine
long count = stream.count(); // java.lang.IllegalStateException:
// stream has already been operated upon or closed There is no typo here: a Java stream is single-use. Once you understand how a stream differs from a collection, most surprises like this one disappear — and that is where we start.
1. What is Stream API in Java
The Stream API is the set of classes and interfaces in the java.util.stream package, introduced in Java 8, that lets you process sequences of elements in a declarative (functional) style. A stream is a sequence of elements coming from a source — a collection, an array, a file, a generator — that supports sequential and parallel operations.
The core idea: you describe what must happen to the data (filter it, transform it, collect it) instead of how to walk over it with loops and temporary variables. A stream is not a data structure: it stores nothing and simply pushes elements through a chain of operations.
Do not confuse java.util.stream.Stream with I/O streams (InputStream, OutputStream) or with threads of execution (Thread). They are three unrelated things that happen to share a name.
2. Why use the Stream API
- Less boilerplate. Filtering, sorting and collecting fit into one chain instead of a loop plus an intermediate list.
- Readability. A
filter().map().collect()pipeline reads like a sentence and states the intent, not the mechanics of iteration. - Parallel processing out of the box. One call to
parallelStream()instead of hand-writtenExecutorServicecode. - A rich operation set. Filtering, mapping, sorting, grouping, aggregating and searching all live in the standard library.
- Laziness. Intermediate operations do nothing until a result is requested, so no work is wasted.
3. Key characteristics of streams
- A stream stores no data. It pulls elements from a source and passes them down the pipeline; the source keeps living its own life.
- A stream does not modify its source.
sorted(),filter()andmap()return a new stream while the original collection stays untouched — unlikeCollections.sort(), which sorts the list in place. - A stream is single-use. After a terminal operation it is closed; touching it again throws
IllegalStateException. - Operations are lazy. Intermediate operations only build the pipeline; real processing starts when a terminal operation is called.
- No index-based access. There is no
get(i)on a stream; if you need random access, work with a list or an array. - Tight integration with lambda expressions and method references: nearly every Stream method takes a functional interface.
Important
A chain without a terminal operation does absolutely nothing. The call list.stream().filter(s -> { System.out.println(s); return true; }); prints not a single line: the lambda inside filter runs only when somebody asks for a result.
4. Stream, IntStream, LongStream, DoubleStream
The Java streams API is built around four interfaces:
java.util.stream.Stream<T>— a stream of objects of any reference type.IntStream,LongStream,DoubleStream— specialised streams of primitives.
The primitive versions are not there for decoration: Stream<Integer> has to box every number into an object, while IntStream works with primitives directly. They also expose numeric methods that Stream<T> simply does not have: sum(), average(), summaryStatistics().
// Stream<Integer>: boxing on every element, no sum() method
int total1 = List.of(1, 2, 3).stream().mapToInt(Integer::intValue).sum();
// IntStream: primitives, sum() available
int total2 = IntStream.of(1, 2, 3).sum(); // 6
double avg = IntStream.of(1, 2, 3).average().orElse(0); // 2.0 Switching between them: mapToInt(), mapToLong(), mapToDouble() take you from an object stream to a primitive one; boxed() or mapToObj() take you back.
For the complete list of methods see the official java.util.stream package documentation.
5. How to create a Stream: 7 ways
1. From a collection — the most common case; every Collection has a stream() method:
List<String> words = Arrays.asList("hello", "hola", "hallo", "ciao");
Stream<String> stream = words.stream(); 2. From explicit values — Stream.of():
Stream<String> stream = Stream.of("hello", "hola", "hallo", "ciao"); 3. From an array of objects:
String[] words = {"hello", "hola", "hallo", "ciao"};
Stream<String> stream = Arrays.stream(words); // or Stream.of(words) 4. From an array of primitives — you get an IntStream, not a Stream<Integer>:
int[] nums = {1, 2, 3, 4, 5};
System.out.println(Arrays.stream(nums).count()); // 5 int[] nums = {1, 2, 3, 4, 5};
System.out.println(IntStream.of(nums).count()); // 5 5. With a generator or an iterator — such streams are infinite, so they need a limit:
Stream<Double> random = Stream.generate(Math::random).limit(5);
Stream<Integer> powers = Stream.iterate(1, n -> n * 2).limit(5); // 1, 2, 4, 8, 16
// Java 9+: iterate with a stop condition, no limit needed
Stream<Integer> upTo100 = Stream.iterate(1, n -> n < 100, n -> n * 2); 6. With a builder:
Stream<String> s = Stream.<String>builder()
.add("h").add("e").add("l").add("l").add("o")
.build();
s.forEach(System.out::print); // hello 7. From a numeric range:
IntStream s1 = IntStream.range(1, 4); // 1, 2, 3 - upper bound excluded
IntStream s2 = IntStream.rangeClosed(1, 4); // 1, 2, 3, 4 - inclusive There are more sources as well: Files.lines(path) for the lines of a file, String.chars() for characters, Random.ints(), Pattern.splitAsStream() and Stream.empty() for an empty stream.
6. Intermediate and terminal operations
Every stream pipeline has three parts: source → intermediate operations → terminal operation. The difference between the two kinds of operations is fundamental:
- An intermediate operation (such as
filter,map,sorted,distinct,limit) returns a newStreamand computes nothing. You can chain as many of them as you like. - A terminal operation (such as
collect,forEach,count,reduce) returns a result (a value, a collection, anOptional) orvoid, triggers the whole pipeline and closes the stream.
Laziness saves real work: in the example below map runs not for every element, but exactly up to the first match.
Optional<String> first = Stream.of("apple", "banana", "avocado", "cherry")
.map(String::toUpperCase) // runs for "apple" only
.filter(s -> s.startsWith("A"))
.findFirst(); // terminal operation, iteration stops here
System.out.println(first.orElse("not found")); // APPLE The full list of intermediate and terminal methods, with tables and examples (including reduce(), toArray(), iterator()/spliterator(), and the stateless vs stateful distinction), is covered in a dedicated lesson: «Intermediate and Terminal Stream Operations».
7. Stream API examples with collections
The task: pick the students scoring above 90 and sort them by score.
How this was written before Java 8:
List<Student> studentsScore = new ArrayList<>();
for (Student s : students) {
if (s.getScore() > 90.0) {
studentsScore.add(s);
}
}
studentsScore.sort(new Comparator<Student>() {
public int compare(Student s1, Student s2) {
return Double.compare(s1.getScore(), s2.getScore());
}
}); The same thing using the Stream API — one chain instead of a loop, a temporary list and an anonymous class:
List<Student> studentsScore = students.stream()
.filter(s -> s.getScore() > 90.0)
.sorted(Comparator.comparing(Student::getScore))
.toList(); // Java 16+; before that: .collect(Collectors.toList()) A few more everyday pipelines:
// Names in upper case, comma separated
String names = students.stream()
.map(Student::getName)
.map(String::toUpperCase)
.collect(Collectors.joining(", "));
// Average score
double average = students.stream()
.mapToDouble(Student::getScore)
.average()
.orElse(0.0);
// Group by city
Map<String, List<Student>> byCity = students.stream()
.collect(Collectors.groupingBy(Student::getCity));
// Is there at least one top student
boolean hasExcellent = students.stream().anyMatch(s -> s.getScore() > 95.0); Tip
Since Java 16 you can write plain toList() instead of collect(Collectors.toList()). The difference is not only length: Stream.toList() returns an unmodifiable list, while Collectors.toList() guarantees neither mutability nor a particular implementation class. If you need to change the list afterwards, use collect(Collectors.toCollection(ArrayList::new)).
8. Where developers get tripped up
- Reusing a stream. The error from the top of this lesson: after a terminal operation the stream is closed. If you need a second pass, build a new stream or keep a
Supplier<Stream<T>>:Supplier<Stream<String>> supplier = words::stream; supplier.get().forEach(System.out::println); long count = supplier.get().count(); // works - Forgetting the terminal operation. A chain of intermediate operations does nothing and reports no error — the code just silently never runs.
Stream.of()with an array of primitives.Stream.of(new int[]{1, 2, 3})gives aStream<int[]>holding one element, not a stream of numbers. For primitives useArrays.stream(nums)orIntStream.of(nums).- An infinite stream with no bound.
Stream.generate(Math::random).forEach(...)spins forever. Streams fromgenerate()and from the two-argumentiterate()must be bounded withlimit()ortakeWhile(). - Modifying the source while iterating. Adding to or removing from the backing collection inside a lambda leads to
ConcurrentModificationException. - Side effects instead of collecting. Prefer
collect()ortoList()overforEach(result::add)— it is safer, especially with parallel streams.
Worth knowing
parallelStream() is not free speed. All parallel streams share the same ForkJoinPool.commonPool() by default, and on small collections the cost of splitting and merging eats the gain. Parallelism pays off on large data volumes with independent, side-effect-free operations — and only after you have measured.
Frequently asked questions
What is the difference between a Stream and a Collection?
A collection is a data structure: it holds elements in memory, knows its size, gives access by index and lets you add or remove elements. A stream holds nothing: it pulls elements from a source, pushes them through a pipeline of operations and closes. A collection can be traversed as many times as you want, a stream only once.
Why does the code inside filter or map never run?
Intermediate operations are lazy: they only describe what should happen. As long as the chain has no terminal operation such as collect, forEach, count, reduce or findFirst, not a single lambda is invoked. Add a terminal operation and the pipeline starts.
How does the Stream API work internally?
Each intermediate call adds a stage to a linked pipeline instead of processing anything. The source is wrapped in a Spliterator, which knows how to traverse and how to split the data. When a terminal operation is called, the pipeline is evaluated in one pass: every element goes through all stages before the next element is taken, and short-circuit operations such as findFirst, anyMatch or limit stop the traversal early. A parallel stream uses the same Spliterator to split the work across ForkJoinPool tasks and then merges the partial results.
What is the difference between IntStream and Stream of Integer?
IntStream works with primitive int values directly, without boxing them into objects, so it puts less pressure on memory and the garbage collector. It also has numeric methods such as sum, average, max, min and summaryStatistics that the object stream does not have. Use mapToInt to go from an object stream to a primitive one and boxed to go back.
Is parallelStream always faster than a sequential stream?
No. A parallel stream splits the data, processes the parts in the shared ForkJoinPool and merges the results, and on small collections that overhead costs more than the work itself. The gain shows up on large data volumes, with independent side-effect-free operations and an easily splittable source such as an ArrayList or an array. Decide by measuring, not by default.
Comments