Semaphores vs Mutex: Internals, Pitfalls and When to Use Each
Every production system that handles concurrency — web servers juggling thousands of requests, database connection pools, real-time trading engines — relies on two primitives to stay sane: semaphores and mutexes. Get them wrong and you don't get a compiler error. You get a data race that only shows up under load at 2 AM on a Friday. That's why this topic matters — the bugs it prevents are the ones that end careers.
The problem both primitives solve is the same: shared state modified by multiple threads simultaneously produces unpredictable results. Without coordination, two threads can read the same value, each increment it, and write back — losing one update entirely. This is the classic lost-update problem, and it's been killing software since the 1960s. Semaphores and mutexes are the industry's answer, but they solve subtly different problems, and using the wrong one is a trap senior engineers fall into too.
By the end of this article you'll understand exactly how each primitive works at the kernel level, why ownership semantics change everything, how to implement both correctly in Java, how to identify priority inversion and deadlock in production, and — critically — you'll have a decision framework for choosing between them that you can apply immediately in code reviews or system design interviews.
How Mutexes Actually Work — Ownership, Kernel Mode and the Futex Trick
A mutex (mutual exclusion lock) is a binary lock with strict ownership: the thread that acquires it is the only thread allowed to release it. This sounds simple, but the implementation is where things get interesting.
On Linux, modern mutexes are built on futexes (Fast Userspace muTEXes). The insight is that most of the time, a mutex is uncontested — no other thread is waiting. In that happy path, acquiring and releasing the lock is just an atomic CAS (compare-and-swap) on a 32-bit integer in userspace. Zero kernel involvement. Zero syscall overhead. That's why a good mutex is blazingly fast under low contention.
Only when contention happens — when thread B tries to lock what thread A already holds — does the kernel get involved. The kernel parks thread B in a wait queue, puts it to sleep, and wakes it up when thread A calls unlock. This park/unpark cycle is expensive (microseconds, not nanoseconds), which is why you should minimize lock hold time aggressively.
Java's synchronized keyword and ReentrantLock both map to this model. ReentrantLock adds re-entrancy: the owning thread can acquire the same lock multiple times without deadlocking itself, as long as it releases it the same number of times. That counter is tracked per-thread in the lock's internal state.
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** * Demonstrates a mutex (ReentrantLock) protecting a shared bank account balance. * Two threads attempt concurrent withdrawals — the mutex ensures correctness. */ public class BankAccountWithMutex { private double balance; // ReentrantLock is Java's explicit mutex — it has ownership semantics: // only the thread that called lock() is allowed to call unlock(). private final ReentrantLock balanceLock = new ReentrantLock(); public BankAccountWithMutex(double initialBalance) { this.balance = initialBalance; } /** * Withdraws an amount if sufficient funds exist. * Returns true if the withdrawal succeeded, false otherwise. */ public boolean withdraw(String threadName, double amount) { // lock() blocks until THIS thread owns the lock. // No other thread can enter this block simultaneously. balanceLock.lock(); try { System.out.printf("[%s] Acquired lock. Balance: %.2f, Requesting: %.2f%n", threadName, balance, amount); if (balance < amount) { System.out.printf("[%s] Insufficient funds. Withdrawal denied.%n", threadName); return false; } // Simulate some processing time — this is the critical section. // Without the mutex, two threads could both pass the balance check // and both withdraw, leaving the account negative. Thread.sleep(50); // checked exception handled by try-catch below balance -= amount; System.out.printf("[%s] Withdrew %.2f. New balance: %.2f%n", threadName, amount, balance); return true; } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); // restore interrupt flag — NEVER swallow this return false; } finally { // ALWAYS release in a finally block — if an exception is thrown above, // the lock MUST still be released or every other thread waits forever. balanceLock.unlock(); System.out.printf("[%s] Released lock.%n", threadName); } } public static void main(String[] args) throws InterruptedException { BankAccountWithMutex account = new BankAccountWithMutex(100.00); ExecutorService executor = Executors.newFixedThreadPool(2); // Both threads attempt to withdraw 80 — only one should succeed. executor.submit(() -> account.withdraw("Thread-Alice", 80.00)); executor.submit(() -> account.withdraw("Thread-Bob", 80.00)); executor.shutdown(); executor.awaitTermination(5, TimeUnit.SECONDS); System.out.printf("%nFinal balance: %.2f%n", account.balance); } }
[Thread-Alice] Withdrew 80.00. New balance: 20.00
[Thread-Alice] Released lock.
[Thread-Bob] Acquired lock. Balance: 20.00, Requesting: 80.00
[Thread-Bob] Insufficient funds. Withdrawal denied.
[Thread-Bob] Released lock.
Final balance: 20.00
How Semaphores Actually Work — Counting, Signaling and Why Ownership Doesn't Exist
A semaphore maintains an integer counter and two atomic operations: acquire (decrement, block if zero) and release (increment, wake a waiter if any). The critical architectural difference from a mutex: any thread can call release, regardless of which thread called acquire. There is no ownership.
This makes semaphores a signaling primitive, not a locking primitive. Thread A can acquire a semaphore and thread B can release it. That's intentional — it's what makes semaphores ideal for producer-consumer scenarios where one thread produces a resource and another consumes it.
A binary semaphore (max count = 1) looks superficially like a mutex, but lacks ownership. This distinction has real consequences: a binary semaphore can be released by a thread that never acquired it, which can corrupt your invariant. This is why the advice 'a binary semaphore is the same as a mutex' is dangerously wrong.
Under the hood, Java's Semaphore class uses AbstractQueuedSynchronizer (AQS) — the same framework backing ReentrantLock. AQS maintains a CLH queue of waiting threads and an atomic state integer. For a semaphore initialized with N permits, the state starts at N. Each acquire does an atomic decrement; each release does an atomic increment and unparks a waiting thread if one exists. Fair vs unfair mode determines whether waiting threads are served FIFO or allowed to barge in — unfair is faster but can starve threads.
import java.util.concurrent.Semaphore; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.ArrayDeque; import java.util.Queue; /** * A simplified database connection pool backed by a Semaphore. * The semaphore acts as a ticket counter: only MAX_CONNECTIONS threads * can hold a connection simultaneously. Others wait until one is returned. * * This is a classic real-world semaphore use case — controlling access * to a fixed pool of resources where any thread can "check in" a resource. */ public class DatabaseConnectionPool { private static final int MAX_CONNECTIONS = 3; // Semaphore initialized with the number of available permits (connections). // fair=true means threads acquire in arrival order — prevents starvation // in production. Costs a bit of throughput due to the CLH queue overhead. private final Semaphore connectionPermits = new Semaphore(MAX_CONNECTIONS, true); // The actual pool of connection objects (simplified as strings here). private final Queue<String> availableConnections = new ArrayDeque<>(); public DatabaseConnectionPool() { for (int i = 1; i <= MAX_CONNECTIONS; i++) { availableConnections.add("Connection-" + i); } } /** * Borrows a connection from the pool, blocking if none are available. * The semaphore enforces that at most MAX_CONNECTIONS threads * hold a connection at any time. */ public String borrowConnection(String workerName) throws InterruptedException { System.out.printf("[%s] Waiting for a connection... (permits available: %d)%n", workerName, connectionPermits.availablePermits()); // acquire() decrements the permit count by 1. // If the count is already 0, this thread parks (blocks) here // until another thread calls release(). connectionPermits.acquire(); // synchronized only on the Queue access — the semaphore already // guarantees at most MAX_CONNECTIONS threads reach here at once. String connection; synchronized (availableConnections) { connection = availableConnections.poll(); } System.out.printf("[%s] Acquired %s (permits remaining: %d)%n", workerName, connection, connectionPermits.availablePermits()); return connection; } /** * Returns a connection to the pool. * NOTE: Any thread can call this — the semaphore has no concept of * ownership. This is by design for pool scenarios, but means you * must ensure callers return the correct connection object. */ public void returnConnection(String workerName, String connection) { synchronized (availableConnections) { availableConnections.offer(connection); } // release() increments the permit count and wakes a waiting thread. // Crucially: this thread does NOT need to be the one that called acquire(). connectionPermits.release(); System.out.printf("[%s] Returned %s (permits now: %d)%n", workerName, connection, connectionPermits.availablePermits()); } public static void main(String[] args) throws InterruptedException { DatabaseConnectionPool pool = new DatabaseConnectionPool(); // 5 workers competing for 3 connections — 2 must always wait. ExecutorService executor = Executors.newFixedThreadPool(5); for (int workerId = 1; workerId <= 5; workerId++) { final String workerName = "Worker-" + workerId; executor.submit(() -> { try { String conn = pool.borrowConnection(workerName); // Simulate using the connection for some work. Thread.sleep(200); pool.returnConnection(workerName, conn); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } executor.shutdown(); executor.awaitTermination(10, TimeUnit.SECONDS); System.out.println("\nAll workers done. Pool permits: " + pool.connectionPermits.availablePermits()); } }
[Worker-2] Waiting for a connection... (permits available: 3)
[Worker-3] Waiting for a connection... (permits available: 3)
[Worker-4] Waiting for a connection... (permits available: 3)
[Worker-5] Waiting for a connection... (permits available: 3)
[Worker-1] Acquired Connection-1 (permits remaining: 2)
[Worker-2] Acquired Connection-2 (permits remaining: 1)
[Worker-3] Acquired Connection-3 (permits remaining: 0)
[Worker-1] Returned Connection-1 (permits now: 1)
[Worker-4] Acquired Connection-1 (permits remaining: 0)
[Worker-2] Returned Connection-2 (permits now: 1)
[Worker-5] Acquired Connection-2 (permits remaining: 0)
[Worker-3] Returned Connection-3 (permits now: 1)
[Worker-4] Returned Connection-1 (permits now: 2)
[Worker-5] Returned Connection-2 (permits now: 3)
All workers done. Pool permits: 3
Priority Inversion, Deadlock and the Production Failures Nobody Warns You About
Priority inversion is one of the most famous real-world concurrency bugs. It famously caused the Mars Pathfinder to reset repeatedly in 1997. Here's the scenario: a low-priority thread L holds a mutex. A high-priority thread H needs that same mutex and blocks. Meanwhile, a medium-priority thread M runs freely because H is blocked. The net effect: M — the lowest-priority task — is effectively running at the expense of H, inverting the priority ordering. The fix is priority inheritance: the OS temporarily boosts L's priority to match H's so it finishes fast and releases the mutex. POSIX mutexes support this with PTHREAD_PRIO_INHERIT. Java's monitor locks do not implement priority inheritance — something to know if you're running hard real-time workloads on the JVM.
Deadlock with mutexes follows a predictable pattern: thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1. The fix is lock ordering: always acquire multiple locks in the same global order across all code paths. Document the order. Enforce it in code review.
With semaphores, the symmetric deadlock is less common but permit leaks are insidious: if a thread acquires a permit and exits without releasing it (due to an exception, a return statement, or a missed code path), the effective pool size shrinks permanently until restart. Always use try/finally.
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.TimeUnit; /** * Demonstrates deadlock caused by inconsistent lock ordering, * then shows the fix: always acquire locks in a globally agreed order. * * Scenario: transferring money between two accounts requires locking both. * Naive approach deadlocks. Fixed approach uses consistent lock ordering. */ public class DeadlockDemoAndFix { static class Account { final int id; double balance; final ReentrantLock lock = new ReentrantLock(); Account(int id, double balance) { this.id = id; this.balance = balance; } } /** * BROKEN: Lock order depends on argument order. * Thread 1 calls transfer(accountA, accountB, 50) -> locks A, then B. * Thread 2 calls transfer(accountB, accountA, 30) -> locks B, then A. * -> Classic deadlock. */ static void transferBroken(Account from, Account to, double amount) throws InterruptedException { from.lock.lock(); System.out.printf("[%s] Locked account %d, waiting for account %d...%n", Thread.currentThread().getName(), from.id, to.id); Thread.sleep(50); // exaggerate the race window for demo purposes to.lock.lock(); try { from.balance -= amount; to.balance += amount; } finally { to.lock.unlock(); from.lock.unlock(); } } /** * FIXED: Lock order is determined by account ID — a global invariant. * Regardless of argument order, we always lock the lower-ID account first. * Both threads now agree on ordering -> deadlock is impossible. * * Uses tryLock() with timeout as an additional safety net. */ static boolean transferFixed(Account first, Account second, double amount) throws InterruptedException { // Enforce consistent global lock ordering by account ID. Account lockFirst = first.id < second.id ? first : second; Account lockSecond = first.id < second.id ? second : first; // tryLock with timeout: fail fast rather than wait forever. // In production, you'd retry or surface an error to the caller. if (!lockFirst.lock.tryLock(1, TimeUnit.SECONDS)) { System.out.printf("[%s] Could not acquire lock on account %d — aborting transfer.%n", Thread.currentThread().getName(), lockFirst.id); return false; } try { if (!lockSecond.lock.tryLock(1, TimeUnit.SECONDS)) { System.out.printf("[%s] Could not acquire lock on account %d — aborting transfer.%n", Thread.currentThread().getName(), lockSecond.id); return false; } try { // We now hold BOTH locks safely. first.balance -= amount; second.balance += amount; System.out.printf("[%s] Transfer of %.2f complete. Balances: A=%.2f, B=%.2f%n", Thread.currentThread().getName(), amount, first.balance, second.balance); return true; } finally { lockSecond.lock.unlock(); } } finally { lockFirst.lock.unlock(); } } public static void main(String[] args) throws InterruptedException { Account accountA = new Account(1, 500.0); Account accountB = new Account(2, 300.0); System.out.println("=== Testing FIXED transfer (no deadlock) ==="); Thread thread1 = new Thread(() -> { try { transferFixed(accountA, accountB, 50.0); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Thread-1"); Thread thread2 = new Thread(() -> { try { transferFixed(accountB, accountA, 30.0); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Thread-2"); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.printf("Final — Account A: %.2f, Account B: %.2f%n", accountA.balance, accountB.balance); } }
[Thread-1] Transfer of 50.00 complete. Balances: A=450.00, B=350.00
[Thread-2] Transfer of 30.00 complete. Balances: B=320.00, A=480.00
Final — Account A: 480.00, Account B: 320.00
| Feature / Aspect | Mutex (ReentrantLock) | Semaphore |
|---|---|---|
| Ownership | Strict — only acquiring thread can release | None — any thread can release |
| Count | Binary (locked / unlocked) | Integer N >= 1 (counting permits) |
| Primary use case | Protecting a critical section | Controlling access to N resources / signaling |
| Re-entrancy | Yes (ReentrantLock tracks depth) | No — acquiring twice without releasing deadlocks the thread |
| Can be released by another thread? | No — throws IllegalMonitorStateException | Yes — this is intentional and useful |
| Deadlock risk | Lock ordering violations | Permit leaks (forgetting to release) |
| Priority inversion mitigation | Supported in POSIX; not in Java monitors | N/A — not a mutex; ownership doesn't apply |
| Performance (uncontested) | Very fast (futex, userspace CAS) | Slightly heavier (AQS state management) |
| Best Java class | ReentrantLock | java.util.concurrent.Semaphore |
| Fair mode available? | Yes (ReentrantLock fair constructor) | Yes (Semaphore fair constructor) |
🎯 Key Takeaways
- Ownership is the defining difference: a mutex can only be released by the thread that locked it — a semaphore can be released by any thread. Treat this as a fundamental architectural choice, not a minor API difference.
- Mutexes protect critical sections; semaphores control resource pools and enable cross-thread signaling. If you find yourself using a semaphore to protect a critical section, you almost certainly want a mutex.
- Deadlock with mutexes is prevented by enforcing a global lock acquisition order across all code paths — document the order, enforce it in review, and use tryLock() with timeouts as a safety net.
- In Java, always release semaphore permits in a finally block and use tryAcquire() with a timeout in production — silent permit leaks and indefinite blocking are both invisible until they collapse your thread pool.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using a binary semaphore as a mutex — A semaphore with 1 permit looks like a mutex but has no ownership. Thread B can call release() on a semaphore Thread A acquired, bumping the permit count above 1 and breaking your invariant. Symptom: multiple threads enter what should be a critical section. Fix: use ReentrantLock or synchronized when you need mutual exclusion with ownership semantics.
- ✕Mistake 2: Forgetting to release a semaphore permit in all code paths — A checked exception, an early return, or a missed branch leaves a permit permanently consumed. Symptom: the effective pool shrinks over time, eventually starving all threads — classic slow memory leak behavior but for permits. Fix: always wrap acquire/release in try/finally: acquire() before try, release() in finally.
- ✕Mistake 3: Holding a mutex across I/O or blocking calls — Locking, then making a network call or DB query inside the critical section, means every other thread waits for the I/O latency. Symptom: throughput collapses under concurrent load; thread dumps show dozens of threads blocked on the same lock. Fix: read/prepare data while unlocked, lock only for the in-memory state update, release immediately. Keep critical sections to nanoseconds, not milliseconds.
Interview Questions on This Topic
- QWhat is the difference between a mutex and a binary semaphore, and can you give a concrete example where using one instead of the other causes a bug?
- QExplain priority inversion. When does it occur with mutexes, why doesn't Java's synchronized block protect against it, and what OS-level mechanism addresses it?
- QYou have a connection pool of size 10 and 50 threads. You protect the pool with a Semaphore(10). A thread acquires a permit, throws an uncaught RuntimeException, and the thread dies. What happens to the permit? How do you fix this in production code?
Frequently Asked Questions
Can a semaphore be used as a mutex?
A binary semaphore (initialized with 1 permit) can enforce mutual exclusion, but it's not a true mutex because it lacks ownership — any thread can release it, even one that never acquired it. This makes it unsuitable for protecting critical sections where the same thread must lock and unlock. Use ReentrantLock or synchronized for that.
What happens if a thread that holds a mutex dies without releasing it?
In Java, if a thread holding a ReentrantLock dies without calling unlock(), the lock remains permanently locked and all waiting threads block forever — this is a deadlock. Always release locks in a finally block. POSIX mutexes support 'robust' mode which notifies waiters that the owner died, but Java has no equivalent — making finally non-negotiable.
Is Java's synchronized keyword a mutex or a semaphore?
Java's synchronized is a mutex — it has ownership semantics, meaning only the thread that entered the synchronized block can exit it (releasing the monitor). It's also re-entrant by default: the same thread can enter nested synchronized blocks on the same object without deadlocking. It does not support timeouts, tryLock, or fair queuing — which is why ReentrantLock exists.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.