Semaphore vs ReentrantLock in Java: Key Differences

Semaphore vs ReentrantLock in Java compared with a table and code for ownership, reentrancy, IllegalMonitorStateException, conditions and when to use each.

ReentrantLock with one owner thread holding count 2 and a waiting thread, next to a Semaphore with three permits held by three threads and a release from a fourth thread

The main difference between Semaphore and ReentrantLock is ownership, because a ReentrantLock lets one thread in, remembers that thread as its owner and lets it enter again, whereas a Semaphore is a counter of permits that lets n threads in and does not track who holds them. Both classes have been part of the java.util.concurrent packages since Java 5.

We use a ReentrantLock to protect shared state, such as the seat map of a ticket booking app, and a Semaphore to limit how many threads use a resource at once, such as 5 parallel calls to a payment gateway.

The following example shows both classes in one booking flow.

Semaphore paymentSlots = new Semaphore(5);              // 5 threads at a time, no owner
ReentrantLock seatLock = new ReentrantLock();           // 1 thread at a time, has an owner
paymentSlots.acquire();
int freeSlots = paymentSlots.availablePermits();        // 4
seatLock.lock();
seatLock.lock();                                        // the owner enters again
int holds = seatLock.getHoldCount();                    // 2
seatLock.unlock();
seatLock.unlock();
paymentSlots.release();
boolean locked = seatLock.isLocked();                   // false

Notice that the lock counts how often its owner entered, while the semaphore only counts free permits. The comparison table comes first, followed by each class on its own and the differences one by one, with code for each.

FeatureSemaphoreReentrantLock
Threads allowed at oncen, set in the constructor1
OwnerNoneThe thread that called lock()
ReentrantNo, a holder that asks again blocksYes, the hold count goes up
Release by another threadAllowedIllegalMonitorStateException
ConditionsNonenewCondition()
Try with timeouttryAcquire(timeout, unit)tryLock(timeout, unit)
Fair modenew Semaphore(n, true)new ReentrantLock(true)
Typical useLimit access to a pool of resources, signalsMutual exclusion on shared state

1. What is a Semaphore?

A Semaphore is a thread synchronization construct that acts as a counter of permits. It is part of the java.util.concurrent package since Java 5. The counter goes down when a thread takes a permit and up when a thread returns one.

  • The acquire() method takes a permit and lowers the counter by one.
  • The release() method returns a permit and raises the counter by one.

If the counter is zero, all permits are in use by other threads, and acquire() blocks the current thread until a permit is released. The JDK has only this one semaphore class. A semaphore created with one permit is called a binary semaphore, and one with more permits is a counting semaphore. The TimedSemaphore that some tutorials list comes from Apache Commons Lang, not from the JDK.

Semaphore binarySemaphore = new Semaphore(1);
binarySemaphore.acquire();
int duringWork = binarySemaphore.availablePermits();    // 0
binarySemaphore.release();
int afterWork = binarySemaphore.availablePermits();     // 1

In real code, we take the permit before a try block and return it in finally. The following example is a payment client that allows 5 parallel calls to the gateway.

class PaymentClient {
    private final Semaphore slots = new Semaphore(5);

    String pay(String booking) throws InterruptedException {
        slots.acquire();
        try {
            return booking + " paid";                   // call the payment gateway
        } finally {
            slots.release();
        }
    }
}
PaymentClient client = new PaymentClient();
String receipt = client.pay("booking-7");               // "booking-7 paid"

2. What is a ReentrantLock?

The ReentrantLock class implements the Lock interface and works like the synchronized keyword, with extra features such as timeouts, interruptible waits and conditions. Reentrant means that the thread holding the lock can acquire it again without blocking.

  • Each lock() call by the owner raises the hold count by one.
  • Each unlock() call lowers the hold count by one.
  • The lock is free for other threads only when the hold count is back to 0, so a thread that entered twice has to call unlock() twice.
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();                                   // hold count 1
reentrantLock.lock();                                   // hold count 2
boolean locked = reentrantLock.isLocked();              // true
boolean mine = reentrantLock.isHeldByCurrentThread();   // true
reentrantLock.unlock();
int holdCount = reentrantLock.getHoldCount();           // 1
boolean stillLocked = reentrantLock.isLocked();         // true
reentrantLock.unlock();
int finalCount = reentrantLock.getHoldCount();          // 0
boolean lockedAtEnd = reentrantLock.isLocked();         // false

