A Semaphore maintains a set of permits: acquire() takes one (blocking if none are available) and release() returns one. It is the standard tool for limiting concurrency — capping how many threads can use a resource at once, such as a bounded connection pool or a rate-limited downstream call.
Permits, not locks
A semaphore is a counter, not a mutual-exclusion lock. With N permits, up to N threads run concurrently in the guarded section. A Semaphore(1) behaves like a lock (a binary semaphore), but with a crucial difference: a semaphore has no ownership. Any thread can release() a permit it never acquired, and you can acquire in one thread and release in another. That flexibility is also a footgun — there is no reentrancy and no "wrong thread released" check.
class ConnectionPool {
private final Semaphore permits;
private final BlockingQueue<Connection> pool;
ConnectionPool(int size) {
this.permits = new Semaphore(size, true); // fair: FIFO
this.pool = new ArrayBlockingQueue<>(size);
// ... fill pool with `size` connections
}
Connection borrow() throws InterruptedException {
permits.acquire(); // block until a slot is free
return pool.take();
}
void giveBack(Connection c) {
pool.offer(c);
permits.release(); // hand the slot to the next waiter
}
}
Acquire/release in finally
The cardinal rule: pair every acquire() with a release() in a finally block, or a thrown exception leaks a permit and the pool slowly starves.
permits.acquire();
try {
callRateLimitedApi();
} finally {
permits.release();
}
Fairness, tryAcquire, and bulk permits
- Fairness —
new Semaphore(n, true)grants permits in FIFO order, preventing barging and starvation at the cost of throughput. The default (non-fair) is faster but can starve. - tryAcquire() — non-blocking, or with a timeout, so you can shed load instead of queuing forever.
- Bulk —
acquire(k)/release(k)take or return several permits atomically, useful for weighted resources.