Skip to content

Semaphore in Java: Permits, Leaks and Virtual Threads

A Java Semaphore is a counter with a wait queue, not a lock. Why release() can mint permits, how leaks starve a pool, and how to cap virtual threads safely.

java-concurrency concurrent-collections virtual-threads
Gopi Gorantala
Reading Progress

On This Page

A Semaphore(3) with five threads inside

$ java Lab.java extra
start            availablePermits=3
2x release()     availablePermits=5
max in flight    5 (gate was built with 3)

OpenJDK 21.0.12.1, 2 vCPU x86_64. Nothing threw. I built a Semaphore with three permits, called release() twice without ever calling acquire(), and the gate now lets five threads through at once. No warning, no exception, no log line.

That's the whole article in miniature. If you've searched for how a Semaphore in Java works, most of what you found says "a semaphore limits access to N threads". It does, as long as every release() in your codebase is paired with exactly one earlier acquire(). The class itself doesn't check that. Nobody does.

A counter with a wait queue, nothing more

A Semaphore holds a number (the permits) and a queue of threads waiting for that number to be above zero. acquire() takes one off the number, or parks the thread in the queue. release() adds one and wakes a waiter. That's it.

Permits don't belong to threads. Any thread can release one, including a thread that never acquired anything. The Javadoc says so in plain words, and even suggests a use for it (recovering from deadlock in specialised binary-semaphore code). Compare that with ReentrantLock, which throws IllegalMonitorStateException if the wrong thread unlocks. A semaphore has no owner to compare against, so it has nothing to throw.

The guarantee you do get is a memory one: whatever a thread did before release() is visible to the thread that completes a matching acquire(). Handing work from one thread to another through a semaphore is safe for visibility. Capping concurrency with it is safe only if you keep the books straight yourself.

Six small programs, about ten minutes

I ran all of this on OpenJDK 21.0.12.1 on a 2 vCPU x86_64 box with -XX:ActiveProcessorCount=2. No ARM, no JDK 22 or later, no JMH. The counts below are from that machine. Yours will differ in the digits and agree on the shape.

