Introduction to the Stream API in Java
A stream describes a computation over a sequence of elements. You state what you want, and the pipeline decides how to walk the data.
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
What a stream is
A stream is a sequence of elements supporting a pipeline of operations. It is not a data structure: it stores nothing, and it does not modify its source. It describes work to be done, and does that work only when a terminal operation asks for a result.
The shape of a pipeline
source -> intermediate operations -> terminal operation
list filter, map, sorted collect, forEach, countList<String> titles = notes.stream() // source
.filter(Note::isPublished) // intermediate
.map(Note::title) // intermediate
.sorted() // intermediate
.toList(); // terminalThe same thing without streams
List<String> titles = new ArrayList<>();
for (Note note : notes) {
if (note.isPublished()) {
titles.add(note.title());
}
}
Collections.sort(titles);The loop describes how; the stream describes what. Neither is always better, but the stream version composes and reads as a single statement of intent.
Creating streams
List<String> names = List.of("Ravi", "Anita", "Meera");
Stream<String> fromCollection = names.stream();
Stream<String> fromValues = Stream.of("a", "b", "c");
Stream<String> fromArray = Arrays.stream(new String[]{"x", "y"});
Stream<String> empty = Stream.empty();
IntStream range = IntStream.range(0, 10); // 0 to 9
IntStream inclusive = IntStream.rangeClosed(1, 10); // 1 to 10
Stream<Integer> generated = Stream.iterate(1, n -> n * 2).limit(10);
Stream<Integer> bounded = Stream.iterate(1, n -> n < 100, n -> n * 2); // Java 9
Stream<Double> random = Stream.generate(Math::random).limit(5);
Stream<String> lines = Files.lines(path); // must be closed
Stream<String> split = Pattern.compile(",").splitAsStream("a,b,c");Stream.iterateandStream.generateare infinite. They must be bounded bylimitor by the three argument form, or the pipeline never ends.
Laziness
Stream<String> pipeline = names.stream()
.filter(name -> {
System.out.println("filtering " + name);
return name.length() > 4;
})
.map(name -> {
System.out.println("mapping " + name);
return name.toUpperCase();
});
System.out.println("nothing has run yet");
List<String> result = pipeline.toList(); // now it runsIntermediate operations only build the pipeline. Nothing is evaluated until a terminal operation arrives.
Elements flow one at a time
Ravi -> filter (length 4, dropped)
Anita -> filter (passes) -> map -> ANITA -> collected
Meera -> filter (passes) -> map -> MEERA -> collectedEach element travels the whole pipeline before the next one starts. The stream does not filter everything, then map everything. This is what makes short circuiting possible.
Optional<String> first = Stream.iterate(1, n -> n + 1) // infinite
.filter(n -> n % 7 == 0)
.map(String::valueOf)
.findFirst(); // stops at the first matchA stream can be used once
Stream<String> stream = names.stream();
stream.forEach(System.out::println);
// stream.count(); // IllegalStateException: stream has already been operated uponCreate a new stream from the source each time, or keep the source rather than the stream.
Primitive streams
IntStream marks = IntStream.of(45, 78, 62, 91);
System.out.println(marks.sum()); // no boxing
System.out.println(IntStream.of(45, 78).average().getAsDouble());
IntSummaryStatistics stats = IntStream.of(45, 78, 62).summaryStatistics();
System.out.println(stats.getMax() + " " + stats.getAverage());
// Converting between them
IntStream lengths = names.stream().mapToInt(String::length);
Stream<Integer> boxed = lengths.boxed();IntStream, LongStream and DoubleStream avoid wrapper allocation and add numeric operations such as sum and average that the object stream does not have.
A worked example
record Note(String title, String category, int views, boolean published) { }
List<Note> notes = List.of(
new Note("Java loops", "java", 320, true),
new Note("SQL joins", "sql", 540, true),
new Note("Java generics", "java", 180, false),
new Note("Java streams", "java", 610, true));
Map<String, Long> publishedPerCategory = notes.stream()
.filter(Note::published)
.collect(Collectors.groupingBy(Note::category, Collectors.counting()));
System.out.println(publishedPerCategory); // {java=2, sql=1}
int totalViews = notes.stream().mapToInt(Note::views).sum();
Optional<Note> mostViewed = notes.stream().max(Comparator.comparingInt(Note::views));Streams do not change the source
List<String> original = new ArrayList<>(List.of("b", "a", "c"));
List<String> sorted = original.stream().sorted().toList();
System.out.println(original); // [b, a, c] - unchanged
System.out.println(sorted); // [a, b, c]When not to use a stream
- A simple loop over a small list, where a
forloop is shorter and clearer. - When you need the index of each element.
- When the body must modify several variables outside the loop.
- When a checked exception must be thrown from the body.
- In a very hot loop over primitives, where a plain loop avoids all overhead.
Common mistakes
- Reusing a stream after a terminal operation.
- Building a pipeline and forgetting the terminal operation, so nothing runs.
- Using an unbounded
iterateorgeneratewithoutlimit. - Modifying the source collection while the stream runs.
- Using
forEachto fill an external list instead ofcollect. - Leaving a
Files.linesstream unclosed.
Best practices
- Keep pipelines short; extract long lambdas into named methods.
- Use primitive streams for numeric work.
- Keep operations free of side effects.
- Use
toList()for the common case, since Java 16. - Close streams over I/O with try with resources.
- Choose a loop when it genuinely reads better.
Practice
- Convert a loop that filters and collects names longer than four characters into a stream.
- Add print statements to a filter and a map and explain the order of the output.
- Why does calling two terminal operations on one stream throw?
- Produce the first five powers of three using
Stream.iterate. - Compute the total and the average of a list of marks without boxing.
Conclusion
A stream is a lazy description of work over a sequence. Build it from a source, chain intermediate operations, finish with a terminal one, and remember that elements flow through one at a time and the source is never modified.