Java Semaphore: acquire(), release(), tryAcquire() Examples

Java Semaphore explained with examples for acquire(), release(), tryAcquire() timeouts, fairness, throttling an executor and limiting virtual threads.

Five threads competing for three semaphore permits, three threads hold a permit and two wait in the queue

A Semaphore in Java is a counter of permits that limits how many threads can use a resource at the same time. A thread calls acquire() to take a permit before it uses the resource and release() to give the permit back. When no permit is left, acquire() blocks the thread until another thread releases one.

We use a semaphore to cap the number of parallel calls to a slow or rate-limited service or the number of open connections, and to stop a producer from submitting more tasks than an executor can handle. The class is part of java.util.concurrent since Java 5, and it fits well with virtual threads, where we no longer limit concurrency with the size of a thread pool.

The following example allows at most 3 parallel calls to a PDF rendering service.

Semaphore renderSlots = new Semaphore(3);             // 3 permits
renderSlots.acquire();                                  // takes a permit, blocks when none is left
int left = renderSlots.availablePermits();              // 2
try {
    String file = "sales" + ".pdf";                     // call the rendering service here
} finally {
    renderSlots.release();                              // always gives the permit back
}
int afterRelease = renderSlots.availablePermits();      // 3
boolean gotOne = renderSlots.tryAcquire(200, TimeUnit.MILLISECONDS);   // true

Notice that the permit is taken before the try block and returned in finally, so an exception in the service call never loses a permit. We start with how permits work, build a rate-limited client, and cover timeouts, weighted permits, fairness, throttling an executor and virtual threads, followed by the mistakes that break a semaphore.

1. How Semaphore Permits Work

A semaphore holds a number of permits, set in the constructor. Each successful acquire() lowers the number by one, and each release() raises it by one. A thread that calls acquire() while the count is zero waits in the semaphore queue, and the next release() wakes one waiting thread, which takes the permit.

Five threads competing for three semaphore permits, three threads hold a permit and two wait in the queue
With 3 permits, three threads work at once and the others wait until a permit is released

The permits are only a count. The Semaphore class does not hand out real objects and does not remember which thread took a permit, so any thread can call release(). We get the most out of that design when the count matches a real limit, such as the 3 parallel requests a partner API accepts or the 20 connections in a database pool.

MethodWhat it doesBlocks?
acquire(), acquire(n)Takes one or n permitsYes, until the permits are available, and throws InterruptedException on interrupt
tryAcquire(), tryAcquire(n)Takes the permits if they are free, returns false otherwiseNo
tryAcquire(timeout, unit)Waits up to the timeout, returns true or falseUp to the timeout
acquireUninterruptibly()Takes a permit and ignores interrupts while waitingYes
release(), release(n)Adds one or n permitsNo
availablePermits(), drainPermits()Reads the count, or takes all free permits and returns how manyNo

2. Limiting Parallel Calls to a Slow Service

Say our reporting app exports PDF files through a rendering service that accepts 3 parallel requests and answers with HTTP 429 to the fourth one. Users can start exports at any time, so the app has to queue the extra calls instead of sending them.

The following example wraps the service call in a PdfRenderer class. The class holds a semaphore with 3 permits and two counters, inside for the calls in progress and peak for the highest value inside ever reached.

class PdfRenderer {
    private final Semaphore slots = new Semaphore(3);
    private final AtomicInteger inside = new AtomicInteger();
    private final AtomicInteger peak = new AtomicInteger();

    String render(String report) throws InterruptedException {
        slots.acquire();
        try {
            int running = inside.incrementAndGet();
            peak.accumulateAndGet(running, Math::max);
            LockSupport.parkNanos(Duration.ofMillis(50).toNanos());   // the remote call
            return report + ".pdf";
        } finally {
            inside.decrementAndGet();
            slots.release();
        }
    }

    int peak() {
        return peak.get();
    }
}

We submit 10 exports at once, each on its own virtual thread, and read the results after the executor closes. The close() method of ExecutorService waits for all tasks, so every future is complete when we call resultNow().