Save this as Lab.java. It's one file, run as java Lab.java <mode>.

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class Lab {
    public static void main(String[] a) throws Exception {
        switch (a[0]) {
            case "extra" -> extra();
            case "interrupt" -> interrupt();
            case "leak" -> leak();
            case "vt" -> vt(Integer.parseInt(a[1]), Integer.parseInt(a[2]));
            case "barge" -> barge(Boolean.parseBoolean(a[1]), Integer.parseInt(a[2]));
            case "finallybug" -> finallyBug();
        }
    }

    // permits are a counter, not a lock
    static void extra() throws Exception {
        Semaphore gate = new Semaphore(3);
        System.out.println("start            availablePermits=" + gate.availablePermits());
        gate.release(); gate.release(); // no acquire() before these: two permits from nowhere
        System.out.println("2x release()     availablePermits=" + gate.availablePermits());
        AtomicInteger in = new AtomicInteger(), max = new AtomicInteger();
        CountDownLatch done = new CountDownLatch(5);
        for (int i = 0; i < 5; i++) Thread.ofPlatform().start(() -> {
            try { gate.acquire(); int n = in.incrementAndGet(); max.accumulateAndGet(n, Math::max);
                  Thread.sleep(200); in.decrementAndGet(); gate.release();
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            finally { done.countDown(); }
        });
        done.await();
        System.out.println("max in flight    " + max.get() + " (gate was built with 3)");
    }

    // acquire() inside try, release() in finally: an interrupted acquire still releases
    static void finallyBug() throws Exception {
        Semaphore gate = new Semaphore(1);
        Thread t = new Thread(() -> {
            Thread.currentThread().interrupt();
            try { gate.acquire(); System.out.println("acquired"); }
            catch (InterruptedException e) { System.out.println("acquire threw"); }
            finally { gate.release(); }
        });
        t.start(); t.join();
        System.out.println("buggy shape: availablePermits=" + gate.availablePermits() + " (built with 1)");
        Semaphore g2 = new Semaphore(1);
        Thread t2 = new Thread(() -> {
            Thread.currentThread().interrupt();
            try { g2.acquire(); } catch (InterruptedException e) { System.out.println("acquire threw"); return; }
            try { System.out.println("work"); } finally { g2.release(); }
        });
        t2.start(); t2.join();
        System.out.println("right shape: availablePermits=" + g2.availablePermits());
    }

    static void interrupt() throws Exception {
        Semaphore s = new Semaphore(1);
        Thread.currentThread().interrupt();
        System.out.println("tryAcquire()               -> " + s.tryAcquire() + " flag still set=" + Thread.currentThread().isInterrupted());
        s.release();
        try { s.acquire(); System.out.println("acquire() got it"); }
        catch (InterruptedException e) { System.out.println("acquire() threw with a permit free; permits=" + s.availablePermits()); }
        Thread.currentThread().interrupt();
        s.acquireUninterruptibly();
        System.out.println("acquireUninterruptibly()   -> got it, flag=" + Thread.currentThread().isInterrupted());
    }

    // permit leak: release skipped on the exception path
    static void leak() throws Exception {
        Semaphore gate = new Semaphore(4);
        ExecutorService ex = Executors.newFixedThreadPool(8);
        AtomicInteger ok = new AtomicInteger();
        for (int i = 0; i < 60; i++) { final int n = i;
            ex.execute(() -> {
                try { gate.acquire(); } catch (InterruptedException e) { return; }
                if (n % 7 == 3) throw new IllegalStateException("gateway said no"); // release skipped
                try { Thread.sleep(5); ok.incrementAndGet(); } catch (InterruptedException e) { }
                gate.release();
            });
        }
        for (int s = 0; s < 4; s++) { Thread.sleep(500);
            System.out.println("t=" + (s+1)*500 + "ms done=" + ok.get() + " permits=" + gate.availablePermits() + " queued=" + gate.getQueueLength()); }
        System.out.println("pid=" + ProcessHandle.current().pid());
        if (System.getProperty("hold") != null) Thread.sleep(8000); // keep the JVM up for jcmd
        ex.shutdownNow();
    }

    static void vt(int tasks, int permits) throws Exception {
        Semaphore gate = new Semaphore(permits);
        AtomicInteger in = new AtomicInteger(), max = new AtomicInteger(), done = new AtomicInteger();
        long t0 = System.nanoTime();
        Thread.ofPlatform().daemon().start(() -> { try { Thread.sleep(300);
            System.out.println("mid-run: queued=" + gate.getQueueLength() + " permits=" + gate.availablePermits()
                + " liveThreads(platform)=" + Thread.getAllStackTraces().size()); } catch (Exception e) {} });
        try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < tasks; i++) ex.submit(() -> {
                gate.acquire();
                try { int n = in.incrementAndGet(); max.accumulateAndGet(n, Math::max); Thread.sleep(50); in.decrementAndGet(); done.incrementAndGet(); }
                finally { gate.release(); }
                return null;
            });
        }
        long ms = (System.nanoTime() - t0) / 1_000_000;
        System.out.println("tasks=" + tasks + " permits=" + permits + " done=" + done + " maxInFlight=" + max
            + " wall=" + ms + " ms (ideal " + (long) Math.ceil((double) tasks / permits) * 50 + ")");
    }

    // does a late tryAcquire() steal the permit from a queued waiter?
    static boolean TIMED = Boolean.getBoolean("timed");
    static void barge(boolean fair, int trials) throws Exception {
        int stolen = 0;
        for (int i = 0; i < trials; i++) {
            Semaphore s = new Semaphore(1, fair);
            s.acquire();
            Thread w = new Thread(() -> { try { s.acquire(); } catch (InterruptedException e) {} });
            w.start();
            while (s.getQueueLength() == 0) Thread.onSpinWait();   // waiter is now parked in the queue
            s.release();
            boolean thief = TIMED ? s.tryAcquire(0, TimeUnit.SECONDS) : s.tryAcquire();
            if (thief) { stolen++; s.release(); }
            w.join();
        }
        System.out.println("fair=" + fair + " tryAcquire() stole the permit from the queued waiter in " + stolen + " of " + trials);
    }
}

