What Is a Java Semaphore?

A Java semaphore is a synchronization tool that limits how many threads may use a resource at the same time. It holds a counter of permits: a thread acquires a permit before entering the guarded section and releases it when finished. If no permit is available, the thread waits until one is. Java provides this as the java.util.concurrent.Semaphore class.

The distinction that matters is one of degree. A Java lock answers a yes-or-no question — may this thread enter the critical section? A semaphore answers a counting question — how many threads may be in there at once? That makes semaphores the natural tool for bounding concurrency: capping simultaneous calls to a slow downstream service, limiting how many threads hold a connection from a pool, or throttling expensive jobs. Preventing a race condition on a single shared variable is a job for a lock; keeping no more than ten threads inside a subsystem at once is a job for a semaphore.

How a Java Semaphore Works

A semaphore is created with a fixed number of permits. Every successful acquire() decrements the counter by one; every release() increments it. When the counter reaches zero, further calls to acquire() block until another thread releases a permit.

// allow at most 5 threads into the guarded section at once
Semaphore semaphore = new Semaphore(5);

// acquire() blocks until a permit is free, and can throw
// InterruptedException, so the caller must handle or declare it.
semaphore.acquire();
try {
    // at most 5 threads run this concurrently
} finally {
    semaphore.release();      // always release, even if the body throws
}

The try/finally is not optional. A permit lost to an exception is gone for the lifetime of the semaphore, and enough leaked permits will deadlock the application permanently.

By default a semaphore is unfair: a newly arriving thread may barge ahead of threads that have been waiting longer, which yields higher throughput but risks starvation. Pass true to the constructor for first-in, first-out ordering:

Semaphore fairSemaphore = new Semaphore(5, true); // FIFO ordering

To avoid waiting forever, use a timed acquisition and take an alternative path when the permit does not arrive:

Semaphore semaphore = new Semaphore(5);

// tryAcquire(time, unit) can throw InterruptedException,
// so the enclosing method must handle or declare it.
if (semaphore.tryAcquire(1, TimeUnit.SECONDS)) {
    try {
        // acquired a permit within one second
    } finally {
        semaphore.release();
    }
} else {
    // could not get a permit — shed load, queue the work, or fail fast
}

Core Methods of Semaphore

  • acquire() — acquires one permit, blocking until one is available or the thread is interrupted. An overload takes the number of permits to acquire.
  • acquireUninterruptibly() — acquires a permit and ignores interruption while waiting.
  • tryAcquire() — acquires a permit only if one is free at that instant, returning true or false immediately instead of blocking. Note that this form barges ahead of queued threads even on a fair semaphore.
  • tryAcquire(long timeout, TimeUnit unit) — waits up to the given time for a permit, then gives up. Unlike the untimed form, this one respects the fairness setting.
  • release() — returns a permit to the semaphore. An overload releases several at once.
  • availablePermits() — returns the number of permits currently available. Useful for monitoring; unreliable for control flow, since the value can change the moment after you read it.
  • drainPermits() — acquires and returns all permits that are immediately available.
  • hasQueuedThreads() and getQueueLength() — report whether, and how many, threads are waiting. Both are estimates intended for monitoring and debugging.
  • isFair() — returns whether the semaphore uses FIFO ordering.
  • reducePermits(int reduction) — shrinks the number of available permits. This method is protected, so it is reachable only from a subclass, not from ordinary application code.

Semaphore vs. Mutex vs. Lock

A mutex — short for mutual exclusion — is a synchronization primitive that lets exactly one thread hold it at a time and ties that hold to the acquiring thread. Java has no class named Mutex. Its mutex is ReentrantLock, or the built-in synchronized keyword.

A binary semaphore is a semaphore created with a single permit, as in new Semaphore(1). It also admits one thread at a time, which is why it is often described as interchangeable with a mutex. It is not. The difference is ownership.

BehaviorSemaphore (new Semaphore(n))Mutex (ReentrantLock, synchronized)
Threads admitted at oncen, chosen at constructionExactly one
OwnershipNone — a permit is not tied to any threadHeld by the acquiring thread
Who may releaseAny thread, including one that never acquiredOnly the owner; otherwise IllegalMonitorStateException
ReentrantNo — a second acquire() consumes a second permitYes — tracked with a hold count
CountingYes — permits can be added and removedNo
Primary purposeBounding concurrency and signaling between threadsProtecting a critical section

Two consequences follow, and both are practical rather than academic.

First, because a semaphore has no owner, one thread can acquire a permit and a completely different thread can release it. That is a bug when you wanted mutual exclusion, and precisely the feature you want when a producer must signal a consumer, or when work is handed between threads in an asynchronous pipeline.

