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:
- an API that allows 2 concurrent requests
- a database that should get at most 10 connections from this service
- limiting heavy downloads so the network isn’t saturated
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
- Release in
finally, always. A permit lost to an exception never comes back, and your limit quietly shrinks. - By default it’s not fair: a newly arriving thread can grab a permit before one that has waited longer.
new Semaphore(2, true)makes it first come, first served, at some speed cost. tryAcquire(timeout)lets you give up instead of waiting forever.
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