PdfRenderer renderer = new PdfRenderer();
List<Future<String>> futures = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 1; i <= 10; i++) {
        String report = "report-" + i;
        futures.add(executor.submit(() -> renderer.render(report)));
    }
}
List<String> files = futures.stream().map(Future::resultNow).toList();
String first = files.getFirst();                        // "report-1.pdf"
int exported = files.size();                            // 10
int peak = renderer.peak();                             // 3

All 10 threads start together, yet the peak stays at 3, because the seven other threads wait in acquire() until a running call releases its permit. The callers need no change, and the limit is defined in one place.

3. Acquire Before try, Release in finally

A permit must be released once and only once for each successful acquire(), and never when acquire() failed. A common version of the code puts acquire() inside the try block, which looks safe but breaks the count when the waiting thread is interrupted.

static void renderWrong(Semaphore slots, Runnable call) {
    try {
        slots.acquire();
        call.run();
    } catch (InterruptedException e) {
        e.printStackTrace();                            // the interrupt flag is lost
    } finally {
        slots.release();                                // runs even when acquire() failed
    }
}

When the thread is interrupted while it waits, acquire() throws InterruptedException without taking a permit, and the finally block still adds one. A semaphore that starts with 0 permits ends with 1 that nobody ever owned.

Semaphore slots = new Semaphore(0);
Thread.currentThread().interrupt();                     // simulate a cancelled request
renderWrong(slots, () -> {});
int leaked = slots.availablePermits();                  // 1

The safe version calls acquire() before the try block and passes the interrupt to the caller, so the permit count stays correct and the cancellation reaches the code that started the task. The InterruptedException guide explains how to handle the exception further up.

static void renderSafe(Semaphore slots, Runnable call) throws InterruptedException {
    slots.acquire();                                    // throws before we hold a permit
    try {
        call.run();
    } finally {
        slots.release();                                // only after a successful acquire
    }
}
Semaphore slots = new Semaphore(0);
Thread.currentThread().interrupt();
renderSafe(slots, () -> {});                            // InterruptedException
int permits = slots.availablePermits();                 // 0

4. Waiting With a Timeout Using tryAcquire()

A user who clicks Export does not want to wait a minute for a free slot. The method tryAcquire(timeout, unit) waits up to the given time and returns false when no permit became free, so we can show a message or return HTTP 503 instead of blocking the request thread.

static String renderOrBusy(Semaphore slots, String report) throws InterruptedException {
    if (!slots.tryAcquire(200, TimeUnit.MILLISECONDS)) {
        return "busy, retry later";
    }
    try {
        return report + ".pdf";
    } finally {
        slots.release();
    }
}
Semaphore slots = new Semaphore(1);
String first = renderOrBusy(slots, "sales");            // "sales.pdf"
slots.acquire();                                        // another caller holds the only permit
String second = renderOrBusy(slots, "stock");           // "busy, retry later"
boolean noWait = slots.tryAcquire();                    // false

The no-argument tryAcquire() does not wait at all. It suits code that has a cheap fallback, such as serving a cached report when every slot is busy.

5. Taking Several Permits at Once

Permits do not have to stand for one call each. When some jobs use more of the resource than others, we let them take more permits. For example, a yearly export renders 2 PDF pages in parallel, so it takes 2 of the 3 slots, and only one small export can run beside it.

Semaphore slots = new Semaphore(3);
slots.acquire(2);                                       // the yearly export takes 2 slots
int free = slots.availablePermits();                    // 1
boolean big = slots.tryAcquire(2);                      // false, only 1 left
boolean small = slots.tryAcquire();                     // true
slots.release(3);
int all = slots.availablePermits();                     // 3

The acquire(n) call takes all n permits at once or waits, and never holds part of them while it waits. A thread that takes several permits must release the same number, because release() without an argument returns only one.

6. Fair and Non-Fair Semaphores

