Swimming with the Stream Sharks

Java has introduced Stream API as part of JDK 8. These can use a new set of classes from the java.util.stream package, and the idea is to support data operations on lists that you may have in memory. Developers would typically integrate these with lambda expressions to evaluate Collections using a functional operator.

For example, if you had a List of stocks you could use a functional operator to invoke a Collector that would accumulate the average stock value. Any of the list implementations that extend from Collection are able to support the Stream methods.

After installing JDK 8, this first presented itself to me when looking at a for loop that was accumulating stock price totals:

for (StockPrice p : prices)
{
    total += p.getPriceAmount();
}

double avg = (total / prices.size());

This is fairly straightforward; the loop accumulates the total of all prices and then outside the loop performs an average of them. As it turns out, with functional operators you can perform this average computation without writing a for{} loop:

total = prices.stream().map((p) -> p.getPriceAmount()).reduce(total, (accumulator, _item) -> accumulator + _item);
double avg = (total / prices.size());

Running this repeatedly produced the following timings:

Scenario 1: Average Price via Loop (501.258)   1493000 ns
Scenario 2: Average Price via Lambda (501.258) 4618000 ns

On average, the functional evaluator was ~5 times slower than a traditional for{} loop. Not to be defeated, I wondered if the stream API’s might offer some salvation here. This above loop was then re-written to invoke Collectors.averagingDouble() and move all of this logic to a single line:

double avg = prices.parallelStream().collect(Collectors.averagingDouble(StockPrice::getPriceAmount));

At first blush you might think parallelStream() will immediately offer your code some kind of performance boost when doing this kind of thing:

Scenario 1: Average Price via Loop (506.749)   1500000 ns
Scenario 2: Average Price via Stream (506.749) 13524000 ns

This is even worse – it’s roughly ~14 times slower than a traditional for{} loop. Honestly, I wasn’t surprised by this as there isn’t an easy way to make the summing of numbers any more efficient than just adding them up. So an operation that might take 1 second in a loop ends up consuming 13+ seconds using parallel Stream operations. Removing the overhead of parallel work, the prices.stream() version does perform slightly better but it does not approach the efficiency of a simple loop which is still ~4 times faster:

Scenario 1: Average Price via Loop (505.105)   1576000 ns
Scenario 2: Average Price via Stream (505.105) 3766000 ns

While there are certainly benefits to the new Stream API’s, they are going to require a developer to have a more complex map reduce requirement before they should even be considered. For example, if you were operating on a List of employees and wanted to group by location and then compute average tenure. Even so, it would be advisable to arrive at a metric to determine how much additional time will be consumed by the functional operators in tandem with the new Stream API map reduce functions.