Using Java's Semaphore to Cap Concurrency Without Reinventing a Pool
Java's Semaphore caps concurrent access with a simple acquire/try/finally-release pattern. This guide shows a worked rate-limiter example, fairness and tryAcquire options, and the permit-leak and deadlock mistakes to avoid.
01 Oct 2025, 15:02 UTC

When you need to limit how many threads hit a resource at once — a rate-limited API, a fixed number of database connections, a bounded set of file handles — Java's java.util.concurrent.Semaphore is usually the right tool, and it is simpler than building your own counter-plus-lock. A semaphore holds a fixed number of permits. A thread calls acquire() to take one; if none are free, it blocks until another thread calls release(). That is the whole mechanism, and most production uses are just that pattern wrapped in try/finally.
The one pattern that matters: acquire, try, finally release
Assume Java 17 or later (the API below has been stable since Java 5). The following example caps concurrent calls to an external service at three, even if ten worker threads are running:
import java.util.concurrent.Semaphore;
public class RateLimitedClient {
// 3 permits, fair = true grants permits in FIFO arrival order
private final Semaphore permits = new Semaphore(3, true);
public String callExternalService(String request) throws InterruptedException {
permits.acquire(); // blocks if 3 calls are already in flight
try {
return doHttpCall(request); // the work you are protecting
} finally {
permits.release(); // must run even if doHttpCall throws
}
}
private String doHttpCall(String request) {
// your real HTTP/RPC logic here
return "response";
}
}
Run this from any thread; no special permissions are needed. To see the throttling, launch ten threads that each call callExternalService and log entry and exit timestamps — you should observe at most three overlapping calls at any moment, and permits.availablePermits() should read 3 again after all threads finish.
Why the finally block is not optional
The single most common semaphore bug is a permit leak: an exception escapes between acquire() and release(), the permit is never returned, and over time the pool drains to zero. Once it does, every subsequent caller blocks forever — a failure that looks like a mysterious hang hours after deploy. Putting release() in finally guarantees the permit returns even on exceptions. Note that Semaphore is not tied to thread ownership the way ReentrantLock is: any thread may release a permit. That flexibility is useful but also means nothing stops buggy code from releasing permits it never acquired, inflating the count beyond the configured limit.
Counting versus binary, and fairness
A semaphore constructed with new Semaphore(1) behaves like a mutual-exclusion lock, but prefer ReentrantLock for that job — it gives you ownership checks, condition variables, and clearer intent. Counting semaphores shine for resource pools: size the permit count to the real capacity (for example, the maximum connections your downstream service tolerates, not an arbitrary round number). The fairness flag matters under contention: with fair = false (the default), a waiting thread can be overtaken by a newly arriving one, which improves throughput but can starve unlucky threads. With fair = true, permits are granted roughly first-in-first-out at a small performance cost. If you see intermittent starvation in load tests, fairness is the first knob to try.
tryAcquire for callers that must not block
Sometimes blocking is worse than failing — for example, a web request that should return HTTP 429 rather than queue. tryAcquire() returns immediately:
if (!permits.tryAcquire()) {
throw new TooManyRequestsException("at capacity");
}
try {
return doHttpCall(request);
} finally {
permits.release();
}
There is also tryAcquire(long timeout, TimeUnit unit), which waits up to a deadline — a good middle ground that also bounds how long a thread can hang if a leak does slip in. Note the ordering difference from the blocking version: you only enter the try (and therefore the finally that releases) if acquisition succeeded.
Limits and common mistakes
- Inconsistent acquisition order causes deadlock. If threads acquire multiple semaphores (say, A then B in one thread, B then A in another), they can block each other permanently. Always acquire in a fixed global order, or redesign so one semaphore suffices.
- Do not branch on the raw count.
availablePermits()is a snapshot; by the time you act on it, another thread may have changed it. Logic like "if permits > 0, proceed" is a race condition. Useacquire()ortryAcquire()as the decision point. - InterruptedException is real.
acquire()throws it; decide whether your thread should propagate the interrupt (usually yes, by rethrowing or restoring the flag) rather than swallowing it in an empty catch. - A semaphore is not a pool. It limits concurrency but does not manage the resources themselves. If you need object reuse with validation and lifecycle, use a real connection pool (e.g., HikariCP) — many pools already throttle internally, making an extra semaphore redundant.
How to verify it works
Write a test that fires more tasks than permits (say, 10 tasks against 3 permits), tracks the peak number of concurrently executing tasks with an AtomicInteger, and asserts the peak never exceeds 3. Then assert availablePermits() equals the initial count after all tasks complete — that second check is what catches permit leaks. Finally, exercise tryAcquire() while all permits are held and confirm it returns false immediately rather than blocking.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.