By default, a semaphore is non-fair. When a permit is released, a thread that arrives at that moment can take it before threads that have waited longer, which gives more throughput but can let a busy thread win again and again. The constructor Semaphore(permits, true) creates a fair semaphore that hands out permits in first-in, first-out order.

Semaphore fair = new Semaphore(3, true);
boolean isFair = fair.isFair();                         // true
boolean waiting = fair.hasQueuedThreads();              // false
boolean inOrder = fair.tryAcquire(0, TimeUnit.SECONDS); // true, respects the queue

The untimed tryAcquire() ignores fairness and takes a free permit even when other threads wait. To respect the queue on a fair semaphore, we call tryAcquire(0, TimeUnit.SECONDS) instead. We choose a fair semaphore when a long wait for some users is worse than a small loss of throughput, for example for user-facing exports.

7. Throttling Task Submission to an Executor

A ThreadPoolExecutor with a bounded queue rejects tasks once its threads and its queue are full, and an executor with an unbounded queue keeps accepting tasks until the app runs out of memory. A semaphore around submit() solves both problems, because the caller blocks when too many tasks are in flight instead of getting a rejection or growing the queue.

The following example is a ThrottledExecutor that wraps any ExecutorService. It takes a permit before it submits a task and releases the permit when the task finishes, not when submit() returns.

class ThrottledExecutor {
    private final ExecutorService executor;
    private final Semaphore slots;

    ThrottledExecutor(ExecutorService executor, int maxInFlight) {
        this.executor = executor;
        this.slots = new Semaphore(maxInFlight);
    }

    Future<?> submit(Runnable task) throws InterruptedException {
        slots.acquire();                                // blocks the caller at the limit
        try {
            return executor.submit(() -> {
                try {
                    task.run();
                } finally {
                    slots.release();                    // the task has finished
                }
            });
        } catch (RejectedExecutionException e) {
            slots.release();                            // the task never started
            throw e;
        }
    }
}
ExecutorService pool = Executors.newFixedThreadPool(2);
ThrottledExecutor throttled = new ThrottledExecutor(pool, 4);   // 2 running + 2 waiting
AtomicInteger done = new AtomicInteger();
for (int i = 0; i < 20; i++) {
    throttled.submit(() -> {
        LockSupport.parkNanos(Duration.ofMillis(10).toNanos());
        done.incrementAndGet();
    });
}
pool.close();
int completed = done.get();                             // 20

At most 4 tasks exist at any time, so the queue of the fixed pool never holds more than a few entries, and no task is rejected. A permit released right after submit() returns limits nothing, so the permit has to stay taken while the task waits and runs. For the other way to react to a full pool, see the RejectedExecutionHandler guide and the BlockingQueue with ThreadPoolExecutor example.

8. Limiting Concurrency With Virtual Threads

With virtual threads, final since Java 21, we create one cheap thread per task and do not pool them. A fixed pool size used to limit concurrency as a side effect, so that limit has to come from somewhere else, and JEP 444 recommends a semaphore for that purpose.

The following example starts 1,000 tasks on a virtual thread per task executor and lets only 10 of them use the database at once, matching a connection pool of 10.

Semaphore dbSlots = new Semaphore(10);
AtomicInteger inside = new AtomicInteger();
AtomicInteger peak = new AtomicInteger();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000; i++) {
        executor.submit(() -> {
            dbSlots.acquire();
            try {
                peak.accumulateAndGet(inside.incrementAndGet(), Math::max);
                LockSupport.parkNanos(Duration.ofMillis(5).toNanos());   // the query
            } finally {
                inside.decrementAndGet();
                dbSlots.release();
            }
            return null;
        });
    }
}
int maxParallel = peak.get();                           // 10

A virtual thread that waits in acquire() unmounts from its carrier thread, so 990 waiting tasks cost a little memory and no platform threads. The task returns null so the lambda is a Callable, which is allowed to throw InterruptedException.

9. Common Semaphore Mistakes

