LLD
Machine Coding
Interview Course
Java โ€ข Interview Prep
๐Ÿ”„ Concurrency Deep Dive/

Semaphores: The Bouncer at the Club

Lesson 4 of 8

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:

java
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

  1. Create a Semaphore with N permits: new Semaphore(3) โ€” this means "up to 3 threads can enter at once."
  2. **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.
  3. **After you're done, call release() in a finally block**: This increments the permit count and wakes up one waiting thread.
  4. **Optional: Use tryAcquire()** to attempt without blocking. It returns true if a permit was available, false otherwise. Great for timeout-based approaches.
  5. 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).
java
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, enters

Let's Build It Together

Let's build an amusement park ride manager โ€” the roller coaster has only 4 seats:

java
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:

java
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:

  1. Check the permit count: The Semaphore uses an internal AbstractQueuedSynchronizer (AQS) โ€” a framework for building locks and synchronizers. The permit count is stored as an int state.
  2. Try CAS to decrement: It attempts compareAndSetState(current, current - 1). If this succeeds, the thread enters immediately.
  3. 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.
  4. **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.
  5. 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.
java
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 WhenDon'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 controlYou need to protect a critical section with reentrancy (Semaphore is NOT reentrant)
Implementing backpressure in pipelines

Common Mistakes & Gotchas

  • **Forgetting to release() in a finally block.** If an exception occurs between acquire() and release(), 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 matching acquire() *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! Unlike ReentrantLock, 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

  1. You create new Semaphore(0). Is this useful? When would you use a semaphore that starts with zero permits?
  1. What happens if Thread A acquires a permit and Thread B (not Thread A) releases it? Is that legal? Is that a good idea?
  1. 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 a finally block** to prevent permit leaks.
  • Semaphores are not reentrant and have no ownership โ€” any thread can release, so be disciplined about matching acquires with releases.