- Define semaphores and understand the concurrent access problem they solve
- Differentiate between binary semaphores (mutex) and counting semaphores
- Master the core operations:
acquire()andrelease()and their behavior - Implement practical examples using Java's
Semaphoreclass - Recognize common use cases like resource pooling and rate limiting
- Understand deadlock risks and proper semaphore usage patterns
A semaphore is a synchronization construct that controls access to shared resources by maintaining an internal counter
representing available permits. When multiple threads compete for limited resources—database connections, file handles,
or network sockets—uncontrolled access leads to race conditions, data corruption, and resource exhaustion. Computer
scientist Edsger Dijkstra introduced semaphores in 1965 as a solution to the critical section problem, providing a
mathematical foundation for coordinating concurrent processes. The mechanism operates through two atomic operations that
either decrease or increase the permit count, blocking threads when no permits remain. Modern Java implements semaphores
through the java.util.concurrent.Semaphore class, which provides both fair and unfair scheduling policies for waiting
threads. This synchronization primitive remains essential for scenarios requiring precise control over resource access
limits, from connection pooling to rate limiting in distributed systems.
Concurrent programming introduces race conditions when multiple threads access shared resources without coordination. Consider a scenario where a service limits database connections to prevent overwhelming the server. Without synchronization, threads might exceed this limit, causing connection failures:
package academy.javapro;
public class RaceConditionDemo {
static class ConnectionPool {
private int activeConnections = 0;
private final int MAX_CONNECTIONS = 3;
public void getConnection() throws InterruptedException {
// Race condition: Multiple threads read activeConnections as 2
if (activeConnections < MAX_CONNECTIONS) {
// All threads pass the check before any increments
Thread.sleep(10); // Simulates connection setup time
activeConnections++;
System.out.println(Thread.currentThread().getName() +
" got connection. Active: " + activeConnections);
}
}
public void releaseConnection() {
activeConnections--;
}
}
public static void main(String[] args) {
ConnectionPool pool = new ConnectionPool();
// Launch 5 threads trying to get connections
for (int i = 1; i <= 5; i++) {
Thread t = new Thread(() -> {
try {
pool.getConnection();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "Thread-" + i);
t.start();
}
}
}This code exhibits a classic race condition: multiple threads read activeConnections before any increment occurs,
allowing more connections than permitted. Semaphores eliminate this problem through atomic permit management.
Semaphores come in two fundamental varieties, each suited to different synchronization requirements. A binary semaphore maintains only two states (0 or 1), functioning as a mutual exclusion lock. A counting semaphore maintains any non-negative integer value, enabling controlled multi-thread access:
package academy.javapro;
import java.util.concurrent.Semaphore;
public class SemaphoreTypesDemo {
// Binary semaphore - mutual exclusion
static class PrinterAccess {
private final Semaphore binary = new Semaphore(1); // Only 0 or 1
public void print(String document) throws InterruptedException {
binary.acquire(); // Get exclusive access
try {
System.out.println(Thread.currentThread().getName() +
" printing: " + document);
Thread.sleep(1000); // Simulate printing
} finally {
binary.release(); // Release exclusive access
}
}
}
// Counting semaphore - limited concurrent access
static class DatabasePool {
private final Semaphore counting = new Semaphore(3); // 3 permits
public void query(String sql) throws InterruptedException {
counting.acquire(); // Get one of 3 permits
try {
System.out.println(Thread.currentThread().getName() +
" executing: " + sql +
" (Available permits: " + counting.availablePermits() + ")");
Thread.sleep(1000); // Simulate query
} finally {
counting.release(); // Return permit
}
}
}
public static void main(String[] args) {
// Binary example - only one thread prints at a time
PrinterAccess printer = new PrinterAccess();
for (int i = 1; i <= 3; i++) {
int docNum = i;
new Thread(() -> {
try {
printer.print("Document-" + docNum);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "PrintThread-" + i).start();
}
// Counting example - up to 3 concurrent queries
DatabasePool db = new DatabasePool();
for (int i = 1; i <= 5; i++) {
int queryNum = i;
new Thread(() -> {
try {
db.query("SELECT * FROM table" + queryNum);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "QueryThread-" + i).start();
}
}
}Binary semaphores enforce strict mutual exclusion, while counting semaphores enable controlled parallelism for scalable resource management.
The semaphore protocol revolves around two atomic operations. The acquire() operation attempts to obtain a permit,
blocking if none are available. The release() operation returns a permit, potentially unblocking waiting threads:
package academy.javapro;
import java.util.concurrent.Semaphore;
public class SemaphoreOperationsDemo {
static class ResourceManager {
private final Semaphore semaphore = new Semaphore(2);
private int resourcesInUse = 0;
public void useResource(String taskName) {
try {
System.out.println(taskName + " requesting resource...");
// acquire() - blocks if count is 0
semaphore.acquire();
synchronized (this) {
resourcesInUse++;
System.out.println(taskName + " acquired resource. " +
"In use: " + resourcesInUse +
", Available permits: " + semaphore.availablePermits());
}
// Simulate resource usage
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// release() - increments count, wakes waiting thread
synchronized (this) {
resourcesInUse--;
System.out.println(taskName + " releasing resource. " +
"In use: " + resourcesInUse);
}
semaphore.release();
}
}
}
public static void main(String[] args) {
ResourceManager manager = new ResourceManager();
// Create 4 threads competing for 2 resources
for (int i = 1; i <= 4; i++) {
String taskName = "Task-" + i;
new Thread(() -> manager.useResource(taskName)).start();
}
}
}When acquire() executes with permits available, it decrements the counter atomically and proceeds. With zero permits,
the calling thread blocks until another thread calls release(). This blocking behavior creates natural backpressure in
resource-constrained systems.
Resource pooling represents the canonical semaphore use case. Database connection pools, thread pools, and object pools all benefit from semaphore-based access control:
package academy.javapro;
import java.util.concurrent.Semaphore;
import java.util.LinkedList;
import java.util.Queue;
public class ConnectionPoolDemo {
static class Connection {
private final int id;
Connection(int id) {
this.id = id;
}
public void execute(String query) {
System.out.println("Connection-" + id + " executing: " + query);
}
}
static class ConnectionPool {
private final Queue<Connection> pool;
private final Semaphore semaphore;
public ConnectionPool(int size) {
this.semaphore = new Semaphore(size);
this.pool = new LinkedList<>();
for (int i = 0; i < size; i++) {
pool.offer(new Connection(i + 1));
}
}
public Connection acquire() throws InterruptedException {
semaphore.acquire();
synchronized (pool) {
return pool.poll();
}
}
public void release(Connection connection) {
synchronized (pool) {
pool.offer(connection);
}
semaphore.release();
}
}
public static void main(String[] args) {
ConnectionPool pool = new ConnectionPool(3);
// Simulate 5 clients needing connections
for (int i = 1; i <= 5; i++) {
int clientId = i;
new Thread(() -> {
try {
System.out.println("Client-" + clientId + " requesting connection");
Connection conn = pool.acquire();
System.out.println("Client-" + clientId + " acquired connection");
conn.execute("SELECT * FROM client_" + clientId);
Thread.sleep(1000);
pool.release(conn);
System.out.println("Client-" + clientId + " released connection");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}The semaphore ensures the pool never hands out more connections than available, preventing resource exhaustion while maximizing utilization.
Java's Semaphore class offers two scheduling policies for waiting threads. Unfair scheduling (default) allows thread
starvation but provides better throughput. Fair scheduling guarantees FIFO ordering at the cost of performance:
package academy.javapro;
import java.util.concurrent.Semaphore;
public class FairnessDemoDemo {
static class Resource {
private final Semaphore unfair = new Semaphore(1, false);
private final Semaphore fair = new Semaphore(1, true);
public void demonstrateUnfair() throws InterruptedException {
for (int i = 1; i <= 3; i++) {
int threadId = i;
new Thread(() -> {
try {
System.out.println("Thread-" + threadId + " waiting (unfair)");
unfair.acquire();
System.out.println("Thread-" + threadId + " acquired (unfair)");
Thread.sleep(100);
unfair.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
Thread.sleep(10); // Ensure ordering
}
}
public void demonstrateFair() throws InterruptedException {
for (int i = 1; i <= 3; i++) {
int threadId = i;
new Thread(() -> {
try {
System.out.println("Thread-" + threadId + " waiting (fair)");
fair.acquire();
System.out.println("Thread-" + threadId + " acquired (fair)");
Thread.sleep(100);
fair.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
Thread.sleep(10); // Ensure ordering
}
}
}
public static void main(String[] args) throws InterruptedException {
Resource resource = new Resource();
System.out.println("Unfair scheduling (may not preserve order):");
resource.demonstrateUnfair();
Thread.sleep(1000);
System.out.println("\nFair scheduling (preserves FIFO order):");
resource.demonstrateFair();
}
}Fair semaphores prevent starvation in high-contention scenarios but incur additional overhead from maintaining the wait queue order.
Semaphores provide a robust mechanism for controlling concurrent access to limited resources through permit-based
synchronization. The fundamental operations—acquire() to obtain permits and release() to return them—create a simple
yet powerful protocol for preventing race conditions and resource exhaustion. Binary semaphores enforce mutual exclusion
for critical sections, while counting semaphores enable controlled parallelism for resource pools. Java's implementation
in java.util.concurrent.Semaphore offers both fair and unfair scheduling policies, allowing developers to balance
between throughput and starvation prevention. Understanding semaphores equips developers to build scalable concurrent
systems that gracefully handle resource constraints without compromising thread safety or system stability.
Looking for a free Java course to launch your software career? Start mastering enterprise programming with our no-cost Java Fundamentals module, the perfect introduction before you accelerate into our intensive Java Bootcamp. Guided by industry professionals, the full program equips you with the real-world expertise employers demand, covering essential data structures, algorithms, and Spring Framework development through hands-on projects. Whether you're a complete beginner or an experienced developer, you can Enroll in your free Java Fundamentals Course here! and transform your passion into a rewarding career.