A Semaphore is a counting set of permits. Threads ask for a permit before doing work, and they hand it back when they're done. If no permit is available, the asker waits. This lesson covers what a semaphore is, how acquire, release, and tryAcquire work, when to use a single-permit semaphore as a non-reentrant mutex, when to use a multi-permit semaphore as a throttle, what the fairness flag does, and the very common bug of leaking permits on failure paths.
The previous chapters covered Lock and ReentrantLock for mutual exclusion, and ReadWriteLock for read-heavy scenarios. A semaphore is a different tool. Locks protect a single critical section. Semaphores count resources. If your goal is "only one thread at a time," a ReentrantLock is appropriate. If your goal is "no more than ten threads at once" or "I have eight database connections and I want to hand them out," that's a semaphore.
A Semaphore is constructed with a number, the permit count. That number is the upper limit on how many threads can be inside the protected section at the same time. A new permit isn't an object. It's just a slot in an internal counter. When a thread calls acquire, the counter goes down by one. When it calls release, the counter goes up by one. When the counter is zero, callers to acquire block until somebody else releases.
The counter starts at three because new Semaphore(3) asked for three permits. Each acquire knocks one off. After three acquires the counter is zero, so a fourth would block. One release puts a permit back, lifting the counter to one and unblocking one waiter if there was one.
The bucket holds the permits. Threads that hold a permit are inside the protected section. Threads that called acquire while the bucket was empty are parked in a queue, waiting for somebody to put a permit back. Once release runs, one waiting thread gets the permit, wakes up, and continues.
A semaphore doesn't care which thread releases a permit. The same thread that called acquire can release, or a completely different thread can. The counter is the only state that matters. That's a deliberate design choice and one of the biggest differences from a Lock, which insists that only the owning thread can unlock.
acquire() throws InterruptedException if the thread is interrupted while waiting. To ignore interrupts, use acquireUninterruptibly(). Mixing them up means a graceful shutdown can't stop a stuck thread.
acquire, release, and availablePermitsThe three most-used methods are acquire, release, and availablePermits. Their job titles:
| Method | What it does |
|---|---|
acquire() | Takes one permit. Blocks if none are available. Throws on interrupt. |
acquire(int n) | Takes n permits at once. Blocks until all n are available. |
release() | Returns one permit. Wakes up a waiter if there is one. |
release(int n) | Returns n permits. Useful for symmetry with acquire(n). |
availablePermits() | Returns the current counter value. For monitoring, not for decisions. |
availablePermits() is tempting to use as a "is it safe to proceed" check, but the value can change between the check and the next call. The reliable way to take a permit is to call acquire (which blocks) or tryAcquire (which returns immediately with a boolean).
A small program that has three worker threads sharing a two-permit semaphore. Only two workers can be inside the protected block at a time.
Output (order of W1/W2 may vary):
W1 and W2 acquire the two available permits and start working. W3 calls acquire and blocks because the counter is zero. After 200 ms, W1 and W2 each release, the counter goes back up, and W3 gets in. The exact order of W1 and W2 isn't fixed; the JVM scheduler picks.
The try/finally shape matters. If anything inside the block throws, the finally still runs and the permit goes back. Without that, an exception leaves the permit checked out forever, and after a few failures the semaphore is permanently empty.
A semaphore with one permit acts like a mutual exclusion lock. Only one thread can hold the permit at a time. Take the permit, do the work, return the permit. That's the same pattern as synchronized or ReentrantLock.
Two threads each increment orderCount a thousand times, and the final count is exactly 2000 because the binary semaphore serializes the read-modify-write. Without the semaphore, the increment is not atomic, and you'd see a number less than 2000 most of the time.
This works, but it isn't what semaphores are best at. There's an important difference between a binary semaphore and a ReentrantLock: a semaphore is not reentrant. If the same thread calls acquire twice on a one-permit semaphore without an intermediate release, it deadlocks itself.
outer takes the single permit, then calls inner, which tries to take it again. The same thread is now waiting for itself to release, which it can't because it's stuck inside inner. A ReentrantLock (or synchronized) would let the same thread re-enter and count the holds. A semaphore doesn't track who holds what; it just counts permits, and the counter is zero.
That's the first reason semaphores aren't usually appropriate for mutual exclusion. The second is that a semaphore has no owner. With ReentrantLock or synchronized, only the owner thread can release. With a semaphore, any thread can call release and increment the counter, even if it never called acquire. That's flexible, but it makes accidental bugs (a misplaced release in code that never acquired) much easier to write.
Use ReentrantLock (or synchronized) for mutual exclusion. Use Semaphore when you actually want to count things.
This is where semaphores are well-suited. Consider an e-commerce checkout service that calls a third-party payment service to charge a card. That third-party service has rate limits: it accepts at most ten concurrent requests from your account. If you send eleven at once, the eleventh gets rejected with an error. To make sure you never have more than ten in flight, even if a thousand checkouts hit your server at the same time, use a semaphore.
A semaphore with ten permits does exactly that.
Output (in-flight count never exceeds 10):
Twenty-five fake charges fly at the service, but the semaphore caps the number running at once at ten. Each call to chargeCard blocks on acquire if all ten slots are taken, runs when a slot opens up, and releases its slot in the finally. The third-party service never sees more than ten concurrent requests, regardless of how many threads call chargeCard.
The semaphore sits between your code and the downstream service. It enforces "no more than ten at once" without your callers having to coordinate. Each caller just acquires before the request and releases after.
A second common throttle is limiting concurrent inventory-sync jobs. Suppose a job runs every minute that pulls stock counts from a warehouse system into your own database. Each job is heavy: it reads a few thousand rows, compares them to your local copy, and writes any differences. If five jobs are still running when the next minute's job starts, the warehouse system gets overwhelmed and starts timing out. The goal is at most two sync jobs running at the same time.
Output (at most two regions run concurrently):
Even though the thread pool has ten threads ready to go, the semaphore lets only two run sync work at a time. Note that the pool is still useful: it's what runs the tasks. The semaphore is what caps concurrency at a smaller number than the pool size. This is a common combo. The pool decides which thread runs the task, and the semaphore decides how many tasks of this kind can be running together.
tryAcquire with a TimeoutBlocking forever on acquire is fine in some places and a disaster in others. If a user clicks "Pay Now" and your server is currently holding all ten payment slots for slow calls, you don't want the user's request to sit there for thirty seconds. Better to time out after, say, two seconds and tell the user "We're busy, try again in a moment."
tryAcquire is the method for that. It comes in three flavors:
| Call | Behavior |
|---|---|
tryAcquire() | Returns true if a permit is available right now, false otherwise. |
tryAcquire(long timeout, TimeUnit unit) | Waits up to timeout for a permit. Returns true on success, false on timeout. |
tryAcquire(int permits, long t, TimeUnit u) | Same as above, but asks for permits permits at once. |
The version with a timeout is the most common. The payment example with a two-second cap:
ORD-1 and ORD-2 grab the two slots. ORD-3 calls tryAcquire with a two-second window. It happens to get in within that window (the others finish in 500 ms), so it proceeds normally. If the others had each held their permits for three seconds, ORD-3 would have hit the timeout and printed ORD-3 timed out waiting for a slot, returning false so the caller can decide what to do next (retry, fail the request, queue for later).
One detail worth noting. The if (acquired) check in the finally block matters. release() always increments the counter, even if you never acquired. If tryAcquire returned false (timeout) and you call release anyway, you've created a permit out of thin air. The next acquirer takes it, and now you have one more concurrent caller than the semaphore promised. The acquired boolean keeps release paired correctly with acquire.
Forgetting to gate the release call on a successful acquire is a real bug. It inflates the permit pool and breaks the throttle without any indication. The signal that it happened is downstream errors, not anything the semaphore itself reports.
The other family member, plain tryAcquire() with no timeout, is for "take a permit if and only if one is free right now." Use it when there's a fallback that doesn't need a permit, or when missing the slot means doing nothing instead of waiting.
The bug that bites every team that uses semaphores is forgetting release on a failure path. The pattern looks innocent.
What's wrong with this code?
If chargeOrThrow throws, the line slots.release() never runs. The permit is checked out and never comes back. After three failures, the semaphore counter is permanently zero, and every future caller blocks forever on acquire. The service looks healthy at startup, but degrades as failures eat the permits one by one.
Fix:
The try/finally is the entire fix. release runs whether the body succeeded or threw. If the call throws, the exception still propagates to the caller, but the permit goes home first.
The diagram traces what happens without try/finally. Each thrown exception subtracts one from the available permit count without warning. The semaphore never reports the leak; the only symptom is that things get slower over time and eventually stop entirely.
A second variation of the leak is calling release more than once for one acquire. That's the opposite mistake. The counter grows past its starting value, and the throttle stops throttling. Both directions of the bug come from breaking the rule that every `acquire` is paired with exactly one `release` in the same logical scope.
A useful habit: write the release immediately when you write the acquire, in a try/finally shape, before writing anything between them.
By default, a semaphore makes no promises about the order in which waiting threads acquire permits. When release adds a permit, the runtime picks one of the parked threads to wake. The choice is "barging-allowed": a thread that calls acquire at the exact moment a permit becomes free can grab it before threads that have been waiting longer.
That's usually fine. Sometimes it isn't. When handing out database connections and one thread has been waiting for a slot for a long time, guaranteed FIFO order may be desirable, even if it costs some throughput.
The constructor takes a second argument:
When fair is true, threads acquire permits strictly in the order they called acquire. New arrivals queue behind any existing waiters, even if there's currently a permit free.
Output (rough order, with fairness):
With fairness on, the threads rotate through the permit in roughly the order they joined. With fairness off, you'd see runs of A grabbing it back-to-back because A is already running on a hot CPU and beats B and C to the next acquire call.
Fairness has a cost. Maintaining strict order requires extra coordination, which slows down both acquire and release by a small amount. For most throttle scenarios, the default (unfair) mode is fine, and you save a bit of overhead. Turn on fairness when:
| Mode | Behavior | Throughput |
|---|---|---|
| Unfair (default) | Barging allowed. Whoever runs at the right moment wins. | Higher under contention. |
Fair (true) | FIFO order across waiting threads. No barging. | Slightly lower overhead. |
Neither mode prevents the permit leak. Fairness only affects the order in which threads pick up permits, not whether the permits exist to be picked up.
Semaphores are designed for counting resources. They make a poor fit for mutual exclusion in most cases, for three reasons.
No ownership. With synchronized or ReentrantLock, the runtime tracks which thread holds the lock. Calling unlock from a thread that doesn't own the lock throws IllegalMonitorStateException. With a semaphore, anyone can call release. If you release without acquiring, the counter goes up, and now you have a phantom permit floating in the system. The lack of ownership is great for cases like "thread A acquires, thread B releases" (a producer-consumer signal), but it's error-prone for mutual exclusion.
No reentrance. A thread that already holds the single permit can't take it again. ReentrantLock tracks the hold count and lets the same thread re-enter. A binary semaphore deadlocks itself the moment a method calls another method that also acquires.
No condition variables. ReentrantLock works with Condition objects so you can express "wait until the inventory is restocked" cleanly. A semaphore has no equivalent. You could fake it with extra permits, but the result is usually harder to reason about than Condition.
| Feature | ReentrantLock | Semaphore |
|---|---|---|
| Owned by acquiring thread | Yes | No (any thread can release) |
| Reentrant (same thread re-acquires) | Yes | No (deadlocks) |
| Counts resources | No (one holder at a time) | Yes (N holders at a time) |
Pairs with Condition | Yes | No |
| Designed for mutual exclusion | Yes | Not primarily (single-permit form is awkward) |
| Designed for resource throttling | No | Yes |
The decision is mostly mechanical. "Only one thread inside the section at a time" is ReentrantLock (or synchronized). "At most N threads inside, where N is bigger than 1" is Semaphore. The edge case is N equals 1 and you really do need a non-reentrant lock that can be released by a different thread, in which case a binary semaphore is the cleaner choice. That edge case shows up in producer-consumer signaling and a few similar patterns, but it's the exception, not the rule.
A related contrast is with CountDownLatch. A semaphore can have permits added back via release. A latch counts down and never resets. For "wait for three things to finish," use a latch. For "limit to three concurrent things," use a semaphore. Same number, different shape.
A small end-to-end e-commerce throttling example. The setup: an order pipeline that calls a third-party payment service and a separate inventory service. The payment service allows ten concurrent calls; the inventory service allows three. Both are throttled with their own semaphores, and every acquire is paired with a guaranteed release.
Output (order varies, but never more than 10 payments or 3 inventory calls at once):
Each order takes a payment slot, then an inventory slot, then finishes. Both throttles use tryAcquire with a timeout so a slow downstream doesn't cause requests to hang forever. The finally blocks make sure the permits go back on every path, including timeouts and exceptions. The thread pool has twenty workers, but at any moment at most ten are inside payment and at most three are inside inventory; the rest are either between calls or parked on acquire.
This is the shape of most semaphore use. The semaphore isn't protecting a critical section; it's limiting how many concurrent calls go to a thing that doesn't like too many at once. The semaphore is a pressure valve.
10 quizzes