Java Tutorial
🔍

Semaphore

Semaphore

A Semaphore manages a set of permits rather than a single lock — where a lock allows exactly one thread in at a time, a Semaphore constructed with N permits allows up to N threads to hold access concurrently, making it the natural tool for capping access to a limited pool of interchangeable resources.

What Is a Semaphore?

java.util.concurrent.Semaphore is a counter of available permits, guarded so that acquire() and release() are safe under concurrent access from any number of threads. Unlike a lock, it has no concept of ownership — any thread can call release(), regardless of which thread called acquire().

Why Thread Safety Matters Here

A pool of desks, database connections, or worker slots has a hard physical or configured limit, and letting more than that many threads in at once is exactly the kind of bug that looks fine in a light test and then fails under real traffic — connections exhausted, desks double-booked, downstream systems overwhelmed. Capping concurrent access with a plain counter is a check-then-act race: two threads can both see room available before either actually claims a spot, and the failure only shows up once enough concurrent load hits it at the same time.

How It Works

One sentence before the diagram: this shows three permits handed out to the first three threads, with two more threads blocked until one is released.

Semaphore(3 permits)

Thread 1 -- acquire() --> [ permit 1 ] running
Thread 2 -- acquire() --> [ permit 2 ] running
Thread 3 -- acquire() --> [ permit 3 ] running
Thread 4 -- acquire() --> blocked, waiting for a permit
Thread 5 -- acquire() --> blocked, waiting for a permit

                 (once any running thread calls release(),
                  one waiting thread acquires the freed permit)

Semaphore enforces the cap atomically — the number of concurrent holders can never exceed the number of permits, no matter how many threads compete for them or how they happen to be scheduled.

acquire() blocks indefinitely until a permit is available. tryAcquire(), with or without a timeout, returns immediately or after a bounded wait instead. A Semaphore is not reentrant — the same thread trying to acquire again before releasing is treated exactly like any other competing thread.

Code Examples

Capping concurrent access with a plain counter is a check-then-act race — two threads can both see room available before either actually claims a spot.

1// File: BeforeSemaphore.java 2// Illustrative only - a plain counter checked and incremented as two 3// separate steps is a check-then-act race; two threads can both pass the 4// check before either increments, letting more than the intended number 5// of threads in at once. 6public class BeforeSemaphore { 7 8 static int activeUsers = 0; 9 static final int MAX_DESKS = 3; 10 11 static boolean tryEnter() { 12 if (activeUsers < MAX_DESKS) { 13 activeUsers++; 14 return true; 15 } 16 return false; 17 } 18 19 static void leave() { 20 activeUsers--; 21 } 22}

Semaphore enforces the cap atomically — acquire() blocks whenever no permit is available, and the number of concurrent holders can never exceed the number of permits, no matter how many threads compete for them.

1// File: SemaphoreCappingExample.java 2import java.util.concurrent.*; 3import java.util.concurrent.atomic.AtomicInteger; 4 5public class SemaphoreCappingExample { 6 public static void main(String[] args) throws InterruptedException { 7 Semaphore desks = new Semaphore(3); 8 AtomicInteger active = new AtomicInteger(); 9 AtomicInteger maxActive = new AtomicInteger(); 10 int userCount = 10; 11 CountDownLatch done = new CountDownLatch(userCount); 12 13 for (int i = 0; i < userCount; i++) { 14 new Thread(() -> { 15 try { 16 desks.acquire(); 17 try { 18 int current = active.incrementAndGet(); 19 maxActive.updateAndGet(max -> Math.max(max, current)); 20 Thread.sleep(50); 21 active.decrementAndGet(); 22 } finally { 23 desks.release(); 24 } 25 } catch (InterruptedException e) { 26 Thread.currentThread().interrupt(); 27 } finally { 28 done.countDown(); 29 } 30 }).start(); 31 } 32 33 done.await(); 34 System.out.println("Max concurrent desks used: " + maxActive.get()); 35 } 36}
Output:
Max concurrent desks used: 3

With ten threads competing for three permits and each one holding its permit for 50ms, the first three to arrive are essentially certain to overlap and push the tracked maximum to exactly 3 — the semaphore itself guarantees that number is never exceeded, regardless of how the ten threads happen to be scheduled.

1// File: SemaphoreTryAcquireExample.java 2import java.util.concurrent.Semaphore; 3import java.util.concurrent.TimeUnit; 4 5public class SemaphoreTryAcquireExample { 6 public static void main(String[] args) throws InterruptedException { 7 Semaphore singleDesk = new Semaphore(1); 8 9 singleDesk.acquire(); 10 11 boolean gotDesk = singleDesk.tryAcquire(100, TimeUnit.MILLISECONDS); 12 System.out.println("Second acquire succeeded: " + gotDesk); 13 14 singleDesk.release(); 15 } 16}
Output:
Second acquire succeeded: false

