Open-source project
pivovarit/parallel-collectors avatar
pivovarit/parallel-collectors

parallel-collectors: virtual-thread collectors for the Java Stream API

Parallel Collectors is a toolkit easing parallel collection processing in Java using Stream API.

683 stars65 forksJavaApache-2.0

At a glance

What is it?
Parallel Collectors is a zero-dependency Java toolkit that replaces the shared ForkJoinPool behind parallelStream with your own executor, virtual threads by default, and a CompletableFuture result. It keeps the Collector interface intact, which makes it a drop-in for I/O-heavy streams, at the cost of a one-off collector you must not reuse.
Who is it for?
Parallel Collectors fits a Java team with a blocking, I/O-heavy stream that parallelStream handles badly, and a team willing to own the executor lifecycle and pick a parallelism level itself, since the library is deliberately unopinionated about both. It does not fit a CPU-bound workload, where the built-in parallel streams already do the right thing, or a codebase that reuses a collector across calls, because every parallel collector is one-off.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 4 days ago.
What is it written in?
Mainly Java, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Why parallelStream is the wrong tool for blocking work

The project states its motivation plainly: Java's built-in parallelization story is geared toward CPU-bound workloads, and parallelStream runs everything on the shared ForkJoinPool, which makes it a poor fit for blocking I/O, remote calls, database access, or anything that can stall a worker thread. The second half of that sentence is the real damage. Once the common pool is saturated by blocking calls, everything else in the JVM that uses it slows down with it, so a slow endpoint in one part of your application becomes a latency problem in unrelated parts. The library keeps the Stream API model but swaps the execution strategy. Instead of the common pool, you supply user-provided executors, with virtual-thread defaults for low-overhead concurrency, plus classification and batching for finer scheduling. The result is integrated with CompletableFuture so you can apply timeouts, callbacks and composition the same way you would on any other asynchronous stage.

Collectors that return a CompletableFuture instead of a value

The main entry point is the com.pivovarit.collectors.ParallelCollectors class, which follows the naming convention established by java.util.stream.Collectors and provides static factory methods that return custom implementations of the Collector interface. The difference from a normal collector is what comes out. Instead of a terminal value, you get a CompletableFuture, which is why the library can offer timeout specification, composition with other CompletableFutures and asynchronous processing on top of a plain stream.

list.stream() .collect(parallel(i -> blockingOp(i), toList())) .orTimeout(1000, MILLISECONDS) .thenAcceptAsync(System.out::println, executor) .thenRun(() -> System.out.println("Finished!"));

The orTimeout call on the returned future is the clearest illustration of the point. With a standard collector you would be blocked inside the terminal operation, and the only way out is a separate thread or a timeout on the whole call. Here the timeout is a method on the result. The library describes itself as non-blocking, which eliminates the need to block the calling thread while awaiting results, and as short-circuiting, where if one of the operations raises an exception the remaining tasks get interrupted.

parallel returns a Stream, not a List, and the collector is one-off

Two API details will bite you if you read them late. First, parallel(mapper) returns CompletableFuture<Stream<R>>, not CompletableFuture<List<R>>. If you want a list, you pass a downstream collector explicitly, so parallel(mapper, toList()), and the same applies to parallelBy. The README calls this out with an Important note, and it is the kind of detail that produces a type error rather than a wrong answer, which is the kinder failure mode. Second, all parallel collectors are one-off and must not be reused. The same warning applies to the second: a collector carries per-invocation state, and reusing it across two terminal operations is not a supported pattern.

The other documented constraint is the ownership boundary. By default collectors use virtual threads, but if you provide a custom Executor you are responsible for its lifecycle management. The library calls itself lightweight and an alternative to Project Reactor for scenarios where a lighter solution is preferred, and non-invasive, since they are just custom implementations of the Collector interface, with no magic inside, zero dependencies and no Stream API internals hacking.

Two families, and how to pick between them

The choice between the two collector families comes down to whether you are willing to block while waiting. The decision flow in the README asks whether it is acceptable to block the caller thread while waiting for processing to finish, and whether the order of elements matters. If you are not willing to block, the answer is ParallelCollectors.parallel. If you are willing to block and order matters, the answer is parallelToStream with the ordered configurer option. If order does not matter, plain parallelToStream. The parallel family returns a CompletableFuture while the parallelToStream family returns a Stream, and the ordering guarantee in the streaming case is opt-in through the ordered() configurer, which applies to streaming collectors only.

The parallel family additionally accepts a custom downstream Collector, which is what makes the List result possible. The streaming family does not. Both families support a custom Executor, a custom parallelism level, and grouping by key through parallelBy and parallelToStreamBy.

Configuration through CollectingConfigurer and StreamingConfigurer

All configuration is done through a configurer object passed as a Consumer: CollectingConfigurer for parallel and parallelBy, StreamingConfigurer for parallelToStream and parallelToStreamBy.

list.stream() .collect(parallel(i -> foo(i), c -> c .executor(executor) .parallelism(4) .batching(), toList()));

