Comparing Java Streams with Jox Flows

Mon, 14 Jul 2025 10:18:00 +0000
Java, streaming, jox, Java Streams

Both Java Streams (introduced way back in Java 8) and Jox's Flows (part of an open-source virtual-thread-based safe concurrency & streaming library) allow transforming data in a streaming fashion.

From such a shallow description, it might seem that both serve the same purpose, and can be used to solve the same problems, which would make one of these implementations unnecessary (and since Java Streams are in the standard library, that would probably be Jox!).

What is the difference, then? It's a question I often face when I give talks on Jox and virtual-thread-based streaming; hence, let's examine these differences in more detail. As you'll find out, when we look more closely, even though the APIs look similar, both approaches have distinct use-cases and both might be used in a single codebase, for different purposes.

Update 25/07/2025: A reddit user pointed out that the push-pull distinction that was described as the source of differences in the implementations is incorrect: both implementations are push-style ones (although Jox has more liberty in pushing elements). The updated text pinpoints the differences more precisely, focusing on the ability to run streams asynchronously.

Java Streams

Let's first examine the characteristics of Java Streams. Here's a simple example of an initially infinite stream, which is then transformed using high-level operations, before being consumed by collecting the results into a list:

List<Integer> result = Stream
  .iterate(0, i -> i + 1)
  .map(i -> i * 2)
  .filter(i -> i % 3 == 2)
  .limit(10)
  .collect(Collectors.toList());

Even though the initial stream is infinite, the example above doesn't try to generate all natural numbers, as Java streams are lazy: until a terminal operation is invoked (such as .collect), nothing happens—only a blueprint of the transformation is created. The execution of the pipeline is highly optimized, producing and transforming only the data that will ultimately be needed, with a rich set of intermediate and terminal operations.

The above illustrates the most common use case for Java Streams: transforming collections, using a functional programming-inspired API, with lambdas supplied to higher-order functions such as map. But there's more!

With the arrival of Stream Gatherers in Java 24, Java Streams have become extensible. The versatility and flexibility of Java Streams has increased significantly, as it is now possible to implement custom intermediate operations without much effort.

For example, there are a couple of built-in gatherers, such as mapConcurrent (leveraging virtual threads), or windowFixed and windowSliding for data windowing; third-party libraries provide additional ones, as exemplified by more-gatherers. This gives you functionalities such as distinctBy, zip, or sampling. Equipped with such a rich set of operators, you can get quite far!

Stream
  .iterate(0, i -> i + 1)
  .gather(Gatherers.mapConcurrent(4, i -> i * 2))
  .gather(Gatherers.windowFixed(10))
  .findFirst();

Stream gatherers expand the scope of Java Streams to handle more use-cases that require infinite and side-effecting streams. For example, you could perform HTTP requests as part of Gatherers.mapConcurrent, limiting the concurrency so that the target server is not overloaded, and sourcing...