Semaphore in Java: Controlling Concurrent Resource Access

A Semaphore maintains a set of permits. Threads acquire a permit before accessing a resource and release it when done. When all permits are taken, further acquire() calls block until a permit is returned. Java's java.util.concurrent.Semaphore supports both fair (FIFO) and non-fair acquisition, bulk acquire/release, and non-blocking tryAcquire().

Share

Semaphore Basics: acquire() and release()

Java
import java.util.concurrent.Semaphore;

// Allow at most 3 concurrent accesses
Semaphore semaphore = new Semaphore(3);

// Acquire a permit (blocks if none available)
semaphore.acquire();
try {
    // --- critical section: access the shared resource ---
    doWork();
} finally {
    semaphore.release();   // always release in finally
}

// Non-blocking: try to acquire without waiting
boolean acquired = semaphore.tryAcquire();
if (acquired) {
    try {
        doWork();
    } finally {
        semaphore.release();
    }
} else {
    System.out.println("Resource busy — try again later");
}

// Inspect state
System.out.println("Available permits : " + semaphore.availablePermits());
System.out.println("Queued threads    : " + semaphore.getQueueLength());
Always call release() in a finally block. A missed release() permanently reduces the permit count and eventually starves all waiting threads.

Fair vs Non-Fair Semaphore

By default, Semaphore is non-fair: threads acquire permits in an unspecified order, which gives higher throughput but can starve long-waiting threads. Pass true to the constructor for FIFO ordering.

Java
// Non-fair (default) — better throughput
Semaphore unfair = new Semaphore(5);

// Fair — FIFO ordering; prevents starvation
Semaphore fair = new Semaphore(5, true);
ModeOrderingThroughputStarvation risk
Non-fair (default)Unspecified (barging allowed)HigherYes: a thread may wait indefinitely
Fair (true)FIFO: longest waiting acquires firstLowerNo: guaranteed progress

tryAcquire with Timeout

Java
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

Semaphore sem = new Semaphore(2);

// Try to acquire with a deadline
boolean got = sem.tryAcquire(500, TimeUnit.MILLISECONDS);
if (got) {
    try {
        processRequest();
    } finally {
        sem.release();
    }
} else {
    // Return 429 Too Many Requests or enqueue for retry
    throw new RuntimeException("Service busy — please retry");
}

Bulk Acquire: Multiple Permits at Once

You can acquire and release more than one permit in a single call. This is useful when a task consumes different amounts of a metered resource (e.g., bandwidth units).

Java
Semaphore bandwidth = new Semaphore(100);  // 100 units available

// Acquire 10 units for a large request
bandwidth.acquire(10);
try {
    sendLargePayload();
} finally {
    bandwidth.release(10);
}

// Acquire 1 unit for a small request
bandwidth.acquire(1);
try {
    sendHeartbeat();
} finally {
    bandwidth.release(1);
}

Pattern: Bounded Connection Pool

A semaphore naturally models a fixed-size connection pool: acquire a permit before borrowing a connection, release when returning it.

Java
import java.util.concurrent.*;

public class ConnectionPool {

    private final Semaphore semaphore;
    private final BlockingQueue<Connection> pool;

    public ConnectionPool(int maxConnections) {
        semaphore = new Semaphore(maxConnections, true);  // fair
        pool      = new ArrayBlockingQueue<>(maxConnections);
        for (int i = 0; i < maxConnections; i++) {
            pool.add(createConnection(i));
        }
    }

    /** Borrow a connection; blocks until one is available. */
    public Connection acquire() throws InterruptedException {
        semaphore.acquire();
        return pool.poll();   // always non-null after acquiring permit
    }

    /** Borrow with timeout; returns null on timeout. */
    public Connection tryAcquire(long timeout, TimeUnit unit)
            throws InterruptedException {
        if (!semaphore.tryAcquire(timeout, unit)) return null;
        return pool.poll();
    }

    /** Return a connection to the pool. */
    public void release(Connection conn) {
        pool.offer(conn);
        semaphore.release();
    }

    private Connection createConnection(int id) {
        return new Connection("conn-" + id);
    }

    record Connection(String id) {}
}

Pattern: Simple Rate Limiter

A semaphore can implement a simple token-bucket style rate limiter by periodically refilling permits with a background scheduler.

Java
import java.util.concurrent.*;

public class RateLimiter {

    private final Semaphore semaphore;

    /**
     * @param maxRequests maximum requests allowed per interval
     * @param interval    refill interval
     * @param unit        time unit for interval
     */
    public RateLimiter(int maxRequests, long interval, TimeUnit unit) {
        semaphore = new Semaphore(maxRequests);

        // Refill permits on a fixed schedule
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
            int needed = maxRequests - semaphore.availablePermits();
            if (needed > 0) semaphore.release(needed);
        }, interval, interval, unit);
    }

    /** Blocks until a permit is available. */
    public void acquire() throws InterruptedException {
        semaphore.acquire();
    }

    /** Non-blocking check. */
    public boolean tryAcquire() {
        return semaphore.tryAcquire();
    }
}

// Usage: allow 100 requests per second
RateLimiter limiter = new RateLimiter(100, 1, TimeUnit.SECONDS);
limiter.acquire();
callExternalApi();
For production rate limiting, prefer libraries like Resilience4j or Guava's RateLimiter which implement token-bucket and leaky-bucket algorithms more accurately. The semaphore approach above is a useful illustration but lacks sub-second precision.

Binary Semaphore: Lightweight Mutex

A Semaphore(1) acts as a non-reentrant mutex. Unlike synchronized, it can be released by a different thread: useful for producer-consumer handoffs.

Java
Semaphore mutex = new Semaphore(1);

// Thread A: acquire and hold
mutex.acquire();
System.out.println("Thread A has the lock");

// Thread B: can release (unlike ReentrantLock)
new Thread(() -> {
    mutex.release();   // valid even from a different thread
    System.out.println("Thread B released the lock");
}).start();
A binary Semaphore is not reentrant: the same thread calling acquire() twice will deadlock itself. Use ReentrantLock when reentrancy is required.