Semaphores: The Bouncer at the Club
Before We Start โ What You Need to Know
We've learned how to avoid concurrency problems (immutability, confinement) and how to do simple atomic updates (CAS). But what if you need to control how many threads can access a resource at the same time? Not just one (like a lock) โ but a specific number, like "only 5 database connections at once" or "max 3 customers in the fitting room."
That's where Semaphores come in. Think of them as a generalized lock that allows *N* concurrent accessors instead of just 1.
You should know: what synchronized does (only one thread at a time), basic thread creation, and why resource exhaustion is bad (too many open connections crashes the database).
What is it? (The Analogy)
Picture a nightclub with a maximum capacity of 100 people. There's a bouncer at the door. The bouncer has a clicker counter starting at 100. When someone enters, the bouncer clicks DOWN (99... 98... 97...). When someone leaves, the bouncer clicks UP (98... 99...). If the counter hits 0, the bouncer says "Sorry, you'll have to wait outside" and the person stands in line until someone leaves.
That's exactly how a Semaphore works:
- The counter is called the number of permits.
- **
acquire()** = "Can I come in?" โ decrements the permits. If permits are 0, the thread *blocks* (waits in line). - **
release()** = "I'm leaving!" โ increments the permits, and wakes up one waiting thread.
A Semaphore with 1 permit acts like a lock (only one thread at a time). A Semaphore with N permits allows up to N concurrent threads. This makes it perfect for managing resource pools โ connection pools, thread pools, rate limiters, and more.
The Problem It Solves
Imagine a food delivery app that has a limited number of delivery drivers:
1// BROKEN: No limit on concurrent deliveries!
2class BrokenDeliveryService {
3 public void deliver(String orderId) {
4 // Suppose we only have 3 drivers, but we don't enforce it
5 Driver driver = driverPool.getAnyDriver(); // What if all busy?
6 driver.deliver(orderId); // NullPointerException!
7 driverPool.returnDriver(driver);
8 }
9}What goes wrong?
- If 10 orders come in at once, we try to grab 10 drivers but only have 3.
getAnyDriver()returns null or throws an exception.- Or worse, we create unbounded threads/connections and crash the system with resource exhaustion.
Without a semaphore, you'd need complex manual bookkeeping to track available resources. With a semaphore, it's elegant and foolproof.
How it works โ Step by Step
- Create a Semaphore with N permits:
new Semaphore(3)โ this means "up to 3 threads can enter at once." - **Before accessing the resource, call
acquire()**: This decrements the permit count. If permits > 0, the thread proceeds immediately. If permits == 0, the thread *blocks* until another thread releases a permit. - **After you're done, call
release()in afinallyblock**: This increments the permit count and wakes up one waiting thread. - **Optional: Use
tryAcquire()** to attempt without blocking. It returnstrueif a permit was available,falseotherwise. Great for timeout-based approaches. - Choose fairness:
new Semaphore(3, true)creates a *fair* semaphore where waiting threads are served in FIFO order. Default is *unfair* (faster, but a thread could theoretically starve).
1Permits: 3
2Thread A: acquire() -> permits = 2, enters
3Thread B: acquire() -> permits = 1, enters
4Thread C: acquire() -> permits = 0, enters
5Thread D: acquire() -> permits = 0, BLOCKED (waits...)
6Thread A: release() -> permits = 1, Thread D is woken up!
7Thread D: -> permits = 0, entersLet's Build It Together
Let's build an amusement park ride manager โ the roller coaster has only 4 seats:
1import java.util.concurrent.Semaphore;
2
3public class RollerCoasterRide {
4
5 // Only 4 seats on the ride โ fair queuing so nobody waits forever
6 private final Semaphore seats = new Semaphore(4, true);
7 private final String rideName;
8
9 public RollerCoasterRide(String rideName) {
10 this.rideName = rideName;
11 }
12
13 /**
14 * A visitor tries to go on the ride.
15 * If all seats are taken, they wait in line.
16 */
17 public void ride(String visitorName) {
18 System.out.println(visitorName + " is waiting in line for "
19 + rideName + "...");
20 try {
21 // Block until a seat is available
22 seats.acquire();
23
24 // We got a seat!
25 System.out.println(visitorName + " is ON the "
26 + rideName + "! (seats left: "
27 + seats.availablePermits() + ")");
28
29 // Simulate the ride duration
30 Thread.sleep((long) (Math.random() * 3000) + 1000);
31
32 System.out.println(visitorName + " finished the "
33 + rideName + "!");
34 } catch (InterruptedException e) {
35 Thread.currentThread().interrupt();
36 System.out.println(visitorName + " was interrupted!");
37 } finally {
38 // ALWAYS release in finally โ even if an exception occurs!
39 seats.release();
40 }
41 }
42
43 public static void main(String[] args) {
44 RollerCoasterRide coaster = new RollerCoasterRide("Thunder Mountain");
45
46 // 10 visitors trying to ride, but only 4 seats
47 String[] visitors = {
48 "Alice", "Bob", "Charlie", "Diana", "Eve",
49 "Frank", "Grace", "Heidi", "Ivan", "Judy"
50 };
51
52 for (String visitor : visitors) {
53 new Thread(() -> coaster.ride(visitor)).start();
54 }
55 }
56}Now let's build something more practical โ a database connection pool:
1import java.util.concurrent.Semaphore;
2import java.util.concurrent.ConcurrentLinkedQueue;
3
4public class ConnectionPool {
5
6 private final Semaphore permits;
7 private final ConcurrentLinkedQueue<Connection> pool;
8
9 public ConnectionPool(int maxConnections) {
10 this.permits = new Semaphore(maxConnections, true);
11 this.pool = new ConcurrentLinkedQueue<>();
12
13 // Pre-create all connections
14 for (int i = 0; i < maxConnections; i++) {
15 pool.add(new Connection("conn-" + i));
16 }
17 }
18
19 /**
20 * Borrow a connection from the pool.
21 * Blocks if all connections are in use.
22 */
23 public Connection borrowConnection() throws InterruptedException {
24 permits.acquire(); // Wait for a permit (= available connection)
25 Connection conn = pool.poll(); // Grab one from the queue
26 System.out.println(Thread.currentThread().getName()
27 + " borrowed " + conn.name);
28 return conn;
29 }
30
31 /**
32 * Return a connection to the pool.
33 */
34 public void returnConnection(Connection conn) {
35 pool.offer(conn); // Put it back in the queue
36 permits.release(); // Release the permit โ wake up a waiter!
37 System.out.println(Thread.currentThread().getName()
38 + " returned " + conn.name);
39 }
40
41 /**
42 * Try to borrow with a timeout โ don't wait forever!
43 */
44 public Connection tryBorrow(long timeoutMs) throws InterruptedException {
45 if (permits.tryAcquire(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)) {
46 Connection conn = pool.poll();
47 System.out.println(Thread.currentThread().getName()
48 + " borrowed " + conn.name + " (with timeout)");
49 return conn;
50 }
51 System.out.println(Thread.currentThread().getName()
52 + " timed out waiting for connection!");
53 return null; // No connection available within timeout
54 }
55
56 // Simple Connection class for demo
57 static class Connection {
58 final String name;
59 Connection(String name) { this.name = name; }
60 }
61
62 public static void main(String[] args) {
63 ConnectionPool pool = new ConnectionPool(3); // Only 3 connections!
64
65 // 8 threads all need a connection
66 for (int i = 0; i < 8; i++) {
67 new Thread(() -> {
68 try {
69 Connection conn = pool.borrowConnection();
70 // Simulate doing some work with the connection
71 Thread.sleep((long) (Math.random() * 2000) + 500);
72 pool.returnConnection(conn);
73 } catch (InterruptedException e) {
74 Thread.currentThread().interrupt();
75 }
76 }, "Worker-" + i).start();
77 }
78 }
79}What Happens Under the Hood
When you call semaphore.acquire(), here's the internal journey:
- Check the permit count: The Semaphore uses an internal
AbstractQueuedSynchronizer(AQS) โ a framework for building locks and synchronizers. The permit count is stored as anint state. - Try CAS to decrement: It attempts
compareAndSetState(current, current - 1). If this succeeds, the thread enters immediately. - If permits == 0: The thread is placed into a CLH queue (a linked list of waiting threads maintained by AQS). The thread is then parked โ it's put to sleep by the OS, consuming zero CPU.
- **When
release()is called: The permit count is CAS-incremented, and the first thread in the CLH queue is unparked** (woken up). That thread retries the CAS to acquire a permit. - Fair vs Unfair: In *fair* mode, new threads always go to the back of the queue (even if permits are available). In *unfair* mode (default), a new thread can "barge in" and steal a permit from a waiting thread. Unfair is faster (fewer context switches) but can cause starvation.
1Semaphore state: 0 (all permits taken)
2CLH Queue: [Thread-D] -> [Thread-E] -> [Thread-F]
3
4Thread-A calls release():
5 state: 0 -> 1 (CAS increment)
6 Unpark Thread-D
7 Thread-D wakes up, CAS state: 1 -> 0, enters!Aha! Semaphore is built on top of AQS, the same framework that powers ReentrantLock, CountDownLatch, and ReentrantReadWriteLock. Understanding AQS means understanding the internals of almost every Java concurrency primitive.
When to Use vs When NOT to Use
| Use Semaphores When | Don't Use Semaphores When |
|---|---|
| Limiting concurrent access to a resource pool (connections, threads, files) | You need mutual exclusion for exactly 1 thread (use ReentrantLock or synchronized) |
| Rate limiting (allow max N operations per time window) | You need to wait for a one-time event (use CountDownLatch) |
| Controlling parallelism (max N threads processing at once) | You need read/write distinction (use ReadWriteLock) |
| Producer-consumer flow control | You need to protect a critical section with reentrancy (Semaphore is NOT reentrant) |
| Implementing backpressure in pipelines |
Common Mistakes & Gotchas
- **Forgetting to
release()in afinallyblock.** If an exception occurs betweenacquire()andrelease(), the permit is lost forever. Eventually, all permits leak and the system deadlocks. Always use try/finally. - Releasing more than you acquired. Calling
release()without a matchingacquire()*increases* the permit count beyond the original! Semaphores don't track who acquired โ you can accidentally inflate the pool. - Assuming Semaphore is reentrant. If a thread that already holds a permit calls
acquire()again, it consumes *another* permit. If no permits are left, it deadlocks with itself! UnlikeReentrantLock, Semaphore does NOT track ownership. - **Using
availablePermits()for decisions.** The return value is immediately stale โ by the time you act on it, another thread may have changed the count. It's only useful for monitoring/logging, not for logic. - Choosing fair when you don't need it. Fair semaphores have higher overhead due to stricter queue ordering. Use unfair (the default) unless you specifically need FIFO guarantees.
Interview Tip
Semaphores come up in two main interview contexts: (1) "Design a connection pool / rate limiter" โ the Semaphore is the throttle that limits concurrent access, and (2) "What's the difference between a Semaphore and a Lock?" The key answer: a lock allows *exactly one* thread; a Semaphore allows *N* threads. Also mention that Semaphore is *not* reentrant and doesn't have ownership โ any thread can release a permit, not just the one that acquired it. This makes Semaphore more flexible but also easier to misuse.
Quick Quiz
- You create
new Semaphore(0). Is this useful? When would you use a semaphore that starts with zero permits?
- What happens if Thread A acquires a permit and Thread B (not Thread A) releases it? Is that legal? Is that a good idea?
- You're building a rate limiter that allows 100 requests per second. Can a Semaphore alone do this? What else would you need?
Summary โ Key Takeaways
- A Semaphore is a counter-based concurrency primitive:
acquire()decrements,release()increments, and threads block when the count reaches zero. - Use Semaphores to limit concurrent access to a pool of N resources (connections, seats, API calls).
- **Always
release()in afinallyblock** to prevent permit leaks. - Semaphores are not reentrant and have no ownership โ any thread can release, so be disciplined about matching acquires with releases.