Even though the same thread already holds the single permit, tryAcquire() still waits out its full 100ms and fails — a Semaphore tracks a count of permits, not which thread currently owns one.

Real-World Example

A co-working space has a fixed number of physical desks, and members compete to book one — a Semaphore sized to the actual desk count is what makes it safe for more than one member to hold a desk at the same time, unlike a plain lock. This is also a natural fit for a simple concurrency-based rate limiter, capping how many operations run at once, and for coordinating a fixed number of parallel worker slots, where the permit count matches the number of workers allowed to run simultaneously.

1// File: MeetingRoomBookingService.java 2import java.util.concurrent.Semaphore; 3import java.util.concurrent.TimeUnit; 4 5public class MeetingRoomBookingService { 6 private final Semaphore availableDesks; 7 8 public MeetingRoomBookingService(int deskCount) { 9 this.availableDesks = new Semaphore(deskCount); 10 } 11 12 public boolean bookDesk(long waitMillis) throws InterruptedException { 13 return availableDesks.tryAcquire(waitMillis, TimeUnit.MILLISECONDS); 14 } 15 16 public void releaseDesk() { 17 availableDesks.release(); 18 } 19 20 public int availablePermits() { 21 return availableDesks.availablePermits(); 22 } 23}
1// File: MeetingRoomBookingDemo.java 2import java.util.concurrent.CountDownLatch; 3import java.util.concurrent.atomic.AtomicInteger; 4 5public class MeetingRoomBookingDemo { 6 public static void main(String[] args) throws InterruptedException { 7 MeetingRoomBookingService service = new MeetingRoomBookingService(2); 8 int memberCount = 5; 9 CountDownLatch done = new CountDownLatch(memberCount); 10 AtomicInteger successfulBookings = new AtomicInteger(); 11 12 for (int i = 0; i < memberCount; i++) { 13 new Thread(() -> { 14 try { 15 if (service.bookDesk(200)) { 16 successfulBookings.incrementAndGet(); 17 Thread.sleep(50); 18 service.releaseDesk(); 19 } 20 } catch (InterruptedException e) { 21 Thread.currentThread().interrupt(); 22 } finally { 23 done.countDown(); 24 } 25 }).start(); 26 } 27 28 done.await(); 29 30 System.out.println("Successful bookings: " + successfulBookings.get()); 31 System.out.println("Desks available after all sessions: " + service.availablePermits()); 32 } 33}
Output:
Successful bookings: 5
Desks available after all sessions: 2

With only two desks and five members, each session holding a desk for 50ms comfortably lets every member's 200ms booking window succeed in turn as desks free up — all five eventually get a desk, and once every thread has finished, all five bookings have also been released, bringing availability back to the original count of two. A mistake that appears often in fresher pull requests is using a plain lock or synchronized block to guard a pool of interchangeable resources like desks, when a lock only ever allows one holder at a time regardless of how many identical resources actually exist. A Semaphore sized to the actual desk count, exactly as MeetingRoomBookingService uses here, lets multiple members hold a desk simultaneously — correctly and safely — instead of forcing everyone through a single-file queue for resources that were never meant to be exclusive to just one holder.

Best Practices

Always release a permit in a finally block, exactly as this article's examples do, so an exception partway through the critical section never leaks a permit permanently.

Use tryAcquire() with a bounded timeout when waiting indefinitely for a resource is unacceptable, rather than acquire(), which blocks with no time limit at all.

Size the semaphore's permit count to the actual capacity of the resource pool it protects — the entire value of Semaphore over a plain lock comes from that number being greater than one whenever more than one holder is genuinely safe.

Do not rely on a Semaphore for reentrant access from the same thread — unlike ReentrantLock, it treats every acquire() call identically regardless of which thread makes it. It shares the same fairness-setting concept as ReentrantLock, and is often combined with an ExecutorService to cap how many submitted tasks may run concurrently, independent of the thread pool's own size.

Common Mistakes

Forgetting to release a permit leaks it permanently, gradually shrinking the pool's effective capacity until nothing can acquire at all.

1// File: LeakedPermitMistake.java 2import java.util.concurrent.Semaphore; 3import java.util.concurrent.TimeUnit; 4 5public class LeakedPermitMistake { 6 public static void main(String[] args) throws InterruptedException { 7 Semaphore singleDesk = new Semaphore(1); 8 9 singleDesk.acquire(); 10 // release() is never called - the permit is leaked 11 12 boolean acquiredAgain = singleDesk.tryAcquire(100, TimeUnit.MILLISECONDS); 13 System.out.println("Acquired again: " + acquiredAgain); 14 } 15}
Output:
Acquired again: false

