Java 8 Stream Intermediate and Terminal Operations with Q & A

A cup of JAVA coffee with NeeSri
8 min readJan 18, 2025

--

Java 8 introduced the Stream API, a powerful tool for processing sequences of elements in a functional style. Streams make it easier to perform operations on collections such as filtering, mapping, and reducing without explicitly iterating over them.

Stream operations are categorized into two types: Intermediate Operations and Terminal Operations. Let’s explore both types and their common methods.

1. Intermediate Operations

Intermediate operations transform a stream into another stream. These operations are lazy, meaning they don’t execute until a terminal operation is invoked. They return a new stream and are typically used for chaining multiple operations together.

2. Terminal Operations

Terminal operations produce a result or a side effect and terminate the stream pipeline. Once a terminal operation is invoked, the stream is considered consumed and cannot be used further.

Example of Stream Operations in Action

Here’s a simple example demonstrating intermediate and terminal operations:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StreamExample {
public static void main(String[] args) {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Alice", "David");

List<String> result = names.stream()
.filter(name -> name.length() > 3)
.distinct()
.sorted()
.collect(Collectors.toList());

System.out.println(result);
}
}
//output- [Alice, Charlie, David]

Understanding Stream Pipelines

A stream pipeline consists of:

  • A source (e.g., List, Set, Array)
  • Intermediate operations (zero or more)
  • A terminal operation (only one)

Example: Full Stream Pipeline

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Edward");

List<String> filteredNames = names.stream()
.filter(name -> name.length() > 3) // Intermediate operation
.map(String::toUpperCase) // Intermediate operation
.sorted() // Intermediate operation
.collect(Collectors.toList()); // Terminal operation

System.out.println(filteredNames);

🔹 Pipeline Explanation:
1️⃣ Filter names longer than 3 characters
2️⃣ Convert to uppercase
3️⃣ Sort alphabetically
4️⃣ Collect as a list

Important Points: —

  • Intermediate operations prepare and modify the data but do not execute immediately.
  • Terminal operations trigger execution and produce a final result.
  • A stream pipeline is a sequence of one or more intermediate operations followed by one terminal operation.
  • Since intermediate operations do not produce final results, they can be chained, whereas terminal operations end the processing.

💡 Think of it like an assembly line:

  • Intermediate operations process raw materials (filtering, mapping, sorting).
  • Terminal operations package and deliver the final product.

Now lets see interview questions

1. What will happen if no terminal operation is invoked on a stream?

Answer:

If no terminal operation is invoked on a stream, the intermediate operations will not be executed. Streams are lazy, meaning they do not process any elements until a terminal operation is applied.

Stream<Integer> stream = Stream.of(1, 2, 3, 4).filter(n -> n % 2 == 0);
System.out.println("Stream created"); // Output: Stream created
// No terminal operation is invoked, so filter is never applied

2. Can you explain why streams are lazy? How does this improve performance?

Answer:

Streams are lazy because they defer processing until a terminal operation is invoked. This improves performance in the following ways:

  1. Avoids unnecessary computations: Intermediate operations are only applied to elements that are needed for the final result.
  2. Short-circuiting: Operations like limit() or findFirst() can stop processing once enough elements have been processed.

Example:

Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5)
.filter(n -> {
System.out.println("Filtering: " + n);
return n % 2 == 0;
})
.limit(1);

stream.forEach(System.out::println); // Output: Filtering: 1, Filtering: 2, 2

Here, filtering stops after finding the first even number because of limit(1).

3. How do reduce() and collect() differ in terminal operations?

Answer:

Both reduce() and collect() are terminal operations, but they serve different purposes:

  • reduce(): Reduces the stream elements to a single value by repeatedly applying a binary operation.
  • collect(): Gathers the elements of a stream into a collection, such as a List, Set, or Map.

Example:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4);

// Using reduce to find the sum
int sum = numbers.stream().reduce(0, Integer::sum); // Output: 10

// Using collect to gather elements into a list
List<Integer> collectedList = numbers.stream().collect(Collectors.toList()); // Output: [1, 2, 3, 4]