Second, because a semaphore is not reentrant, a thread that already holds the only permit and calls acquire() again blocks forever waiting for itself. A ReentrantLock in the same position simply increments its hold count. This makes new Semaphore(1) a poor substitute for a lock in any code path that might re-enter, such as recursion or a callback that loops back into the guarded method.

Common Pitfalls

  • Releasing outside finally — an exception on the guarded path permanently destroys a permit.
  • Releasing a permit that was never acquiredrelease() does not check, so a stray call silently raises the permit ceiling above the limit you designed.
  • Assuming reentrancy — a thread cannot re-acquire a permit it already holds. Use a lock when re-entry is possible.
  • Unbounded acquire() — blocking with no timeout turns a slow dependency into a stalled thread pool. Prefer tryAcquire with a timeout at system boundaries.
  • Multi-permit deadlockacquire(n) is all-or-nothing, so two threads each holding part of what the other needs will wait forever. Acquire multi-permit batches in a consistent order.
  • Branching on availablePermits() — the count is stale the instant it returns. Use tryAcquire() to test and take a permit atomically.

From Single-JVM Semaphores to Distributed Semaphores

Every semaphore described so far lives inside one JVM. new Semaphore(5) caps concurrency at five per process. Run that application on four servers and the real limit is twenty, because each instance keeps its own private counter and none of them can see the others.

That gap matters most in exactly the situations semaphores are chosen for. A cap on concurrent calls to a payment gateway, a license limiting how many workers may run a job, or a quota on connections to a legacy database are all global constraints, and an in-process counter cannot enforce a global constraint.

Enforcing the limit across the whole cluster requires the permit counter to live somewhere every instance can reach — the same reasoning that leads from an in-process lock to a distributed lock. With Valkey or Redis holding the counter, the permits become cluster-wide.

Semaphores and the Bulkhead Pattern

Bounding concurrency in order to contain a failure is a named pattern. A ship's hull is divided into watertight compartments so that a breach floods one of them instead of sinking the vessel; the bulkhead pattern applies the same idea to software, giving each dependency a fixed allowance of callers so that one slow service cannot consume every thread in the application. A semaphore is the usual way to draw that compartment wall.

In Java the best-known implementation is the Bulkhead module in Resilience4j, which comes in two forms. The semaphore-based bulkhead admits maxConcurrentCalls callers at once — 25 by default — and rejects the rest immediately unless maxWaitDuration is raised from its default of zero. The thread-pool-based bulkhead hands each call to a bounded pool of its own, so a dependency that hangs consumes those threads rather than the caller's, at the cost of a handoff and of whatever the calling thread was carrying in a ThreadLocal. Neither form adds a timeout; in Resilience4j that is a separate TimeLimiter, applied outside the bulkhead.

Both keep their configuration in an in-memory registry, so the compartment is drawn per process. Twenty instances each admitting 25 concurrent calls — the semaphore bulkhead's default — present up to 500 to the dependency they were meant to protect, which has one capacity budget rather than one per container. A bulkhead that has to hold at the fleet level therefore needs what any other cluster-wide limit needs: a counter the instances share. The same arithmetic applied to requests per second is worked through in Bucket4j vs Resilience4j.

Distributed Java Semaphores With Redisson

Redisson implements Valkey and Redis based locks and synchronizers, including RSemaphore, a distributed counterpart to java.util.concurrent.Semaphore with a familiar API:

RSemaphore semaphore = redisson.getSemaphore("mySemaphore");

// set the cluster-wide permit count once;
// returns false if the semaphore was already initialized
semaphore.trySetPermits(5);

semaphore.acquire();          // blocks until a permit is free
try {
    // at most 5 threads across the entire cluster run this
} finally {
    semaphore.release();
}

As in plain Java, prefer a timed acquisition at system boundaries so a saturated semaphore degrades instead of stalling. Permits can also be taken in batches, which is all-or-nothing:

RSemaphore semaphore = redisson.getSemaphore("mySemaphore");

// wait up to 15 seconds for a single permit
if (semaphore.tryAcquire(15, TimeUnit.SECONDS)) {
    try {
        // ...
    } finally {
        semaphore.release();
    }
}

// or reserve 3 of the 5 permits at once, waiting up to 15 seconds
if (semaphore.tryAcquire(3, 15, TimeUnit.SECONDS)) {
    try {
        // ...
    } finally {
        semaphore.release(3);   // release exactly as many as you acquired
    }
}

The ownership rule carries over from plain Java, and Redisson's documentation makes deliberate use of it: an RLock may only be unlocked by the thread that locked it, so when a permit must be released by a different thread — or a different JVM entirely — RSemaphore is the object to reach for.

