Java | Streams
Java Streams describe a pipeline that transforms or aggregates data. They make common collection work expressive, but correctness still depends on understanding laziness, stateless transformations, terminal operations and when an ordinary loop is clearer.
Source, Intermediate and Terminal Operations
A stream starts from a collection, array or generator. Intermediate operations such as filter and map build a lazy pipeline; a terminal operation such as collect, reduce or count triggers evaluation and consumes the stream.
Laziness and Short Circuiting
Intermediate stages run only when a terminal operation needs their values. findFirst, limit and anyMatch can stop early, which is useful for large or generated sources when the answer appears near the beginning.
Functional Purity
Prefer lambdas that transform or test their input without mutating outside state. Side effects become especially dangerous with parallel streams and make pipeline order or repeated execution hard to reason about.
Collectors Shape Results
Collectors can build lists, sets, maps, grouped results and summaries. Specify merge behavior when keys can repeat; Collectors.toMap otherwise throws on duplicate keys.
Parallelism Is Not a Default
parallelStream has scheduling and coordination overhead. Use it only after profiling a CPU-bound, easily partitioned workload with no shared mutable state and a safe terminal collector.
Worked Examples
Example 1: Filter, Map and Collect
The pipeline reads a list of scores, keeps passing values, formats them and collects the new values. The source list remains unchanged because each stage produces a new stream view.
Code
import java.util.List;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Integer> scores = List.of(48, 72, 91, 64, 88);
List<String> passed = scores.stream()
.filter(score -> score >= 60)
.map(score -> "pass:" + score)
.collect(Collectors.toList());
System.out.println(passed);
}
}
Expected Result
[pass:72, pass:91, pass:64, pass:88]
Example 2: Group Values by a Property
groupingBy chooses a map key for every item and collects matching values into a list. It is often clearer than manually checking whether a mutable map bucket already exists.
Code
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<String> tools = List.of("git", "gradle", "java", "maven");
Map<Integer, List<String>> byLength = tools.stream()
.collect(Collectors.groupingBy(String::length));
System.out.println(byLength.get(4));
System.out.println(byLength.get(5));
}
}
Expected Result
[gradle, java] [maven]
Example 3: Reduce to One Aggregate
reduce combines values with an associative operation. The identity zero means an empty stream produces a sensible result, and addition lets the same operation work sequentially or in parallel.
Code
import java.util.List;
public class Main {
public static void main(String[] args) {
int total = List.of(3, 5, 8, 13).stream()
.reduce(0, Integer::sum);
int maximum = List.of(3, 5, 8, 13).stream()
.reduce(Integer.MIN_VALUE, Integer::max);
System.out.println(total);
System.out.println(maximum);
}
}
Expected Result
29 13
Example 4: Create a Map With a Duplicate-Key Merge
toMap needs a collision policy when two values have the same key. This example keeps the larger score for a repeated team name instead of failing with an IllegalStateException.
Code
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Main {
record Result(String team, int score) { }
public static void main(String[] args) {
List<Result> results = List.of(
new Result("Orion", 74),
new Result("Nova", 88),
new Result("Orion", 91)
);
Map<String, Integer> bestScores = results.stream()
.collect(Collectors.toMap(
Result::team,
Result::score,
Integer::max
));
System.out.println(bestScores.get("Orion"));
}
}
Expected Result
91
Example 5: Make the input visible
Start by naming the value the next line depends on. This keeps the lesson input explicit before we apply a transformation or call an external boundary. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
public static void main(String[] args) {
String lesson = "Java Streams";
System.out.println(lesson.toUpperCase());
}
}Expected Result
JAVA STREAMS
Try next: Replace the sample value with one from your own notes, predict the result, and then run it.
Example 6: Make the input visible
Start by naming the value the next line depends on. This keeps the lesson input explicit before we apply a transformation or call an external boundary. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
public static void main(String[] args) {
String lesson = "Java Streams";
System.out.println(lesson.toUpperCase());
}
}Expected Result
JAVA STREAMS
Try next: Replace the sample value with one from your own notes, predict the result, and then run it.
Example 7: Guard an empty boundary
A small guard makes the allowed and rejected path visible. We want a caller to receive an intentional result instead of discovering an unexplained failure later. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
static boolean accepted(String value) {
return value != null && !value.isBlank();
}
public static void main(String[] args) {
System.out.println(accepted("Java Streams"));
}
}Expected Result
true
Try next: Try an empty value as well as the normal value and describe why the two outcomes differ.
Example 8: Keep the transformation pure
This step returns a value instead of mixing the rule with display or storage work. That separation gives us a small unit we can run, test, and reuse. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
static int doubleValue(int value) { return value * 2; }
public static void main(String[] args) {
System.out.println(doubleValue(6));
}
}Expected Result
12
Try next: Call the same rule twice with different inputs and confirm that each result depends only on its input.
Example 9: Make a branch deliberate
Use a named branch where the business rule changes. A future reader should be able to point at the condition and state the result for both sides. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
public static void main(String[] args) {
int score = 7;
String result = score >= 5 ? "ready" : "review";
System.out.println(result);
}
}Expected Result
ready
Try next: Change the boundary value by one and explain exactly which branch becomes active.
Example 10: Use a collection without hidden mutation
Collections become safer when we can see which value is read, which value is derived, and whether the original collection remains owned by the caller. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> lessons = List.of("Java Streams", "boundary");
System.out.println(String.join(" | ", lessons));
}
}Expected Result
Java Streams | boundary
Try next: Add one more item and predict the derived result before executing the code.
Example 11: Return a named outcome
A named outcome is easier to test than an implied side effect. We can now distinguish a successful path from a rejected path without guessing from console noise. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
record Outcome(String status, String lesson) {}
public static void main(String[] args) {
Outcome outcome = new Outcome("ready", "Java Streams");
System.out.println(outcome.status());
}
}Expected Result
ready
Try next: Rename the outcome in your own words while preserving the same success and failure rule.
Example 12: Trace a small loop
Work through one value at a time. The point is not to memorize syntax; it is to see how the accumulator or output changes after each repeatable step. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
public static void main(String[] args) {
int total = 0;
for (int value : new int[] {1, 2, 3}) total += value;
System.out.println(total);
}
}Expected Result
6
Try next: Use a shorter collection first, then add one value and trace the extra iteration aloud.
Example 13: Keep display at the edge
The core rule prepares a value, while the outer line decides how to show it. That keeps the result usable by a UI, test, API, or command-line caller. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
static String display(String value) { return "Lesson: " + value; }
public static void main(String[] args) {
System.out.println(display("Java Streams"));
}
}Expected Result
Lesson: Java Streams
Try next: Change only the display text and verify that the underlying rule and result stay unchanged.
Example 14: Test the closest boundary
A useful example includes the nearest boundary, not only the happy path. We are making the behavior inspectable before a real caller relies on it. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
static int quantity(int value) {
if (value < 1) throw new IllegalArgumentException("quantity must be positive");
return value;
}
public static void main(String[] args) {
System.out.println(quantity(1));
}
}Expected Result
1
Try next: Try the lowest supported value and then one value just outside the accepted range.
Example 15: Write a tiny regression check
A compact assertion turns the lesson into a repeatable promise. It helps us catch a changed rule before a larger program hides the source of the mistake. We are applying that habit while revisiting Java Streams.
Keep the example small enough to trace. First predict the normal result; then change one input and explain the boundary result in your own words.
Java
public class Main {
public static void main(String[] args) {
String result = "Java Streams".toUpperCase();
if (!result.equals("JAVA STREAMS")) throw new AssertionError("contract failed");
System.out.println("contract holds");
}
}Expected Result
contract holds
Try next: Deliberately change the expected value once, observe the failed check, and then restore the correct contract.
Complexity Analysis
| Operation | Complexity | Explanation |
|---|---|---|
| Filter and map | O(n) time, O(output) space | Each input is tested once and accepted values become output elements. |
| Grouping collector | O(n) expected time | Each element is classified once into a hash-based map bucket. |
| Reduction | O(n) time, O(1) extra state | The accumulator combines every value into one result. |
Common Mistakes
- Reusing a stream after a terminal operation has already consumed it.
- Mutating external variables inside map, filter or forEach and obscuring pipeline behavior.
- Calling toMap without a merge function when the key is not guaranteed unique.
- Switching to parallelStream without measuring overhead, thread safety and workload partitioning.
Interview Checklist
- Identify the source, each lazy intermediate stage and the terminal operation.
- Explain whether the pipeline is stateless and why no external mutation is needed.
- Choose collect, reduce, groupingBy or toMap based on the desired result shape.
- Test empty input, duplicate keys, a short-circuit operation and an input with no matches.