Step 1: the extra permits. java Lab.java extra prints the block at the top. It's deterministic. There's nothing probabilistic about a counter going up.

Step 2: the finally block that mints permits. java Lab.java finallybug:

acquire threw
buggy shape: availablePermits=2 (built with 1)
acquire threw
right shape: availablePermits=1

The buggy version is the shape in half the semaphore examples I've reviewed: try { sem.acquire(); ... } finally { sem.release(); }. If acquire() throws, the finally still runs, and you release something you never took. One interrupt, one extra permit. Do it enough times, say during a rolling restart when shutdown interrupts everything, and your cap of 20 becomes 50.

Step 3: interrupts and free permits. java Lab.java interrupt:

tryAcquire()               -> true flag still set=true
acquire() threw with a permit free; permits=1
acquireUninterruptibly()   -> got it, flag=true

I didn't expect the middle line. acquire() checks the interrupt flag before it looks at the permits, so a thread with the flag set gets InterruptedException even though a permit was sitting there. tryAcquire() with no arguments ignores the flag completely.

Step 4: the leak. java -Dhold=1 Lab.java leak, then jcmd <pid> Thread.print from another terminal while it sleeps:

t=500ms done=16 permits=0 queued=8
t=1000ms done=16 permits=0 queued=8
t=1500ms done=16 permits=0 queued=8
t=2000ms done=16 permits=0 queued=8
"pool-1-thread-1" #19 [6503] prio=5 ... waiting on condition
   java.lang.Thread.State: WAITING (parking)
	- parking to wait for  <0x00000000a7f31d00> (a java.util.concurrent.Semaphore$NonfairSync)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(...AbstractQueuedSynchronizer.java:1133)
	at java.util.concurrent.Semaphore.acquire(...Semaphore.java:318)
	at Lab.lambda$leak$3(Lab.java:75)

Eight workers, all parked at the same address, all in Semaphore.acquire. Counting frames, jcmd showed 8 of them. Sixty tasks submitted, sixteen finished, and the numbers never move again.

Step 5: virtual threads. java -XX:ActiveProcessorCount=2 Lab.java vt 10000 20, then vt 100000 200:

mid-run: queued=9880 permits=0 liveThreads(platform)=10
tasks=10000 permits=20 done=10000 maxInFlight=20 wall=25300 ms (ideal 25000)
mid-run: queued=24132 permits=0 liveThreads(platform)=10
tasks=100000 permits=200 done=100000 maxInFlight=200 wall=25788 ms (ideal 25000)

Step 6: barging. java Lab.java barge false 2000, barge true 2000, and -Dtimed=true barge true 2000:

fair=false tryAcquire() stole the permit from the queued waiter in 1208 of 2000
fair=true  tryAcquire() stole the permit from the queued waiter in 977 of 2000
fair=true, tryAcquire(0, SECONDS)           ... in 0 of 2000

Forcing levers. The leak lab throws on tasks 3, 10, 17 and so on, and four permits are enough to lose them all fast. In real life you'd see it over days. The barge lab spins until getQueueLength() is non-zero so the waiter is really parked before the release, which removes most of the timing luck. If you see nothing odd in step 6, you're probably on a machine with one usable core; run it with -XX:ActiveProcessorCount=2 or more.

Cleanup. jps, then kill any leftover Lab JVM. The leak mode with -Dhold stays alive for 8 seconds on purpose.

What a permit is, and who can hand one out

A permit is one unit of a shared count. Nothing more. Three things follow from that.

First, accounting is your job. Every acquire() needs exactly one release() on every path out, including the exceptional ones and including the one where acquire() itself throws. That's why the right shape in step 2 acquires outside the try and releases inside the finally, so the finally only exists if the acquire succeeded.

Second, a semaphore with one permit is not a lock. Another thread can release it. Nothing stops a bug elsewhere in your code from letting two threads into a section you thought was exclusive, and nothing makes the same thread's second acquire() succeed, so it isn't reentrant either. If you want a lock, use one.