The options listed in the README cover a custom Executor, a custom parallelism level, batching through the batching() configurer option, grouping by key via parallelBy and parallelToStreamBy, ordered streaming via the ordered() configurer option, a custom downstream Collector for the parallel family only, executor decoration through executorDecorator() to wrap the resolved executor, and task decoration through taskDecorator() to wrap each individual task. The two decoration hooks are the ones worth knowing about, since they are the difference between wrapping a pool once and wrapping every unit of work. The library describes itself as versatile, enabling easy integration with existing Stream API Collectors, which is the practical argument for it over writing a custom collector for each pipeline.

Batching exists because one task per element is expensive

The batching section explains the problem the batching() option solves. When you use non-batching parallel collectors, every input element is turned into an individual task submitted to an ExecutorService, so 1000 elements means 1000 submitted tasks. The README points out that even if only two threads are processing them, both threads hammer the same task queue, repeatedly competing for the next piece of work. The cost is not the work itself but the queue contention and the per-task submission overhead, which matters precisely in the virtual-thread scenario the library targets, where the whole point is that cheap threads make many small tasks viable. Once you enable batching, elements are grouped before submission. The README section on batching is cut off mid-sentence in the version available here, so the exact batching policy is not spelled out and would need to be read in the API documentation.

Deliberately unopinionated, with the responsibility left to you

The philosophy section is short and worth reading before you ship this in anything. Parallel Collectors are intentionally unopinionated, leaving three responsibilities to users: proper configuration of the provided Executors and their lifecycle management, choosing appropriate parallelism levels, and ensuring the tool is applied in the right context. The README then says to review the API documentation before deploying in production. That is a real design position rather than a disclaimer. A library that picked an executor for you would be convenient and would also hide the common-pool problem it exists to solve.

The licence is Apache-2.0, with the LICENSE.md file at the root of the repository, and the project publishes to Maven Central under the com.pivovarit group with the artifact name parallel-collectors. Recent releases include v4.1.1 on 2026-06-07, v4.1.0 on 2026-05-01 and v4.0.0 on 2026-02-12, and the last push to the repository was on 2026-10-19. The README lists the Jenkins JUnit Plugin and LinkedIn Avro Util as projects using it, which is the closest thing to an adoption signal the documentation offers.

Adding the dependency and running the first parallel stream

The artifact coordinates are the same for both JDK lines, with different versions. For JDK 21 and later the README gives version 4.0.0.

<dependency> <groupId>com.pivovarit</groupId> <artifactId>parallel-collectors</artifactId> <version>4.0.0</version> </dependency>

For JDK 8 and later the version is 2.6.1.

<dependency> <groupId>com.pivovarit</groupId> <artifactId>parallel-collectors</artifactId> <version>2.6.1</version> </dependency>

Gradle users have the same split, with the single-line form.

implementation 'com.pivovarit:parallel-collectors:4.0.0'

implementation 'com.pivovarit:parallel-collectors:2.6.1'

The version you pick is determined by your JDK, not by a feature you need, and that is the first thing to settle. On JDK 21 and later the default is virtual threads, so a plain parallel(mapper, toList()) call needs no executor at all and returns a future you can compose on.

Editorial conclusion

Parallel Collectors fits a Java team with a blocking, I/O-heavy stream that parallelStream handles badly, and a team willing to own the executor lifecycle and pick a parallelism level itself, since the library is deliberately unopinionated about both. It does not fit a CPU-bound workload, where the built-in parallel streams already do the right thing, or a codebase that reuses a collector across calls, because every parallel collector is one-off. Before you adopt it, confirm the JDK floor, since the JDK 21 and later artifact is version 4.0.0 while JDK 8 and later users are pinned to 2.6.1, and read the API documentation before deploying it in production as the README asks.

Frequently asked questions

What does Parallel Collectors do differently from parallelStream?

It replaces the shared ForkJoinPool used by parallelStream with a user-provided executor, defaults to virtual threads, and returns a CompletableFuture so you can apply timeouts, callbacks and composition. The README says parallelStream runs everything on the shared pool, which is a poor fit for blocking I/O, remote calls or database access, and that once the pool is saturated everything else using it slows down as well.

How do I add Parallel Collectors to a Maven or Gradle project?

The Maven coordinates are com.pivovarit with the artifact parallel-collectors, at version 4.0.0 for JDK 21 and later and 2.6.1 for JDK 8 and later. The Gradle equivalent is the implementation line with the same group, artifact and versions.

Why does parallel(mapper) return a Stream instead of a List?

The README states that parallel(mapper) returns CompletableFuture<Stream<R>>, not CompletableFuture<List<R>>. If you want a List you pass a downstream collector explicitly, so parallel(mapper, toList()), and the same applies to parallelBy.

Can I reuse a Parallel Collectors collector across calls?

No. The README states that all parallel collectors are one-off and must not be reused. It also says that when you provide a custom Executor you are responsible for its lifecycle management, since the collectors default to virtual threads but do not manage a supplied executor for you.

When should I use parallelToStream instead of parallel?

The README's decision flow asks whether you are willing to block the caller thread while waiting for processing to finish, and whether element order matters. The parallel family returns a CompletableFuture, while the parallelToStream family returns a Stream and can be made ordered through the ordered() configurer option.

Official sources

  1. License: Apache-2.0
  2. pivovarit/parallel-collectors on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/pivovarit-parallel-collectors.svg)](https://hysenlabs.com/projects/pivovarit-parallel-collectors)

Was this useful?