How can you limit the number of concurrent accesses to a resource?
That's the job of a semaphore. The JDK ships a Semaphore class for exactly this: capping how many threads can use a resource at the same time, in a way that says clearly, in the code itself, "I want to limit concurrent access to this."
Question
How can you limit the number of concurrent accesses to a resource?
What It Is
A semaphore holds a fixed number of permits. A thread calls acquire() before using the resource — which blocks if no permit is available — and release() when it's done, handing the permit back for another thread to use:
javavar semaphore = new Semaphore(10);
try {
semaphore.acquire();
scope.fork(Service::readData);
} finally {
semaphore.release();
}This is a much more explicit and intention-revealing approach than a common alternative: creating a dedicated ExecutorService with a fixed thread pool sized to the limit you want.
javavar executor = Executors.newFixedThreadPool(10);
executor.submit(Service::readData);That trick works, but it hides the real intent behind an unrelated abstraction (a thread pool), and it's easy to misuse or lose track of as the codebase grows.
A Stream-Based Alternative
Since Java added stream gatherers (JEP 485), there's a third option for this exact problem when you're processing a stream of requests: Gatherers.mapConcurrent(maxConcurrency, mapper).
You pass it a maximum concurrency and a mapping function. Each mapping runs concurrently, but the number of mappings actively running at any given moment never exceeds maxConcurrency — internally, it's backed by the same kind of permit-based limiting a semaphore provides, without you having to manage one yourself.
Practical Example
javavar requests = List.of(/* your requests */);
var results = requests.stream()
.gather(Gatherers.mapConcurrent(
10, // max concurrency
Service::readData))
.toList();Here, up to 10 calls to Service::readData run concurrently; the rest wait their turn automatically as slots free up.
Solution and Conclusion
Prefer Semaphore when you need explicit, reusable control over access to a resource across arbitrary code paths. Prefer Gatherers.mapConcurrent when the work is naturally expressed as a stream of requests being mapped to responses — it keeps the code simpler and the intent obvious.
One important caveat: never combine this gatherer with a parallel stream — the two concurrency mechanisms fight each other and the result is a mess. Stick to a plain sequential stream; mapConcurrent already manages the concurrency for you.