Limit concurrency with a Semaphore

Read the explanation
What if

Four threads want to call an API that allows two at a time. A Semaphore with 2 permits keeps the rest waiting.

01234567callApi(1)callApi(2)acquire()callApi(3)acquire()callApi(4)

Tap any block to see what that thread is doing.

  • Waiting on I/O
  • Waiting / parked
Step 1/4

Four threads, two permits. worker-1 and worker-2 take them; 3 and 4 park in acquire().

1Semaphore permits = new Semaphore(2); // at most 2 at a time2for (int i = 1; i <= 4; i++) {3    int id = i;4    new Thread(() -> {5        permits.acquireUninterruptibly(); // waits if both permits are taken6        try { callApi(id); }7        finally { permits.release(); }    // hand the permit back8    }, "worker-" + id).start();9}

Explanation

What just happened

The semaphore started with 2 permits. worker-1 and worker-2 took them. worker-3 and worker-4 parked in acquire(). Each time a worker finished and called release(), one waiting worker woke up and took that permit. There were never more than 2 workers inside callApi.

Try the what-if switch

1 permit: one worker at a time, so the semaphore acts like a lock and the four calls take 12 units instead of 6. Unlike a lock, a permit has no owner: any thread may release it.

When to use it

To cap how many threads do something at the same time:

A thread pool limits threads. A semaphore limits one kind of work, whichever threads happen to do it. That makes it a good fit with virtual threads, where you might have thousands of threads but only want 10 hitting the database.

Watch out

Common questions

How do I limit how many threads call something at once?

Use a Semaphore with that many permits. Each thread calls acquire() before the call and release() after it. When no permit is left, acquire() waits.

Is a Semaphore with 1 permit the same as a lock?

Almost: one thread at a time. But a permit has no owner, so any thread may release it, while a lock must be released by the thread that holds it.

Why must release() be in a finally block?

If the work throws before release(), that permit never comes back, and your limit quietly shrinks.

Verified: this timeline matches a real program, checked in CI on JDK 21 and 25. How we verify. Unclear or wrong? Tell us