Third, a semaphore caps concurrency, not rate. Twenty permits and a 50 ms call gives you a ceiling of 20 / 0.05 = 400 calls per second. My 10,000-task run took 25.3 s, which is 395 per second. That's Little's Law doing its job, not a rate limiter. Make the call faster and throughput rises with it; the cap doesn't care. If a provider says "no more than 100 requests per second", you need a token bucket or similar, and a semaphore alone is the wrong tool.

Where the JDK keeps the count

I'll be upfront: I didn't read Semaphore.java line by line for this one. What I did read was the Javadoc for Java 21 and the stack trace in step 4, which is more informative than it looks. Semaphore.acquire calls acquireSharedInterruptibly, which lands in AbstractQueuedSynchronizer.acquire, which parks the thread. The lock object in the dump is a Semaphore$NonfairSync. So a semaphore is a thin wrapper over the same queue machinery behind ReentrantLock and CountDownLatch, running in shared mode, where the synchronizer's state is the permit count and several threads can hold it at once. I'll go through that engine properly in the AQS article, which isn't written yet.

The other thing the dump gives you: NonfairSync or FairSync is right there in the class name. Check it before you argue about fairness.

What the Javadoc says about the fair flag: the default constructor is non-fair, which allows barging. A fair semaphore grants permits in FIFO order to threads that queue. Read that last part twice, because step 6 is about it.

A short version history, kept to what I'm confident about:

Java 5    Semaphore arrives with java.util.concurrent (JSR 166)
JDK 19+   virtual threads preview; JDK 21 final (JEP 444). Semaphore parks them like any thread
JDK 24    JEP 491 changes synchronized pinning; Semaphore never used a monitor, so unaffected

I verified the Semaphore behaviour on 21 only. The JEP 491 line is from the series notes, not something I ran.

How a semaphore quietly stops letting anyone in

Here's the failure I'd bet is behind most "the service hangs but nothing's wrong" pages involving a semaphore: permits only go down. Step 4 is that. A downstream call throws, the release sits after it instead of in a finally, and every failure eats a permit forever. Thread dumps show healthy parked workers. CPU is flat. No deadlock is reported, because there isn't one: findDeadlockedThreads() looks for lock cycles, and these threads are waiting on a count that nobody will ever raise.

The tell: availablePermits() is 0 and getQueueLength() doesn't change between two checks a minute apart. Contention moves. A leak sits there.

The reverse bug is the one from the opening. Permits only go up when a release runs without a matching acquire: the interrupted-acquire finally block, or a catch that releases "to be safe", or a retry wrapper that releases once per attempt. Nothing fails. The cap erodes, and you find out when the downstream database (which really does only want 20 connections) starts refusing.

I lost most of a day on the next one. I expected a fair semaphore to stop a late tryAcquire() from stealing a permit from a queued thread. Fairness is the whole point, right? I got 977 steals in 2,000 trials and assumed my lab was broken. I rewrote the wait loop twice. Then I went back to the Javadoc and found it, stated plainly: the no-argument tryAcquire() barges even on a fair semaphore, and the docs point you at tryAcquire(0, TimeUnit.SECONDS) if you want fairness respected. I swapped it in and got 0 of 2,000. The steal rate on the non-fair one was 1,208 of 2,000, so fairness did reduce stealing from the 60% region to the 49% region for the no-arg call, which is not what anyone means by fair. I'd call that a footgun, and it's documented.

Two more things that look like semaphore bugs and aren't:

  • "Release without acquire to signal." Using a semaphore initialised to 0 as a one-shot signal is legitimate, and the Javadoc allows negative initial counts too. It's only a bug when you meant a cap. Name the variable for what it is.
  • Pool-size semaphore inside a pool. Wrapping a Semaphore(20) around work running on a 10-thread pool means the semaphore never blocks anyone. The pool is the binding limit. Pick one place for the cap. The pool-side details are in the ThreadPoolExecutor article.

Writing it so permits can't drift, and what each choice costs

Why this shape works: the finally only exists once the acquire has succeeded, so release count can never exceed acquire count on any path.

sem.acquire();                 // throws InterruptedException: let it propagate or restore the flag
try {
    callGateway();
} finally {
    sem.release();
}

If you'd rather not wrestle with interrupts, acquireUninterruptibly() keeps waiting and re-sets the flag on return (step 3 shows it). That's reasonable for a short critical piece. It is the wrong choice on a request path where you need cancellation to work, as covered in the interruption article.