The usual pattern calls lock() right before the try block and unlock() in finally. The following example is the seat map of the booking app, where two users must never book the same seat.

class SeatMap {
    private final ReentrantLock lock = new ReentrantLock();
    private final Set<String> booked = new HashSet<>();

    boolean book(String seat) {
        lock.lock();
        try {
            return booked.add(seat);                    // check and add as one step
        } finally {
            lock.unlock();
        }
    }
}
SeatMap seats = new SeatMap();
boolean firstUser = seats.book("12A");                  // true
boolean secondUser = seats.book("12A");                 // false, already booked

3. Difference between Semaphore and ReentrantLock

Both classes block threads and both build on the same JDK base class, AbstractQueuedSynchronizer. They differ in what they count and in who is allowed to give access back, which leads to the differences below.

ReentrantLock with one owner thread holding count 2 and a waiting thread, next to a Semaphore with three permits held by three threads and a release from a fourth thread
A lock has one owner that can enter again, a semaphore has n permits and no owner

3.1. Reentrant Nature

Semaphores are not reentrant. A thread that holds the only permit and calls acquire() again blocks itself forever, which is a deadlock of one thread. A ReentrantLock lets its owner call lock() as often as it needs, which matters when one locked method calls another locked method of the same object.

Semaphore single = new Semaphore(1);
single.acquire();
boolean again = single.tryAcquire(100, TimeUnit.MILLISECONDS);   // false, blocks itself
ReentrantLock lock = new ReentrantLock();
lock.lock();
boolean relock = lock.tryLock();                        // true, the owner enters again
lock.unlock();
lock.unlock();

3.2. Synchronization Mechanism

A semaphore is a counting mechanism. It lets up to n threads work at the same time, and it is also a good fit for signal passing, where one thread calls release() to wake a thread that waits in acquire().

A ReentrantLock is a mutual exclusion mechanism. One thread locks the resource with lock(), works on it and releases it with unlock(), and every other thread waits. The lock also guarantees that the next owner sees all changes the previous owner made. A semaphore gives the same visibility guarantee from a release() to the next acquire(), but with n permits, n threads can still change the same data at once.

3.3. Deadlock Recovery

A binary semaphore can be released by a thread other than the one that acquired it, which can help in specialized cases such as deadlock recovery. For example, a watchdog thread can release a permit that a stuck thread holds. That frees the waiting threads, but the stuck thread may still be in the middle of its work, so two threads can end up using the resource.

A ReentrantLock cannot be released by another thread. Instead, it gives us tools to avoid getting stuck in the first place. The tryLock(timeout, unit) method gives up after a timeout, and lockInterruptibly() lets another thread stop the wait with interrupt(). The deadlock article shows both in a running example.

3.4. Throwing IllegalMonitorStateException

A thread that calls unlock() on a ReentrantLock it does not hold gets an IllegalMonitorStateException, while a release() on a semaphore always succeeds. No thread owns a permit, so any thread can call release(), and the count goes up even beyond its initial value.

ReentrantLock lock = new ReentrantLock();
lock.unlock();                                          // IllegalMonitorStateException
Semaphore permits = new Semaphore(0);
Thread other = Thread.ofPlatform().start(permits::release);
other.join();
int count = permits.availablePermits();                 // 1, released by another thread

The exception is useful, because it reports a wrong unlock() at once. The same bug with a semaphore stays hidden and raises the number of threads allowed in. The IllegalMonitorStateException article lists the other causes of the exception.

3.5. Conditions and Waiting for a State

