What is a Semaphore?

Learning Objectives

  • Define semaphores and understand the concurrent access problem they solve
  • Differentiate between binary semaphores (mutex) and counting semaphores
  • Master the core operations: acquire() and release() and their behavior
  • Implement practical examples using Java's Semaphore class
  • Recognize common use cases like resource pooling and rate limiting
  • Understand deadlock risks and proper semaphore usage patterns

Introduction

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.

Understanding the Problem Semaphores Solve

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.

Binary Semaphores vs Counting Semaphores

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.

Core Operations: acquire() and release()

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.

Practical Resource Pool Implementation

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.

Fair vs Unfair Semaphore Scheduling

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.

Summary

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.

Complete Core Java Programming Course

Master Java from scratch to advanced! This comprehensive bootcamp covers everything from your first line of code to building complex applications, with expert guidance on collections, multithreading, exception handling, and more.

Leave a Comment