A Semaphore maintains a set of permits. A thread calls acquire() to take a permit (blocking if none are free) and release() to return one. Where a lock enforces one thread at a time, a semaphore enforces at most N — which makes it the natural tool for bounding access to a limited resource: a connection pool, a rate limiter, a fixed number of concurrent downloads.
Permits, acquire, release
Construct with the number of permits. acquire() blocks until one is available and takes it; release() hands one back. The permit count is just an integer the semaphore guards atomically.
Semaphore sem = new Semaphore(3); // at most 3 threads in the section at once
sem.acquire();
try {
useLimitedResource();
} finally {
sem.release(); // ALWAYS release, even on exception
}As with an explicit lock, the release() belongs in a finally. But note a semaphore has no notion of ownership: any thread can release a permit, and a thread can acquire several. That flexibility is powerful and dangerous — a double release() silently adds a permit that was never taken, letting one too many threads in.
Bounding a resource pool
The archetypal use: cap concurrent access to N identical resources. The semaphore gates entry; the actual resources live in a thread-safe collection.
class ConnectionPool {
private final Semaphore permits;
private final BlockingQueue<Conn> pool;
Conn borrow() throws InterruptedException {
permits.acquire(); // block until a slot is free
return pool.take(); // take an actual connection
}
void giveBack(Conn c) {
pool.offer(c);
permits.release(); // free the slot
}
}The semaphore count and the pool size must stay in lockstep — every acquired permit corresponds to exactly one borrowed resource. This is why the release pairs with the return, not with anything else.
tryAcquire: non-blocking and timed
tryAcquire() takes a permit only if one is immediately free, returning a boolean; tryAcquire(timeout) waits up to a bound. This turns 'wait forever for a slot' into 'serve a 429 Too Many Requests if the system is saturated' — the backbone of a bounded admission control or a rate limiter.
if (sem.tryAcquire(100, TimeUnit.MILLISECONDS)) {
try { handle(); } finally { sem.release(); }
} else {
rejectAsOverloaded(); // fail fast instead of piling up
}You can also acquire and release multiple permits at once (acquire(n)/release(n)) — useful when a single task consumes a weighted share of a resource budget.
Fairness and starvation
Like ReentrantLock, a semaphore can be fair (new Semaphore(n, true)), handing permits out in FIFO order, or unfair (default), allowing barging. Unfair is faster; fair prevents a steady stream of new arrivals from starving a long-waiting thread. For a resource pool where every waiter is equivalent, unfair is usually fine; for a fairness-sensitive queue (say, request admission where order matters), fair mode earns its cost.
Binary semaphore vs lock
A Semaphore(1) permits one thread at a time, so it looks like a lock — but it is not a reentrant lock and, crucially, has no owner. A real lock can only be released by the thread that holds it and can be re-acquired by that same thread (reentrancy). A binary semaphore can be released by a different thread than the one that acquired it. That property is occasionally exactly what you want — a signalling handoff where thread A takes the permit and thread B releases it — but if you just need mutual exclusion, use a lock; the ownership check catches bugs a semaphore would let through.
Permit leaks: the failure mode to watch
The defining bug is the leaked permit: an acquire whose matching release is skipped (an exception path without a finally, an early return). Each leak permanently shrinks the effective pool; enough of them and the semaphore is exhausted and every thread blocks forever. Because there is no ownership, the runtime cannot detect this for you. Keep acquire/release strictly paired in try/finally, prefer a small wrapper that guarantees the pairing, and if a pool mysteriously 'shrinks' under load, suspect a leaked permit on an error path first.
Semaphore caps concurrency at N permits — the tool for bounded resource pools and admission control. Pair acquire()/release() in try/finally, use tryAcquire(timeout) to fail fast instead of piling up, and remember it has no owner: any thread can release, so a leaked or double release silently corrupts the count. For plain mutual exclusion use a lock, not a binary semaphore.