A ReentrantLock can create any number of Condition objects with newCondition(). A thread that holds the lock calls await() to wait for a state, such as “a seat became free”, and another thread calls signal(). A semaphore has no conditions, so waiting for a state needs extra code.

ReentrantLock lock = new ReentrantLock();
Condition seatFreed = lock.newCondition();
lock.lock();
boolean signaled = seatFreed.await(50, TimeUnit.MILLISECONDS);   // false, nobody signaled
lock.unlock();
boolean stillLocked = lock.isLocked();                  // false

The await() call releases the lock while it waits and takes it back before it returns, which is the same behavior that wait() and notify() have for synchronized.

3.6. Timeouts and Fairness

Both classes offer a timed attempt and a fair mode, so these features do not decide between them. The tryAcquire(timeout, unit) and tryLock(timeout, unit) methods return false when the wait times out, and the constructors new Semaphore(n, true) and new ReentrantLock(true) hand out access in arrival order.

Semaphore fairSlots = new Semaphore(5, true);
ReentrantLock fairLock = new ReentrantLock(true);
boolean slot = fairSlots.tryAcquire(100, TimeUnit.MILLISECONDS);  // true
boolean entered = fairLock.tryLock(100, TimeUnit.MILLISECONDS);   // true
boolean bothFair = fairSlots.isFair() && fairLock.isFair();       // true

In both classes, the untimed tryAcquire() and tryLock() ignore fairness and take a free permit or a free lock at once, even when other threads wait.

4. When to Use Semaphore or ReentrantLock?

The choice follows from what we protect. A Semaphore limits the number of threads, and a ReentrantLock protects the consistency of data.

SituationChoose
At most 5 parallel calls to a payment gatewaySemaphore(5)
Book a seat so that two users cannot get itReentrantLock or synchronized
Wait until a seat becomes freeReentrantLock with a Condition
One thread signals another that data is readySemaphore(0) or a CountDownLatch
Limit thousands of virtual threads to 20 database connectionsSemaphore(20)
Many readers and few writers of a cacheReentrantReadWriteLock

A semaphore fits non-ownership-release semantics, where more than one thread can enter and the semaphore cares only about free permits, not about which thread calls acquire() and release(). For reentrant mutual exclusion or a plain mutex, ReentrantLock is the better choice, and for simple cases synchronized is shorter. Since Java 24, virtual threads no longer pin their carrier thread inside synchronized (JEP 491), so that old reason to prefer ReentrantLock is gone. The Java concurrency overview groups the other tools by task.

5. Semaphore vs ReentrantLock FAQs

A one-permit semaphore looks like a lock, so most questions ask whether one can stand in for the other.

5.1. Can Semaphore(1) replace a ReentrantLock?

Only for simple mutual exclusion without nesting. A Semaphore(1) blocks a thread that enters twice, lets any thread release it and has no conditions. When a locked method calls another locked method, or when we need Condition objects, we use a ReentrantLock.

5.2. Is Semaphore reentrant in Java?

No. The semaphore does not know which thread holds a permit, so a second acquire() by the same thread takes another permit, or waits when none is left.

5.3. Does Semaphore.release() throw IllegalMonitorStateException?

No. The method release() never checks the caller, and it raises the count even above the initial value. Only lock classes such as ReentrantLock, and wait()/notify() outside a synchronized block, throw IllegalMonitorStateException.

5.4. Which is faster, Semaphore or ReentrantLock?

Neither by design, because both use AbstractQueuedSynchronizer for queuing and blocking. Speed depends on contention and on fair or non-fair mode much more than on the class, so we choose by meaning and measure the app when it matters.

6. Conclusion

A ReentrantLock has one owner at a time, lets that owner enter again and throws IllegalMonitorStateException when another thread tries to release it. We use it to keep shared data consistent, and its conditions and interruptible waits help with more complex coordination.

A Semaphore has n permits and no owner. We use it to limit how many threads use a resource and to pass signals between threads, and we keep its acquire() and release() calls paired, because nothing else checks them.

7. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.