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.

Advertisement

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.

Advertisement

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.