Semaphore
A Semaphore controls access to a shared resource by maintaining a set of permits. Threads acquire permits before accessing the resource and release them when done. Built on AQS shared mode.
Document Structure:
- [Basic Usage](#Basic Usage) / [AQS State](#AQS State) --- Overview
- [acquire() Deep Dive](#acquire() Deep Dive) --- Call flow, tryAcquireShared, fair vs unfair, enqueue, park, timeout, interrupt
- [release() Deep Dive](#release() Deep Dive) --- Call flow, tryReleaseShared, wake-up engine, PROPAGATE cascade
- [Shared AQS Internals](#Shared AQS Internals) --- The PROPAGATE(-3) race, propagate==0 problem
- [Behavior & Patterns](#Behavior & Patterns) --- No ownership, common patterns, comparison
- FAQ --- Common questions answered
Basic Usage
java
Semaphore semaphore = new Semaphore(3); // 3 permits
semaphore.acquire(); // blocks if no permits available
try {
accessResource();
} finally {
semaphore.release(); // return permit
}
// Non-blocking (no wait, no enqueue)
if (semaphore.tryAcquire()) {
try { accessResource(); }
finally { semaphore.release(); }
} else {
handleNoPermit();
}
// Timed
if (semaphore.tryAcquire(5, TimeUnit.SECONDS)) {
try { accessResource(); }
finally { semaphore.release(); }
}
// Acquire multiple permits at once
semaphore.acquire(2); // need 2 permits
try { accessHeavyResource(); }
finally { semaphore.release(2); }
AQS State = Available Permits
Semaphore(3):
state = 3 -> 3 permits available
acquire(): CAS(state, 3, 2) -> success, 2 permits left
acquire(): CAS(state, 2, 1) -> success, 1 permit left
acquire(): CAS(state, 1, 0) -> success, 0 permits left
acquire(): state == 0 -> enqueue -> park (wait for release)
release(): CAS(state, 0, 1) -> 1 permit -> unpark waiting thread
Key difference from CountDownLatch: Semaphore's state goes both directions (acquire decrements, release increments). CountDownLatch only decrements and never resets.
acquire() Deep Dive
Everything related to acquire(): the call flow, fair vs unfair tryAcquireShared, enqueue, parking, timeout, and interrupt handling.
acquire() Call Flow
acquire()
-> sync.acquireSharedInterruptibly(1)
-> tryAcquireShared(1) <- TRY TO GRAB PERMIT (non-blocking CAS)
remaining >= 0 -> success (got permit)
remaining < 0 -> fail (not enough permits)
-> doAcquireSharedInterruptibly(1) <- only entered if no permits
-> addWaiter(SHARED) <- enqueue node into CLH queue
-> spin loop {
-> tryAcquireShared(1) <- RE-CHECK permits after each wake-up
-> shouldParkAfterFailedAcquire()
-> parkAndCheckInterrupt() <- ACTUAL WAIT
}
-> setHeadAndPropagate() <- on success: promote to head, maybe wake next
-> acquire() returns
-> accessResource()
Same AQS machinery as CountDownLatch's await(). The difference is in tryAcquireShared --- Semaphore actually consumes a permit (CAS decrement), while CountDownLatch just checks if state == 0.
acquire() Source Code
java
// Semaphore.acquire()
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
// AQS.acquireSharedInterruptibly() --- same as CountDownLatch
public final void acquireSharedInterruptibly(int arg) {
if (Thread.interrupted()) throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
tryAcquireShared --- Fair vs Unfair
Semaphore has two implementations. The default is unfair.
Unfair (default):
java
int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0)
return remaining; // not enough permits -> fail
if (CAS(state, available, remaining))
return remaining; // success -> remaining permits
}
}
Fair:
java
int tryAcquireShared(int acquires) {
for (;;) {
if (hasQueuedPredecessors())
return -1; // someone waiting -> don't barge
int available = getState();
int remaining = available - acquires;
if (remaining < 0)
return remaining;
if (CAS(state, available, remaining))
return remaining;
}
}
The only difference: fair version calls hasQueuedPredecessors() first. If there are threads already waiting in the queue, the incoming thread yields and enqueues behind them instead of grabbing the permit.
Return value semantics --- critical difference from CountDownLatch:
| Return value | Meaning | What happens next |
|---|---|---|
| Positive (e.g., 2) | Acquired, permits remain for others | setHeadAndPropagate -> propagate (wake next) |
| Zero (0) | Acquired, but NO permits left | setHeadAndPropagate -> propagate ONLY if PROPAGATE breadcrumb exists |
| Negative (e.g., -1) | Not enough permits | Enqueue and park |
The return 0 case is where PROPAGATE matters. CountDownLatch always returns 1 or -1, never 0. Semaphore can return 0 when the last permit is taken.
hasQueuedPredecessors() --- Fair Mode Gate
java
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
Returns true if there are threads queued before us. The check:
h != t--- queue is not emptys == null--- a node is mid-enqueue (next link not set yet) -> assume someone is waitings.thread != currentThread--- head's successor is a different thread -> someone is ahead of us
If s.thread == currentThread, we ARE the head's successor (reentrant acquire or woke up and re-trying) -> don't block ourselves.
Timed acquire --- tryAcquire(timeout, unit)
java
// Semaphore.tryAcquire(timeout, unit)
public boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
// AQS.tryAcquireSharedNanos
public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) {
if (Thread.interrupted()) throw new InterruptedException();
return tryAcquireShared(arg) >= 0 || // fast path: permit available
doAcquireSharedNanos(arg, nanosTimeout); // slow path: wait with timeout
}
doAcquireSharedNanos is the timed version of doAcquireSharedInterruptibly:
java
private boolean doAcquireSharedNanos(int arg, long nanosTimeout) {
if (nanosTimeout <= 0L) return false;
final long deadline = System.nanoTime() + nanosTimeout;
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null;
failed = false;
return true; // <- SUCCESS
}
}
nanosTimeout = deadline - System.nanoTime();
if (nanosTimeout <= 0L)
return false; // <- TIMEOUT: return false
if (shouldParkAfterFailedAcquire(p, node) &&
nanosTimeout > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout); // <- timed park
if (Thread.interrupted())
throw new InterruptedException(); // <- INTERRUPT: throw
}
} finally {
if (failed) cancelAcquire(node); // <- cleanup on timeout or interrupt
}
}
Three exit paths:
| Exit | How | Return / Throw | cancelAcquire called? |
|---|---|---|---|
| Success | tryAcquireShared >= 0 |
return true |
No (failed = false) |
| Timeout | nanosTimeout <= 0 |
return false |
Yes |
| Interrupt | Thread.interrupted() |
throw InterruptedException |
Yes |
spinForTimeoutThreshold: If remaining time is very short (< 1000ns), the thread spin-waits instead of parking. Parking has OS overhead (~1-10us), so for sub-microsecond waits, spinning is cheaper.
Interrupt Handling
Same as CountDownLatch --- acquire() uses acquireSharedInterruptibly, which responds to interrupts:
java
semaphore.acquire(); // throws InterruptedException if interrupted while waiting
If interrupted while parked:
parkAndCheckInterrupt()returnstruedoAcquireSharedInterruptiblythrowsInterruptedExceptioncancelAcquire(node)removes the node from the queue- The permit count is unaffected --- the thread never acquired a permit
release() Deep Dive
Everything related to release(): the CAS increment, wake-up engine, and propagation cascade.
release() Call Flow
release()
-> sync.releaseShared(1)
-> tryReleaseShared(1) <- CAS INCREMENT (non-blocking)
CAS(state, current, current+1)
-> always returns true
-> doReleaseShared() <- WAKE first waiter (always called)
-> unparkSuccessor(head) <- unpark head's successor
-> release() returns
Key difference from CountDownLatch's countDown(): tryReleaseShared always returns true for Semaphore (every release adds a permit). CountDownLatch's tryReleaseShared only returns true on the 1->0 transition.
tryReleaseShared --- The CAS Increment
java
boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current) throw new Error("Maximum permit count exceeded"); // overflow
if (CAS(state, current, next))
return true; // always true -> AQS always calls doReleaseShared()
}
}
- Always returns
true--- everyrelease()triggersdoReleaseShared() - The overflow check (
next < current) catches integer overflow when permits exceedInteger.MAX_VALUE - CAS loop handles concurrent releases safely
doReleaseShared() --- The Wake-Up Engine
Same code as CountDownLatch (shared AQS machinery). See count-down-latch.md for the full deep dive on doReleaseShared, h == head exit condition, and concurrent callers.
java
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!CAS(h.waitStatus, SIGNAL, 0))
continue;
unparkSuccessor(h); // wake next thread
}
else if (ws == 0 &&
!CAS(h.waitStatus, 0, PROPAGATE))
continue; // set PROPAGATE breadcrumb
}
if (h == head) break;
}
}
Important : doReleaseShared() only unparks one thread per invocation. The "wake multiple" effect is a cascade: each woken thread calls setHeadAndPropagate -> doReleaseShared(), which unparks the next one. See the cascade walkthrough below.
The Propagation Cascade
When permits are released, the cascade wakes threads one by one. The key: propagation continues as long as propagate > 0 (permits remain) or the PROPAGATE breadcrumb is set.
Semaphore(0), 3 threads waiting, then release(3):
Initial:
state = 0
Queue: head -> [T-1 SIGNAL] -> [T-2 SIGNAL] -> [T-3, 0] <- tail
release(3): CAS(state, 0, 3) -> state = 3
-> doReleaseShared() -> unpark T-1
T-1 wakes:
tryAcquireShared(1) -> remaining = 2 (positive!)
-> setHeadAndPropagate(node, propagate=2)
-> propagate > 0 -> doReleaseShared() -> unpark T-2
T-2 wakes:
tryAcquireShared(1) -> remaining = 1 (positive!)
-> setHeadAndPropagate(node, propagate=1)
-> propagate > 0 -> doReleaseShared() -> unpark T-3
T-3 wakes:
tryAcquireShared(1) -> remaining = 0 (zero!)
-> setHeadAndPropagate(node, propagate=0)
-> propagate == 0 -> condition (a) fails
-> check h.waitStatus < 0 -> if PROPAGATE breadcrumb exists, still propagate
-> no more waiters -> doReleaseShared() finds head == tail -> done
All 3 threads acquired permits
Queue: head(nodeT3) <- tail (empty)
state = 0
The propagate == 0 case is where Semaphore differs from CountDownLatch. CountDownLatch always returns 1 (propagate > 0), so condition (a) always fires. Semaphore can return 0, requiring the PROPAGATE fallback.
setHeadAndPropagate --- Three Paths in Semaphore
Same code as CountDownLatch, but Semaphore exercises all three paths:
java
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // save old head
setHead(node); // promote us to head
if (propagate > 0 // (a) permits remain
|| h == null // (b) defensive
|| h.waitStatus < 0 // (c) old head has PROPAGATE or SIGNAL
|| (h = head) == null // (d) defensive re-read
|| h.waitStatus < 0) // (e) new head has PROPAGATE or SIGNAL
{
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
Path 1: propagate > 0 --- permits remain (e.g., release(3), first two acquirers)
tryAcquireShared(1) -> remaining = 2 -> propagate = 2
propagate > 0 -> condition (a) fires -> doReleaseShared() -> unpark next
Straightforward. Same as CountDownLatch (which always takes this path).
Path 2: propagate == 0, PROPAGATE breadcrumb exists --- last permit taken, concurrent release happened
tryAcquireShared(1) -> remaining = 0 -> propagate = 0
propagate > 0? NO
h.waitStatus < 0? -> old head has PROPAGATE(-3) -> YES -> doReleaseShared() -> unpark next
This is the PROPAGATE race scenario. A concurrent release() set PROPAGATE on the old head. Without this check, the release signal would be lost.
Path 3: propagate == 0, no breadcrumb --- last permit taken, no concurrent release
tryAcquireShared(1) -> remaining = 0 -> propagate = 0
propagate > 0? NO
h.waitStatus < 0? -> old head ws == 0 -> NO
h.waitStatus < 0? -> new head ws == 0 -> NO
-> ALL conditions fail -> skip doReleaseShared() -> no propagation
Correct --- no permits left, no concurrent release, don't wake anyone.
Decision table:
propagate |
Old head ws | New head ws | Propagate? | Why |
|---|---|---|---|---|
| > 0 | any | any | Yes | Permits remain |
| 0 | PROPAGATE(-3) | any | Yes | Concurrent release detected via old head |
| 0 | 0 | PROPAGATE(-3) | Yes | Concurrent release detected via new head |
| 0 | >= 0 | >= 0 | No | No permits, no concurrent release |
PROPAGATE Behavior: CountDownLatch vs Semaphore
The code is identical (shared AQS). The behavior differs because of tryAcquireShared return values:
| CountDownLatch | Semaphore | |
|---|---|---|
PROPAGATE set by doReleaseShared? |
Yes (same code) | Yes (same code) |
propagate value |
Always 1 | Can be 0 |
| Condition (a) fires? | Always | Not when propagate == 0 |
| PROPAGATE breadcrumb consumed? | Never (condition a short-circuits) | Yes (conditions c/e catch it) |
| Lost wake-up possible without PROPAGATE? | No | Yes |
Shared AQS Internals
The PROPAGATE (-3) Race --- Full Scenario
This is the race we deferred from count-down-latch.md. It only manifests when tryAcquireShared returns 0.
Setup: Semaphore(1), T-1 and T-2 waiting, two releasers T-A and T-B.
Timeline:
1. T-A: release() -> state 0->1 -> doReleaseShared()
head.ws == SIGNAL -> CAS(SIGNAL, 0) -> unparkSuccessor -> unpark T-1
2. T-1 wakes: tryAcquireShared(1) -> state 1->0, return 0 (propagate=0)
(T-1 took the LAST permit --- propagate is 0, meaning "no more permits")
3. T-B: release() -> state 0->1 -> doReleaseShared()
head.ws == 0 (T-A already cleared it in step 1)
-> CAS(0, PROPAGATE) <- leaves breadcrumb
-> h == head -> break -> return
(T-B does NOT unpark anyone --- ws was 0, not SIGNAL)
4. T-1 (still in setHeadAndPropagate):
h = head (old head, which now has ws = PROPAGATE = -3)
setHead(nodeT1)
propagate == 0 -> condition (a) FAILS
h.waitStatus == -3 < 0 -> condition (c) FIRES!
-> doReleaseShared() -> head.ws == SIGNAL (set by T-2 during enqueue)
-> CAS(SIGNAL, 0) -> unparkSuccessor -> unpark T-2
5. T-2 wakes: tryAcquireShared(1) -> state 1->0, return 0 -> success!
Without PROPAGATE : Step 3 would do nothing (ws0, no breadcrumb). Step 4: T-1 sees propagate0, all conditions fail, stops. T-2 stays parked forever even though T-B released a permit. Lost wake-up bug.
The timing window:
T-A's doReleaseShared() T-1 (waking up) T-B's doReleaseShared()
----- ----- -----
CAS(SIGNAL -> 0)
unparkSuccessor(T-1)
h == head -> break
tryAcquireShared -> 0
(about to setHeadAndPropagate)
head.ws == 0
CAS(0 -> PROPAGATE)
h == head -> break
setHeadAndPropagate:
h.ws == PROPAGATE < 0
-> condition (c) fires!
-> doReleaseShared()
-> unpark T-2
PROPAGATE Lifecycle Summary
| Step | Who | What | Why |
|---|---|---|---|
| Set | doReleaseShared() |
CAS(head.ws, 0, PROPAGATE) | "A release happened but successor was already unparked" |
| Read | setHeadAndPropagate() |
h.waitStatus < 0 check |
"Did a release happen while I was acquiring?" |
| Consumed | setHeadAndPropagate() |
Calls doReleaseShared() |
"Yes --- keep propagating to avoid lost wake-up" |
Behavior & Patterns
No Ownership
Unlike ReentrantLock, Semaphore has no concept of ownership. Any thread can release a permit, even if it didn't acquire one:
java
Semaphore sem = new Semaphore(0);
// Thread-A:
sem.release(); // permits = 1 (Thread-A never acquired!)
// Thread-B:
sem.acquire(); // succeeds --- gets the permit Thread-A created
This is by design --- Semaphore is a counter, not a lock. But it means permits can leak if you forget to release, or over-release:
java
// BUG: release without acquire -> permits grow unbounded
for (int i = 0; i < 1000; i++) {
sem.release(); // oops, should have been acquire + release
}
Fair vs Unfair --- When to Use
java
Semaphore unfair = new Semaphore(3); // default: unfair
Semaphore fair = new Semaphore(3, true); // fair: FIFO ordering
| Unfair (default) | Fair | |
|---|---|---|
| Behavior | Incoming thread can grab permit even if others are queued | Strictly FIFO --- new arrivals queue behind waiting threads |
| Throughput | Higher (less context switching) | Lower (always enqueues) |
| Starvation | Possible (unlucky threads keep losing to newcomers) | Impossible (guaranteed ordering) |
| Use when | Performance matters, starvation is acceptable | Fairness required, latency SLA per request |
Common Patterns
Connection Pool / Rate Limiting
java
class ConnectionPool {
private final Semaphore semaphore;
private final Queue<Connection> pool;
ConnectionPool(int maxConnections) {
semaphore = new Semaphore(maxConnections);
pool = new ConcurrentLinkedQueue<>();
for (int i = 0; i < maxConnections; i++)
pool.add(createConnection());
}
Connection acquire() throws InterruptedException {
semaphore.acquire(); // block if all connections in use
return pool.poll();
}
void release(Connection conn) {
pool.offer(conn);
semaphore.release(); // allow another thread in
}
}
Bounded Resource Access
java
// Allow max 5 concurrent API calls
Semaphore apiThrottle = new Semaphore(5);
void callApi() throws InterruptedException {
apiThrottle.acquire();
try {
httpClient.call(endpoint);
} finally {
apiThrottle.release();
}
}
Binary Semaphore (Mutex)
java
Semaphore mutex = new Semaphore(1); // acts like a lock (but no ownership/reentrancy)
mutex.acquire();
try { criticalSection(); }
finally { mutex.release(); }
Semaphore vs CountDownLatch vs ReentrantLock
| Feature | Semaphore | CountDownLatch | ReentrantLock |
|---|---|---|---|
| Permits/count | N (configurable) | N (one-shot) | 1 (exclusive) |
| Direction | Both (acquire/release) | Down only (countDown) | Lock/unlock |
| Reusable | Yes | No | Yes |
| Ownership | No | No | Yes (thread-bound) |
| Reentrant | No | N/A | Yes |
| AQS mode | Shared | Shared | Exclusive |
tryAcquireShared returns 0? |
Yes (last permit) | Never (always 1 or -1) | N/A (exclusive) |
| PROPAGATE needed? | Yes (critical) | No (propagate always > 0) | N/A |
| Use case | Rate limiting, pools | Wait for N events | Mutual exclusion |
FAQ
Q: What happens if I release more than I acquire?
Permits grow beyond the initial count. Semaphore doesn't track ownership, so it can't prevent this:
java
Semaphore sem = new Semaphore(3);
sem.release(); // permits = 4 (no acquire first!)
sem.release(); // permits = 5
This is a bug in your code, not a Semaphore feature. Always pair acquire/release in try/finally.
Q: Can acquire(2) succeed if only 1 permit is available?
No. tryAcquireShared(2) computes remaining = available - 2. If available == 1, remaining == -1 < 0, so it returns -1 (fail). The thread enqueues and waits until 2 permits are available.
Q: Does release() always wake a thread?
release() always calls doReleaseShared(), but it only unparks a thread if head.waitStatus == SIGNAL. If no threads are waiting (head == tail), the permit is just added to the count for future acquirers.
Q: Why does unfair Semaphore have better throughput?
With unfair, an incoming thread can grab a permit without entering the queue. This avoids the overhead of: enqueue -> park -> context switch -> unpark -> dequeue. The thread that just released might immediately re-acquire on the same CPU core (cache-hot), which is much faster than waking a parked thread on a different core.
Q: How does timed tryAcquire differ from untimed acquire?
acquire() |
tryAcquire(timeout, unit) |
|
|---|---|---|
| AQS method | doAcquireSharedInterruptibly |
doAcquireSharedNanos |
| Park call | LockSupport.park(this) |
LockSupport.parkNanos(this, nanosTimeout) |
| On timeout | N/A (waits forever) | return false -> cancelAcquire |
| On interrupt | throw InterruptedException |
throw InterruptedException |
| On success | Returns (void) | return true |
| Spin optimization | No | Yes (spin if < 1000ns remaining) |