Java

Semaphore in Java

4 min read Updated Mar 21, 2026

Engineering Notes and Practical Examples

A Semaphore controls how many threads can access a shared resource at the same time.

Problem description:

We need to limit concurrency against a shared dependency or scarce resource instead of letting unlimited requests pile in.

What we are solving actually:

We are solving admission control, not scheduling. The semaphore decides how many callers may proceed at once and forces the rest to wait or fail fast.

What we are doing actually:

  1. Create a semaphore with a permit count equal to safe concurrency.
  2. Acquire before entering the protected work.
  3. Release in finally so permits are never leaked.
flowchart LR
    A[Incoming task] --> B{Permit available?}
    B -->|Yes| C[Acquire permit]
    B -->|No| D[Wait or timeout]
    C --> E[Use dependency]
    E --> F[Release permit]

Real-World Use Cases

  • limiting concurrent outbound API calls
  • database connection throttling
  • download/upload slot management
  • rate-limited integrations

Core Concept

A semaphore has permits:

  • acquire() takes a permit (waits if none available)
  • release() returns a permit

You can also choose fairness:

Semaphore fair = new Semaphore(10, true);

Fair semaphores reduce starvation risk, but may have lower throughput.

Java 8 Style Example

import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    private static final Semaphore semaphore = new Semaphore(2);

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            final int id = i;
            new Thread(() -> runTask(id), "worker-" + id).start();
        }
    }

    private static void runTask(int id) {
        try {
            semaphore.acquire();
            System.out.println("Task " + id + " acquired permit");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            semaphore.release();
            System.out.println("Task " + id + " released permit");
        }
    }
}

Timeout Pattern (tryAcquire)

In request paths, blocking forever is dangerous. Prefer timeout-based acquisition.

if (!semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)) {
    throw new RuntimeException("dependency busy, please retry");
}
try {
    callDownstream();
} finally {
    semaphore.release();
}

This gives predictable backpressure behavior under saturation.

Bulkhead Pattern with Semaphore

A common production pattern is one semaphore per downstream dependency:

  • paymentApiSemaphore for payment provider
  • searchApiSemaphore for search dependency
  • emailApiSemaphore for notifications

This prevents one slow dependency from consuming all worker capacity.

Permit Leak Prevention

Permit leaks are serious: eventually all requests block.

Checklist:

  1. release only if acquisition succeeded
  2. use finally always
  3. avoid branching paths that skip release
  4. track available permits and waiting threads in metrics

JDK 11 and Java 17 Notes

Semaphore API is stable in JDK 11 and Java 17. The same concurrency-limit pattern is still the recommended approach.

Java 21+ Example (Virtual Threads)

try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
    Semaphore semaphore = new Semaphore(50);

    for (int i = 0; i < 10_000; i++) {
        int id = i;
        executor.submit(() -> {
            semaphore.acquire();
            try {
                // remote call / IO task
            } finally {
                semaphore.release();
            }
            return id;
        });
    }
}

This is useful when you run many lightweight tasks but still need strict external resource limits.

Java 25 Note

Semaphore API remains stable. The same design works; focus on observability and backpressure instead of API migration.

Monitoring Checklist

  • availablePermits() trend
  • acquire timeout count
  • average wait time before acquire
  • request failure rate during saturation

Debug steps:

  • log acquisition failures and timeouts separately from downstream failures
  • verify permits are released only after successful acquisition
  • compare availablePermits() trends with real downstream saturation
  • use fairness only when starvation risk matters more than throughput

These signals tell you whether permit counts match real dependency capacity.

Key Takeaways

  • Semaphore is a concurrency limiter, not a queue.
  • Always release permits in finally.
  • Combine semaphores with retry and timeout logic in production systems.
  • Prefer timeout-based acquisition on latency-sensitive paths.

Categories

Tags

Comments