Assuming a Semaphore enforces the same "only the acquiring thread may release" discipline a lock does is a second, subtler mistake — it does not. Any thread can call release() on a Semaphore regardless of whether it ever called acquire(), and calling release() more times than were ever acquired silently increases the semaphore's effective capacity beyond what was originally intended, rather than throwing an error.

Interview Questions

Q1. What is the difference between a Semaphore and a Lock?

A Lock allows exactly one thread to hold it at a time. A Semaphore allows up to N concurrent holders, where N is the number of permits it was constructed with — a lock is effectively a semaphore with exactly one permit, minus the ownership tracking a lock provides. Interviewers are listening for whether you can name that ownership distinction unprompted.

Q2. What does a semaphore constructed with 1 permit behave like?

A binary semaphore, similar in effect to a mutual-exclusion lock, but without ownership tracking — any thread can call release(), not only the thread that called acquire(). The nuance is recognizing this is similar to, not identical to, a lock.

Q3. Can a permit be released by a thread that never acquired one?

Yes. Semaphore does not track which thread acquired which permit, so any thread can call release() at any time — correct usage is entirely a matter of application-level discipline, not something the class enforces. This is exactly the kind of detail that separates a candidate who has actually used the class from one who has only read about it.

Q4. What is the difference between acquire() and tryAcquire()?

acquire() blocks indefinitely until a permit becomes available. tryAcquire() either returns immediately with false if none is available, or, with a timeout argument, waits up to that bounded duration before giving up. Interviewers want to hear you connect this to a real fail-fast scenario, not just recite the method signatures.

Q5. What happens if release() is never called after acquire()?

The permit is leaked permanently, exactly as demonstrated in this article's LeakedPermitMistake example — the semaphore's effective capacity shrinks by one for the rest of the program's lifetime unless something else compensates for it. The follow-up they are watching for is whether you immediately reach for try/finally as the fix.

Q6. Is Semaphore reentrant, the way ReentrantLock is?

No. A thread that already holds a permit and calls acquire() again is treated exactly like any other thread competing for a permit — it can end up blocking on itself, exactly as demonstrated in this article's SemaphoreTryAcquireExample. This is a common trap question specifically because the name invites the wrong assumption.

Q7. What common use case is Semaphore particularly well suited for?

Capping concurrent access to a bounded pool of interchangeable resources — database connections, worker slots, or physical resources like the desks in this article's real-world example — anywhere more than one, but not unlimited, concurrent holders should be allowed. Product-company interviewers often push further into how you would size the permit count against real capacity limits.

FAQs

Can a Semaphore be constructed to be fair, like ReentrantLock?

Yes, new Semaphore(permits, true) constructs a fair semaphore that favors the longest-waiting thread, the same fairness concept covered in this section's Lock & ReentrantLock article.

What does availablePermits() return, and can its value be negative?

It returns the current count of permits available to acquire. Under normal acquire()/release() usage it stays at zero or above, but it can be pushed above the originally constructed count if release() is called more times than permits were ever actually acquired.

Is Semaphore part of java.util.concurrent?

Yes, java.util.concurrent.Semaphore has been part of the concurrency utilities since Java 5, alongside ReentrantLock, CountDownLatch, and the rest of the package.

Can more permits be released than were ever acquired?

Yes, and this is exactly the surprising behavior covered in this article's Common Mistakes section — Semaphore does not enforce a maximum permit count, so an extra, unmatched release() call silently increases capacity rather than throwing an error.

Does acquire() block indefinitely if no permit is available?

Yes, unless the calling thread is interrupted — acquire() has no built-in timeout, which is exactly why tryAcquire() with a timeout argument exists for cases where an unbounded wait is not acceptable.

Is a Semaphore with 1 permit exactly equivalent to a synchronized block?

Not exactly — both provide mutual exclusion in effect, but a binary semaphore lacks reentrancy and lacks any connection between a specific thread and the permit it holds, while synchronized is automatically released by the owning thread even if an exception is thrown, with the JVM enforcing that only the owning thread can exit the block.

Can Semaphore be used for rate limiting?

It can cap how many operations run concurrently at any given moment, but it does not by itself limit how many operations happen per unit of time — a true time-based rate limiter typically combines a semaphore-like permit count with a scheduled mechanism that refills permits on a timer.

Summary

Semaphore generalizes the idea of a lock from "exactly one holder" to "up to N holders," making it the right tool whenever a resource pool genuinely supports more than one concurrent user but still needs a hard cap. acquire() and tryAcquire() mirror Lock's blocking and non-blocking styles, but without reentrancy and without any enforced connection between a thread and the permit it holds — any thread can release any permit.

The habit worth carrying forward from this article's co-working space example is sizing the semaphore to the resource pool's actual capacity, always releasing in a finally block, and never assuming the same ownership discipline a lock provides applies here too.

What to Read Next