[THREADING 101] 05 – Semaphores

[

What is a Semaphore?

A semaphore is a concurrency primitive that controls access to a shared resource by maintaining a set of permits. Threads acquire permits before accessing the resource and release them when done. Unlike locks, semaphores don’t have an “owner” — any thread can release a permit, not just the one that acquired it.

Key Difference: Semaphore vs Lock

AspectLock/MutexSemaphore
PermitsExactly 1 (exclusive)N (configurable)
OwnershipThread that locked must unlockAny thread can release
ReentrancySupported (ReentrantLock)Not applicable
PurposeMutual exclusionResource limiting / signaling
AnalogyKey to a room (one person)Parking lot (N spaces)

Basic Usage

import java.util.concurrent.Semaphore;

public class ConnectionPool {
    private final Semaphore semaphore;
    private final List<Connection> connections;

    public ConnectionPool(int poolSize) {
        this.semaphore = new Semaphore(poolSize);
        this.connections = createConnections(poolSize);
    }

    public Connection acquire() throws InterruptedException {
        semaphore.acquire(); // Block until a permit is available
        return getAvailableConnection();
    }

    public void release(Connection conn) {
        returnConnectionToPool(conn);
        semaphore.release(); // Return the permit
    }
}

Semaphore API

Core Methods

MethodDescription
acquire()Acquires one permit. Blocks if none available.
acquire(int n)Acquires n permits at once.
tryAcquire()Tries to acquire without blocking. Returns boolean.
tryAcquire(long timeout, TimeUnit unit)Tries to acquire with a timeout.
release()Releases one permit.
release(int n)Releases n permits.
availablePermits()Returns the number of currently available permits.
drainPermits()Acquires all available permits and returns the count.

Informational Methods

MethodDescription
hasQueuedThreads()Are threads waiting to acquire?
getQueueLength()Estimated number of waiting threads.
isFair()Was the semaphore created with fairness enabled?

Fair vs Unfair Semaphore

Semaphore unfair = new Semaphore(5);        // Default: unfair
Semaphore fair = new Semaphore(5, true);    // Fair: FIFO guarantee

Fair semaphores guarantee that threads acquire permits in the order they requested them. This prevents starvation but reduces throughput.

Pattern: Rate Limiter (Fixed Window)

Limit the number of concurrent operations:

public class RateLimiter {
    private final Semaphore semaphore;

    public RateLimiter(int maxConcurrent) {
        this.semaphore = new Semaphore(maxConcurrent);
    }

    public <T> T execute(Callable<T> task) throws Exception {
        semaphore.acquire();
        try {
            return task.call();
        } finally {
            semaphore.release();
        }
    }
}

// Usage: max 10 concurrent API calls
RateLimiter limiter = new RateLimiter(10);
String result = limiter.execute(() -> callExternalApi());

Pattern: Producer-Consumer Signaling

Use two semaphores to coordinate a bounded buffer without explicit locks:

public class SemaphoreBuffer<T> {
    private final Queue<T> queue = new ConcurrentLinkedQueue<>();
    private final Semaphore availableItems;  // Signals consumers: items ready
    private final Semaphore availableSlots;  // Signals producers: space ready

    public SemaphoreBuffer(int capacity) {
        availableItems = new Semaphore(0);          // Initially: no items
        availableSlots = new Semaphore(capacity);   // Initially: all slots free
    }

    public void put(T item) throws InterruptedException {
        availableSlots.acquire();  // Wait for free slot
        queue.add(item);
        availableItems.release();  // Signal that an item is available
    }

    public T take() throws InterruptedException {
        availableItems.acquire();  // Wait for available item
        T item = queue.poll();
        availableSlots.release();  // Signal that a slot is free
        return item;
    }
}

This is elegant: two semaphores signal each other — availableSlots tells producers when to go, availableItems tells consumers when to go. No explicit locking of the queue is needed because ConcurrentLinkedQueue is thread-safe.

Pattern: Binary Semaphore (Mutex)

A semaphore with one permit behaves like a non-reentrant mutex:

Semaphore mutex = new Semaphore(1);

mutex.acquire();
try {
    // Exclusive access — only one thread at a time
} finally {
    mutex.release();
}

Unlike ReentrantLock, a binary semaphore is not reentrant — acquiring twice from the same thread will deadlock.

TimedSemaphore (Apache Commons)

A semaphore that automatically resets its permits at regular intervals:

import org.apache.commons.lang3.concurrent.TimedSemaphore;

// Allow max 100 operations per second
TimedSemaphore rateLimiter = new TimedSemaphore(1, TimeUnit.SECONDS, 100);

public void handleRequest() throws InterruptedException {
    rateLimiter.acquire(); // Blocks if 100 operations already happened this second
    processRequest();
    // No release needed — permits reset automatically every second
}

Use Cases for TimedSemaphore

Use CaseConfiguration
API rate limiting (100 req/sec)TimedSemaphore(1, SECONDS, 100)
Email throttling (50 emails/min)TimedSemaphore(1, MINUTES, 50)
Database query limitingTimedSemaphore(1, SECONDS, 20)

Note: TimedSemaphore implements a fixed-window rate limiter. For sliding-window or token-bucket algorithms, a different approach is needed.

Semaphore vs Other Synchronization

Common Mistakes

1. Forgetting to Release

// WRONG — if processRequest() throws, permit is never released
semaphore.acquire();
processRequest();
semaphore.release();

// CORRECT — always release in finally
semaphore.acquire();
try {
    processRequest();
} finally {
    semaphore.release();
}

2. Releasing Without Acquiring

// WRONG — release without acquire inflates the permit count
semaphore.release(); // Now permits = maxPermits + 1!

Semaphore doesn’t track ownership, so it won’t throw. The permit count will silently grow beyond the intended maximum.

3. Using Semaphore as a Lock

// WRONG — semaphore is not reentrant
Semaphore mutex = new Semaphore(1);

void methodA() {
    mutex.acquire();
    methodB(); // DEADLOCK — same thread tries to acquire again
    mutex.release();
}

void methodB() {
    mutex.acquire(); // Blocks forever — semaphore has no reentrant concept
    // ...
    mutex.release();
}

Summary

  • Semaphores limit the number of threads accessing a resource concurrently.
  • Unlike locks, they have no ownership — any thread can release.
  • Use for connection pools, rate limiting, and producer-consumer signaling.
  • Always release in a finally block.
  • A semaphore with 0 initial permits is a useful signaling mechanism between threads.
  • TimedSemaphore auto-resets permits at intervals — useful for simple rate limiting.

References

About the author

Maksim

I build AI-powered products and lead engineering teams. I've launched platforms from zero to millions of users and learned most lessons the hard way. I write about the gap between engineering theory and practice, what actually matters when building products, and the decisions that shape teams and systems.

Add Comment

By Maksim

Maksim

Get in touch

Reach out if you want to discuss engineering leadership, collaborate on something interesting, or suggest topics you'd like me to write about.