Because a distributed permit counter is shared state on a replicated server, Redisson verifies after each acquisition that the change reached the connected replicas, so a failover cannot quietly hand the same permit to a second client. The checkLockSyncedSlaves setting controls the check and is enabled by default; slavesSyncTimeout (default 1000 ms) bounds the wait, and both apply to RSemaphore and RPermitExpirableSemaphore as well as to locks.

Permits That Expire: RPermitExpirableSemaphore

A distributed semaphore inherits a hazard its single-JVM ancestor never had: a process can crash while holding a permit. The finally block never runs, and the permit is lost for good.

RPermitExpirableSemaphore solves this by giving a permit a lease time and its own identifier. When the lease expires the permit returns to the pool automatically, and because each permit is identified by an id, it can only be released with that id.

RPermitExpirableSemaphore semaphore = redisson.getPermitExpirableSemaphore("mySemaphore");

// must be initialized before use
semaphore.trySetPermits(23);

// wait up to 10 seconds for a permit that leases for 15 seconds
String id = semaphore.tryAcquire(10, 15, TimeUnit.SECONDS);
if (id != null) {
    try {
        // ...
    } finally {
        semaphore.release(id); // released by id, not by thread
    }
}

A blocking acquire(leaseTime, unit) is available too, returning the permit id once one is free. Note that the argument order differs between the two semaphore types: on RSemaphore the leading integer is a permit count, while here it is the wait time.

The lease is opt-in, and worth stating plainly: the overloads without a lease argument — acquire() and tryAcquire() — hand out permits that do not expire, and there is no watchdog renewing them in the background as there is for RLock. Choosing the lease is therefore part of the design. Too short and it returns a permit while the call is still running, over-admitting to the dependency the limit was protecting; too long and a crashed instance holds its permit until the lease runs out. Size it above the slowest call you are willing to wait for, and use updateLeaseTime where the work legitimately runs longer than expected.

Unlike RSemaphore, this object must be initialized with trySetPermits(permits) before use, and the pool can be resized later with addPermits(permits). Both semaphore types are also available through Redisson's Async, Reactive, and RxJava3 APIs, so the same permit semantics apply whichever programming model your application uses.

Java Semaphore: Frequently Asked Questions

What Is the Difference Between a Semaphore and a Mutex?

A mutex admits one thread and belongs to the thread that acquired it, so only that thread can release it, and it can usually re-acquire it. A semaphore admits a configurable number of threads and has no owner, so any thread may release a permit and re-acquiring consumes a second permit. Use a mutex to protect a critical section; use a semaphore to cap concurrency or to signal between threads.

Does Java Have a Mutex Class?

No. What developers usually mean by a Java mutex is either the synchronized keyword or ReentrantLock, both of which provide owner-based mutual exclusion. new Semaphore(1) restricts access to one thread but does not track ownership, so it is not a true mutex.

What Is a Binary Semaphore in Java?

A semaphore constructed with one permit, written new Semaphore(1). It permits one thread at a time, but any thread can release the permit and the holder cannot re-acquire it, so it behaves as a signaling mechanism rather than a lock.

Is a Java Semaphore Reentrant?

No. A thread that calls acquire() twice consumes two permits. If only one permit exists, the thread blocks against itself indefinitely. Use ReentrantLock when a thread may re-enter the guarded section.

What Is the Difference Between a Semaphore and a CountDownLatch?

A semaphore's counter goes both up and down and is reusable indefinitely. A CountDownLatch only counts down; once it reaches zero it stays there and cannot be reset. Use a semaphore to limit concurrent access, a latch to wait until a set of tasks has finished.

How Do I Share a Semaphore Across Multiple JVMs?

java.util.concurrent.Semaphore cannot leave its process, so each instance would enforce its own separate limit. Use a distributed semaphore backed by a shared store — for example Redisson's RSemaphore or RPermitExpirableSemaphore, whose permit counter lives in Valkey or Redis and applies across every instance.

What Is the Bulkhead Pattern?

The bulkhead pattern isolates failures by giving each dependency a fixed allowance of concurrent callers, so that one slow or failing service cannot exhaust the threads the rest of the application needs. The name comes from the watertight compartments in a ship's hull. A semaphore is the usual mechanism: set the permit count to the allowance, acquire a permit before calling the dependency, and shed load when none is free.

What Is the Difference Between a Bulkhead and a Circuit Breaker?

A bulkhead limits how many calls may be in flight at once, and it applies continuously. A circuit breaker watches the outcome of calls and stops sending them altogether once the failure rate crosses a threshold; after a wait it lets a limited number of real calls through as trials and closes again if they succeed. It generates no traffic of its own. A bulkhead caps the damage a slow dependency can do while it is still answering; a circuit breaker gives up on one that is not. Resilience4j applies both, with the bulkhead innermost.

Similar terms