4. What is the difference between peek() and forEach()?

Answer:

  • peek(): It is an intermediate operation used to perform an action on each element without consuming the stream. It is typically used for debugging.
  • forEach(): It is a terminal operation that consumes the stream and performs an action on each element.

Example:

Stream<Integer> stream = Stream.of(1, 2, 3, 4);

stream.peek(System.out::println) // Prints elements but doesn't consume the stream
.filter(n -> n % 2 == 0)
.forEach(System.out::println); // Consumes the stream and prints only even numbers

5. Can a stream have multiple terminal operations?

Answer:

No, a stream can only have one terminal operation. Once a terminal operation is invoked, the stream is considered consumed, and further operations will result in an IllegalStateException.

Stream<Integer> stream = Stream.of(1, 2, 3);

stream.forEach(System.out::println); // Consumes the stream
stream.forEach(System.out::println); // Throws IllegalStateException

6. What are short-circuiting operations in streams? Give examples.

Answer:

Short-circuiting operations allow the stream to terminate early without processing all elements. These can be either:

  1. Intermediate short-circuiting operations: limit(), skip().
  2. Terminal short-circuiting operations: findFirst(), findAny(), anyMatch(), allMatch(), noneMatch().
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

boolean result = numbers.stream()
.anyMatch(n -> n > 3); // Stops processing after finding the first match

System.out.println(result); // Output: true

7. How can you implement custom short-circuiting in a stream?

Answer:

You can implement custom short-circuiting using takeWhile() (introduced in Java 9) or by manually using a combination of limit() and filter().

Example (Java 8 approach using limit):

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

List<Integer> result = numbers.stream()
.filter(n -> n < 4)
.limit(2)
.collect(Collectors.toList());

System.out.println(result); // Output: [1, 2]

8. How does collect(Collectors.toMap()) handle duplicate keys?

Answer:

If collect(Collectors.toMap()) encounters duplicate keys, it throws an IllegalStateException by default. To handle duplicates, you can provide a merge function as the third argument.

Example:

List<String> list = Arrays.asList("apple", "banana", "apple");

Map<String, Integer> map = list.stream()
.collect(Collectors.toMap(
Function.identity(),
String::length,
(existing, replacement) -> existing)); // Merge function to handle duplicates

System.out.println(map); // Output: {apple=5, banana=6}

9. How do you convert a stream into a Map where keys are strings and values are lists of elements sharing the same key?

Answer:

You can use Collectors.groupingBy() to group elements by key.

Example:

List<String> list = Arrays.asList("apple", "banana", "apricot", "blueberry");

Map<Character, List<String>> groupedMap = list.stream()
.collect(Collectors.groupingBy(s -> s.charAt(0)));

System.out.println(groupedMap);
// Output: {a=[apple, apricot], b=[banana, blueberry]}

10. What are the differences between findFirst() and findAny()? When should each be used?

Answer:

  • findFirst(): Returns the first element of the stream, preserving the encounter order.
  • findAny(): Returns any element, and is generally faster when working with parallel streams as it does not require maintaining order.
  • Use case:
  • Use findFirst() when the order of elements matters.
  • Use findAny() when the order does not matter, and performance is critical (especially in parallel streams).

11. What is the difference between sorted() and distinct()?

Answer:

  • sorted(): An intermediate operation that sorts the elements of a stream in natural or custom order.
  • distinct(): An intermediate operation that removes duplicate elements by comparing them using equals().
List<Integer> numbers = Arrays.asList(5, 3, 1, 2, 3, 1);

// Using sorted
List<Integer> sortedList = numbers.stream()
.sorted()
.collect(Collectors.toList()); // Output: [1, 1, 2, 3, 3, 5]

// Using distinct
List<Integer> distinctList = numbers.stream()
.distinct()
.collect(Collectors.toList()); // Output: [5, 3, 1, 2]

12. What is the behavior of limit() and skip() when used together?

Answer:

  • limit(n): Returns a stream consisting of the first n elements.
  • skip(n): Returns a stream with the first n elements skipped.
  • When used together, they can select a range of elements from a stream.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7);

