Java Streams: Practical Differences Between forEach and peek for In-Place Updates
This guide illustrates how to update objects inside a list while still performing downstream stream operations, and clarifies how Stream.forEach, Collection.forEach, and Stream.peek differ.
Model used in examples
package com.example.model;
public class Person {
private Integer id;
private String fullName;
private int age;
public Person() {}
public Person(String fullName, int age) {
this.fullName = fullName;
this.age = age;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"id=" + id +
", fullName='" + fullName + '\'' +
", age=" + age +
'}';
}
}
Test data and scenarios
import org.junit.jupiter.api.Test;
import java.util.*;
import java.util.stream.*;
public class StreamSideEffectTest {
private List<Person> seed() {
List<Person> data = new ArrayList<>();
data.add(new Person("A", 28));
data.add(new Person("B", 19));
data.add(new Person("C", 33));
data.add(new Person("D", 22));
return data;
}
@Test
void mutateThenMin_withCollectionForEach() {
List<Person> people = seed();
// Mutate first, outside any stream pipeline
people.forEach(p -> p.setFullName(p.getFullName() + "_tag"));
// Then create a fresh stream and compute the minimum by age
Person youngest = people.stream()
.min(Comparator.comparingInt(Person::getAge))
.orElseThrow();
System.out.println(youngest);
}
@Test
void inlineMutate_withPeekThenMin() {
List<Person> people = seed();
// Perform the side-effect in the pipeline using peek, then find the min
Person youngest = people.stream()
.peek(p -> p.setFullName(p.getFullName() + "_tag"))
.min(Comparator.comparingInt(Person::getAge))
.orElseThrow();
System.out.println(youngest);
}
@Test
void streamForEach_isTerminalAndRequiresANewStream() {
List<Person> people = seed();
// Stream.forEach is a terminal operation; it consumes the stream
people.stream().forEach(p -> p.setFullName(p.getFullName() + "_mut"));
// You must obtain a new stream for further operations
int minAge = people.stream()
.mapToInt(Person::getAge)
.min()
.orElseThrow();
System.out.println(minAge);
}
}
What’s happening under the hood
-
Collection.forEach (e.g., list.forEach):
- Iterates the collection and returns void.
- Not part of a stream pipeline; you can create a stream afterward to further processing.
-
Stream.forEach:
- Terminal operation that consumes the stream and returns void.
- After calling it, you cannnot reuse the same stream; start a new stream from the source if you need more operatinos.
-
Stream.peek:
- Intermediate operation that returns a new stream.
- Intended primarily for observing elements in a pipeline; the action runs only when a terminal operation is invoked downstream.
- If used to mutate elements, be cautious—especially with parallel streams—because side effects on shared state require proper synchronizaiton.
Notes on selecting the minimum element
- Prefer concise comparators when possible:
- Using Comparator.comparingInt(Person::getAge) is clearer and less error-prone than hand-written comparisons.
- min(...).orElseThrow() is a straightforward way to unwrap the Optional when you expect at least one element.
API references (signatures)
- Iterable (e.g., List):
- default void forEach(Consumer<? super T> action)
- Stream:
- void forEach(Consumer<? super T> action) // terminal
- Stream<T> peek(Consumer<? super T> action) // intermediate