A semaphore has no owner and no upper bound, so the compiler and the runtime catch none of its misuse. These mistakes show up in production as a limit that slowly grows or a service that slowly stops.

  • An extra release() adds a permit for good. The initial count is not a maximum, so a bug that releases twice raises the limit by one each time.
  • A missing release() leaks a permit. After enough exceptions on a path without finally, every thread waits forever.
  • A thread that calls acquire() twice on a semaphore with one permit blocks itself, because a semaphore is not reentrant. The binary semaphore post shows that case.
  • A semaphore limits how many tasks run at the same time, not how many start per second. A partner API with a limit of 10 requests per second needs a rate limiter.
Semaphore slots = new Semaphore(2);
slots.release();                                        // no acquire() before it
int permits = slots.availablePermits();                 // 3
int drained = slots.drainPermits();                     // 3
int remaining = slots.availablePermits();               // 0

The drainPermits() method takes every free permit and returns the count, which helps when we want to pause all new work, for example during a maintenance window. Releasing the same count later resumes it.

10. Semaphore Compared With Locks and Thread Pools

A semaphore, a lock and a thread pool all limit concurrent work, but they limit different things. A lock lets one thread at a time into a block of code and knows which thread holds it, whereas a semaphore lets n threads in and tracks only the count.

ToolLimitsOwner trackedTypical use
Semaphore(n)n threads at the same timeNoConnections, partner API calls, in-flight tasks
ReentrantLock, synchronizedOne thread at a timeYes, and reentrantProtecting shared state
Fixed thread poolNumber of platform threadsNot applicableCPU-bound work
Rate limiterCalls per time unitNoRequests per second to an API

The Semaphore vs ReentrantLock article compares the two in detail, and the Lock tutorial covers the lock side. All the tools of the package are grouped by task on the Java concurrency page.

11. Java Semaphore FAQs

Ownership, rate limits and the choice between a semaphore and a pool are what developers ask about once their first semaphore works.

11.1. Can a thread release a permit it did not acquire?

Yes. A releasing thread does not need to have acquired a permit, because semaphores have no notion of ownership. Correct pairing of acquire() and release() is up to our code, which is why the try–finally pattern matters.

11.2. What is the difference between a semaphore and a mutex?

A mutex lets one thread in and only that thread can release it. A semaphore with one permit also lets one thread in, but any thread can release it and the same thread cannot take it twice. In Java, we use ReentrantLock or synchronized as a mutex.

11.3. Can the number of permits go above the initial value?

Yes. Every release() adds a permit, with no upper bound, so new Semaphore(2) followed by one extra release() has 3 permits. The constructor even accepts a negative count, in which case releases must happen before any acquire() succeeds.

11.4. Should we use a semaphore or a fixed thread pool to limit concurrency?

With virtual threads, we use a semaphore, because pooling virtual threads defeats their purpose. With platform threads, a fixed pool limits the threads, and a semaphore still helps when only one step of the task, such as a database call, needs a tighter limit than the pool size.

12. Conclusion

A Semaphore holds a number of permits, and acquire() and release() take and return them. We use it to cap parallel calls to a slow service or the in-flight tasks in front of an executor, and to limit virtual threads, which are no longer limited by a pool size.

The safe pattern is acquire() before try and release() in finally, with tryAcquire() and a timeout when the caller should not wait forever. Because the semaphore tracks no owner and has no maximum, every extra or missing release() changes the limit for the rest of the app’s life.

13. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. Also, in the printerQueue class, you are initializing the array with a for loop. If you are going to initialize all of the elements to
    the same value, Arrays.fill() is a bit simpler.

            Arrays.fill(freePrinters, true);
    
  2. You have an error above in the page. You are suggesting that the counter variable must always be 0 >= n >= 5. The operators need to be changed to <=

    • The lock is needed for updating the printer array. If you do not use lock then printer array won’t be synchronised.

      • We could use binary semaphore in replace of lock-reentrantLock. However, which one is preferred and will have benefits of using lock vs binary semaphore ? appreciate your inputs. Thanks.

Comments are closed.

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.