I got curious and wrote a simple micro-benchmark using JMH.
And it turns out that there is a quite severe performance penalty for using flatMap(Optional::stream) over filter(Optional::isPresent).map(Optional::get).
Java 16 introduced mapMulti which is similar to flatMap in usage and has performance characteristics very close to those of filter/map.
Each of my benchmark methods takes a list of Optional<Integer> and computes the sum of all present values.
I implemented three approaches:
flatMap as posited the question
filter and map as described in the question
mapMulti introduced in JDK 16.
Note that I did not make use of the flatMapToInt or mapMultiToInt methods, which are probably more efficient, because I didn't want to focus on the streams-over-wrapper objects aspect and just compare the usage of streams over Optional objects.
For all approaches I ran the benchmark with a full list (all values present), a half empty list (every second value present), and an entirely empty list (each optional is empty). The lists are all the same length (arbitrarily picked 10 000 elements each).
The unit for the values is us/op (microseconds per operation, meaning one full stream evaluation).
| Approach |
Full List |
Half Empty List |
Empty List |
flatMap |
207.219 ± 1.176 |
175.355 ± 4.955 |
142.986 ± 2.821 |
filter/map |
12.856 ± 0.375 |
12.086 ± 0.451 |
6.856 ± 0.143 |
mapMulti |
13.990 ± 0.353 |
11.685 ± 0.276 |
7.034 ± 0.199 |
Note that the absolute numbers here are specific to my machine running JDK 16 and are mostly irrelevant anyway. The relative differences are what is important here.
It seems the flatMap approach is both significantly slower and more variable. If I had to guess the variability comes from increased GC pressure caused by all the Stream objects created, even for empty results.
Disclaimer: this is obviously just a single made-up example being tested and the benchmark hasn't been peer-reviewed (yet), so don't take these results for granted without further investigation.
Full benchmark code below (note that I turned down some iterations/runtimes to get responses in a reasonable time and hard-coded to use 4 threads. Adapt as required.)
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Fork(value = 1, warmups = 0)
@Warmup(iterations = 5, time = 5)
@Measurement(iterations = 5, time = 5)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Threads(4)
public class MyBenchmark {
@State(Scope.Benchmark)
public static class MyLists {
private static final int LIST_SIZE = 10_000;
public final List<Optional<Integer>> allValues;
public final List<Optional<Integer>> halfEmpty;
public final List<Optional<Integer>> allEmpty;
public MyLists() {
List<Optional<Integer>> allValues = new ArrayList<>(LIST_SIZE);
List<Optional<Integer>> halfEmpty = new ArrayList<>(LIST_SIZE);
List<Optional<Integer>> allEmpty = new ArrayList<>(LIST_SIZE);
for (int i = 0; i < LIST_SIZE; i++) {
Optional<Integer> o = Optional.of(i);
allValues.add(o);
halfEmpty.add(i % 2 == 0 ? o : Optional.empty());
allEmpty.add(Optional.empty());
}
this.allValues = Collections.unmodifiableList(allValues);
this.halfEmpty = Collections.unmodifiableList(halfEmpty);
this.allEmpty = Collections.unmodifiableList(allEmpty);
}
}
@Benchmark
public long filter_and_map_allValues(MyLists lists) {
return filterAndMap(lists.allValues);
}
@Benchmark
public long filter_and_map_halfEmpty(MyLists lists) {
return filterAndMap(lists.halfEmpty);
}
@Benchmark
public long filter_and_map_allEmpty(MyLists lists) {
return filterAndMap(lists.allEmpty);
}
@Benchmark
public long flatMap_allValues(MyLists lists) {
return flatMap(lists.allValues);
}
@Benchmark
public long flatMap_halfEmpty(MyLists lists) {
return flatMap(lists.halfEmpty);
}
@Benchmark
public long flatMap_allEmpty(MyLists lists) {
return flatMap(lists.allEmpty);
}
@Benchmark
public long mapMulti_allValues(MyLists lists) {
return mapMulti(lists.allValues);
}
@Benchmark
public long mapMulti_halfEmpty(MyLists lists) {
return mapMulti(lists.halfEmpty);
}
@Benchmark
public long mapMulti_allEmpty(MyLists lists) {
return mapMulti(lists.allEmpty);
}
private long filterAndMap(List<Optional<Integer>> input) {
return input.stream().filter(Optional::isPresent).map(Optional::get).mapToInt(Integer::intValue).sum();
}
private long flatMap(List<Optional<Integer>> input) {
return input.stream().flatMap(Optional::stream).mapToInt(Integer::intValue).sum();
}
private long mapMulti(List<Optional<Integer>> input) {
// Unfortunately the type witness <Integer> is necessary here, as type inference would otherwise make mapMulti produce a Stream<Object>.
return input.stream().<Integer>mapMulti(Optional::ifPresent).mapToInt(Integer::intValue).sum();
}
}