// Selecting a sublist (elements 3, 4, 5)
List<Integer> sublist = numbers.stream()
.skip(2) // Skip first 2 elements
.limit(3) // Limit to the next 3 elements
.collect(Collectors.toList());

System.out.println(sublist); // Output: [3, 4, 5]

13. How does allMatch(), anyMatch(), and noneMatch() work?

Answer:

These are terminal short-circuiting operations used for matching conditions in a stream:

  • allMatch(predicate): Returns true if all elements match the predicate.
  • anyMatch(predicate): Returns true if any element matches the predicate.
  • noneMatch(predicate): Returns true if no elements match the predicate.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

boolean allEven = numbers.stream().allMatch(n -> n % 2 == 0); // Output: false
boolean anyEven = numbers.stream().anyMatch(n -> n % 2 == 0); // Output: true
boolean noneNegative = numbers.stream().noneMatch(n -> n < 0); // Output: true

14. How do you find the maximum and minimum element in a stream?

Answer:

You can use the max() and min() terminal operations with a comparator to find the maximum or minimum element.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

int max = numbers.stream()
.max(Integer::compareTo)
.orElseThrow(); // Output: 5

int min = numbers.stream()
.min(Integer::compareTo)
orElseThrow(); // Output: 1

15. Can you explain how partitioningBy() works in streams?

Answer:

Collectors.partitioningBy() is a special case of grouping that divides elements into two groups based on a predicate.

It returns a Map<Boolean, List<T>>.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

Map<Boolean, List<Integer>> partitioned = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));

System.out.println(partitioned);
// Output: {false=[1, 3, 5], true=[2, 4]}

16. How do you collect elements into an immutable list using streams?

Answer:

You can use Collectors.toUnmodifiableList() to collect elements into an immutable list.

List<Integer> numbers = Arrays.asList(1, 2, 3);

List<Integer> immutableList = numbers.stream()
.collect(Collectors.toUnmodifiableList());

immutableList.add(4); // Throws UnsupportedOperationException

17. What is the difference between collect() and toArray()?

Answer:

  • collect(): Used to collect elements into a mutable collection (e.g., List, Set, Map).
  • toArray(): Converts the stream elements into an array.
List<String> list = Arrays.asList("a", "b", "c");

// Using collect
List<String> collectedList = list.stream().collect(Collectors.toList());
// Output: [a, b, c]

// Using toArray
String[] array = list.stream().toArray(String[]::new);
// Output: [a, b, c]

18. What are the differences between Stream.of() and Arrays.stream()?

Answer:

  • Stream.of(): Creates a stream from a fixed number of elements or an array.
  • Arrays.stream(): Creates a stream only from an array.
  • If you pass a primitive array to Stream.of(), it will treat the array as a single element, whereas Arrays.stream() will treat it as a stream of elements.
int[] array = {1, 2, 3};

// Stream.of creates a stream with a single element (the array itself)
Stream<int[]> streamOfArray = Stream.of(array);

// Arrays.stream creates a stream of elements from the array
IntStream intStream = Arrays.stream(array);

19. How do parallel streams work? What are the potential downsides?

Answer:

Parallel streams use multiple threads to process elements in parallel, which can speed up the computation for large datasets.

Potential downsides:

  1. Overhead: For small datasets, the overhead of managing threads can outweigh the benefits.
  2. Order: Operations on parallel streams may not preserve the order unless explicitly specified.
  3. Thread-safety: If operations involve mutable shared state, it can lead to incorrect results.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

numbers.parallelStream().forEach(System.out::println);
// Output order may vary

20. Can intermediate operations modify the original data source?

Answer:

No, intermediate operations do not modify the original data source. Streams operate on a view of the data, and the original collection remains unmodified.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

numbers.stream()
.filter(n -> n % 2 == 0)
.forEach(System.out::println); // Output: 2 4

System.out.println(numbers); // Output: [1, 2, 3, 4, 5]

Happy Learning :)

--

--

No responses yet