Functional vs Imperative in Java
Let's compare two ways to process a collection in Java: streams and explicit loops. The point is not to crown a universal winner. The point is to see how the two approaches behave on the same workload, and to make the tradeoffs visible.
Here's the task: Given a collection of random numbers, find the distinct even numbers in sorted order.
That gives us a realistic pipeline with four steps: map, flatten, filter, and sort.
A quick warning before we start: this is an illustrative benchmark, not a substitute for proper microbenchmarking. If you need production-grade numbers, use JMH.
We'll split this into two parts. First, we'll build the test client. Then we'll run the benchmarks and look at the results.
Building The Test Client
We'll start with a small wrapper that creates a collection of random numbers.
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
public class RandomNumberWrapper {
private final List<Integer> values;
public RandomNumberWrapper(long n) {
this.values = new Random()
.ints(n)
.boxed()
.collect(Collectors.toList());
}
public List<Integer> getValues() {
return this.values;
}
}
Next, we'll define the class that runs the benchmark.
import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintWriter;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.function.Supplier;
import java.util.stream.Collectors;
public class StreamVsLoop {}
From here on out, the Java snippets are methods on StreamVsLoop.
We'll use 10 RandomNumberWrappers per run.
For a given n, the benchmark processes n * 10 numbers.
private static final int N_WRAPPERS = 10;
private static List<RandomNumberWrapper> getRandomNumbers(final long n) {
final List<RandomNumberWrapper> wrappers = new ArrayList<>();
for (long i = 0; i < N_WRAPPERS; i++) {
wrappers.add(new RandomNumberWrapper(n));
}
return wrappers;
}
We also need a small helper for filtering even numbers.
private static boolean isEven(final int n) {
return n % 2 == 0;
}
Now we can implement the two versions.
The stream version reads directly as a pipeline.
private static long timeStreamImpl(final List<RandomNumberWrapper> payloads) {
final long start = System.currentTimeMillis();
payloads.stream()
.map(RandomNumberWrapper::getValues)
.flatMap(List::stream)
.filter(StreamVsLoop::isEven)
.distinct()
.sorted()
.collect(Collectors.toList());
return System.currentTimeMillis() - start;
}
The loop version performs the same work step by step.
private static long timeLoopImpl(final List<RandomNumberWrapper> payloads) {
final long start = System.currentTimeMillis();
List<Integer> randomNumbers = new ArrayList<>();
// map() / flatMap()
for (final RandomNumberWrapper payload : payloads) {
randomNumbers.addAll(payload.getValues());
}
// distinct()
randomNumbers = new ArrayList<>(new HashSet<>(randomNumbers));
// filter()
randomNumbers.removeIf(n -> !isEven(n));
// sorted()
randomNumbers.sort(Integer::compareTo);
return System.currentTimeMillis() - start;
}
Each benchmark returns a long representing elapsed time in milliseconds.
To reduce noise, we'll average 100 runs of each implementation.
private static final int N_TEST_RUNS = 100;
private static double getAverage(final Supplier<Long> testCase) {
return Collections.nCopies(N_TEST_RUNS, testCase)
.stream()
.map(Supplier::get)
.mapToLong(Long::valueOf)
.average()
.orElse(-1);
}
Finally, we'll write the results to a CSV file so we can inspect them later.
private static void writeToCSV(final List<String> lines) {
final File csvOutputFile = new File("stream_vs_loop.csv");
try (final PrintWriter pw = new PrintWriter(csvOutputFile)) {
lines.forEach(pw::println);
} catch (FileNotFoundException e) {
System.err.println("failed to write csv file: " + e.getMessage());
System.exit(1);
}
assert csvOutputFile.exists();
}
Now we can wire everything together. For each input size, we'll measure both implementations and print a CSV row.
private static final NumberFormat NUM_FORMAT = new DecimalFormat("0E0");
public static void main(final String[] args) {
final List<String> lines = new ArrayList<>();
final String headers = "n,stream,loop";
System.out.println(headers);
lines.add(headers);
for (long n = 1; n <= 1000000; n *= 10) {
final List<RandomNumberWrapper> payloads = getRandomNumbers(n);
final String line = NUM_FORMAT.format(n * N_WRAPPERS).toLowerCase(Locale.ROOT) + ','
+ getAverage(() -> timeStreamImpl(payloads)) + ','
+ getAverage(() -> timeLoopImpl(payloads));
System.out.println(line);
lines.add(line);
}
writeToCSV(lines);
}
Comparing Functional And Imperative Implementations
In practice, the difference between streams and loops is usually smaller than people expect. For small inputs, either approach can be fine. For large inputs, implementation details, JVM warm-up, and allocation behavior all start to matter. That is why the table below should be read as directional rather than absolute.
| n | stream (ms) | loop (ms) |
|---|---|---|
| 1e1 | 0.18 | 0.05 |
| 1e2 | 0.18 | 0.21 |
| 1e3 | 0.36 | 0.25 |
| 1e4 | 1.53 | 1.07 |
| 1e5 | 11.57 | 15.22 |
| 1e6 | 213.31 | 222.61 |
| 1e7 | 3343.59 | 12392.42 |
The stream version is easier to read because the code mirrors the problem statement. The loop version gives you more control and can be easier to tune when you need to squeeze out performance.
My default recommendation is simple: use streams when the code is clearer, and reach for a loop when profiling shows the stream version is a real bottleneck. If you are making that decision seriously, benchmark with JMH before you optimize.