Choice What you get What it costs
acquire() cancellable wait must handle InterruptedException; throws even with a free permit
acquireUninterruptibly() never throws can't be cancelled while queued
tryAcquire() instant answer, never queues barges past queued threads, even if fair
tryAcquire(t, unit) bounded wait, honours fairness one more exception path, and t is a latency you've now chosen
fair Semaphore(n, true) FIFO among queued threads lower throughput under load (I did not benchmark it; no JMH here)

For the bulkhead use, I prefer tryAcquire(timeout) and treating false as load shedding: fail fast with a clear error rather than let the queue grow to 9,880 waiters like step 5 did. Those 9,880 were cheap only because virtual threads are cheap. They still hold memory and still make the request time out from the caller's side.

With virtual threads

Step 5 is the good news. 100,000 virtual threads, 200 permits, maxInFlight never above 200, and the process had 10 live platform threads while 24,132 virtual ones sat parked in the queue. Wall time came out at 25.8 s against an ideal of 25.0 s. That's how you cap concurrency now: let every task have its own thread and make the resource-guarding code acquire a permit, instead of sizing a pool to the limit. If you're moving from pools, the comparison is in virtual vs platform threads, which I'm still writing.

One note. I used Thread.sleep as the "call". A real blocking call that pins the carrier thread (native frames, or synchronized before JDK 24) changes this picture. Not tested here.

Watching permits in production, and retesting on upgrade

Expose three numbers per semaphore: availablePermits(), getQueueLength() and a counter of acquisitions. Alert on the combination: permits at 0 and the queue non-empty for longer than your slowest legitimate call, with no completed acquisitions in that window. That catches the leak. For the erosion bug, assert availablePermits() <= capacity in a test or a periodic health check; if it ever exceeds the number you built it with, someone released without acquiring.

In a thread dump, search for Semaphore.acquire and look at how many threads share one parking to wait for address. Take three dumps ten seconds apart. The same threads, same address, same line means a leak, not load. jcmd <pid> Thread.print works; for virtual threads use jcmd <pid> Thread.dump_to_file -format=json out.json (JDK 21), because the plain text dump doesn't list them.

A test that would have caught step 4: run N tasks where a random 10% throw, then assert availablePermits() == capacity once the executor is quiescent. It takes ten lines. I'd put it in CI for any class that owns a semaphore.

Upgrade note: everything above ran on 21.0.12.1. Retest on 25 and on your ARM fleet if you have one; I haven't, and I won't claim the barge percentages carry over. The semantics are Javadoc-defined and haven't moved as far as I know.

Keep these

  • A permit is a number. Anyone can raise it. Count your acquires and your releases on every path, including the one where acquire() throws.
  • Acquire outside the try, release in the finally.
  • tryAcquire() with no arguments ignores fairness and ignores the interrupt flag. Use the timed form with zero if you want the queue respected.
  • It caps concurrency, not rate. Permits divided by call latency is your throughput ceiling.
  • Zero permits plus a growing queue that doesn't move is a leak. Say so in your alert.

Short questions, short answers

Is a Semaphore the same as a lock? No. A lock has an owner and only that thread can release it; a Semaphore has a count and any thread can release. A one-permit semaphore is non-reentrant and can be released by the wrong thread without any error. For a real mutex use ReentrantLock or synchronized.

What happens if you call release() before acquire()? The permit count goes up by one. It's legal and the count can exceed the initial value, as the first block of this article shows. It's the intended way to signal from a semaphore built with zero permits, and a bug when you meant a cap.

Does a fair Semaphore guarantee first-come-first-served? Only among threads that block in acquire() or a timed tryAcquire. The no-argument tryAcquire() can still take a permit ahead of queued threads (977 of 2,000 trials on JDK 21 here).

Semaphore vs CountDownLatch vs CyclicBarrier? A semaphore is a reusable counter that goes up and down. A latch counts down once and releases everyone, and a barrier makes a fixed group meet each round. There's a short side-by-side in the site's earlier how-to.

java-concurrencyconcurrent-collectionsvirtual-threads

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments