cswork / libraryOpen practice workspace

Articles

Java Semaphore Interview Questions: Permit Leaks, Interruptions, and Concurrency Limits

Practice Java Semaphore interviews: prevent permit leaks and over-release, handle interruption and cancellation, and define safe concurrency limits.

A Java Semaphore interview question becomes interesting when acquire() never succeeds, a task throws, or a queued task is cancelled before it starts. The central invariant is simple: a successful acquisition creates one release obligation; a failed acquisition creates none.

Putting release() in a finally block is useful only when that block belongs to the correct obligation. Otherwise, an interruption can create an extra permit, or an asynchronous cancellation can leave a reserved permit permanently missing.

This guide distinguishes official JDK contracts from original permit ledgers, failure schedules, and implementation recommendations. Java examples are source-reviewed; no local JVM execution or concurrency benchmark is claimed.

What does a semaphore actually limit?

Official facts: a Semaphore maintains a permit count. Acquisition removes permits and release adds them. It does not enforce thread ownership: a releasing thread need not be the acquiring thread. JDK Semaphore reference.

Our original use case protects a downstream service that permits at most two simultaneous calls. A successful acquisition reserves one unit of that capacity until the protected operation exits. The application must ensure that every release corresponds to an outstanding reservation.

A semaphore initialized to two is not intrinsically capped at two. Calling release() without an associated acquisition can raise the available count above the intended limit. Do not treat the initial constructor value as protection against an over-release bug.

Define the protected region precisely. If the permit covers only the external call, parsing a response afterward may run outside the limit. If the permit covers a scarce connection, release only after returning or closing that connection. “Task finished” is too vague when the resource has a different lifetime.

A semaphore also does not make unrelated shared state safe. Two admitted operations can still race while modifying a common map. Use the concurrency limit for occupancy and a separate synchronization design for mutable invariants.

Repair the interruption over-release bug

Consider this original broken pattern:

try {
    gate.acquire();
    callDependency();
} finally {
    gate.release();
}

If acquire() throws before returning successfully, the finally still executes. The code returns a permit that it never acquired. A saturated service can then admit more work than its intended limit.

Official fact: interruptible acquire() can throw InterruptedException; the documented interruption path clears the thread's interrupted status. Semaphore acquisition contract.

For a synchronous operation, place acquisition before the protected try:

static <T> T limitedCall(Semaphore gate, Callable<T> operation)
        throws Exception {
    gate.acquire();
    try {
        return operation.call();
    } finally {
        gate.release();
    }
}

If acquisition fails, execution never enters that try. If the operation returns or throws after acquisition, cleanup returns the permit. This is an original source-reviewed pattern using standard Callable and Semaphore imports, not a complete service client.

If you catch interruption at a Runnable boundary, restore the interrupted flag and stop through the chosen cleanup path. If the enclosing method can propagate the checked exception, let it do so. Do not restore the flag and then continue blindly into the protected operation.

Handle failed tryAcquire without inventing capacity

A timed acquisition creates the same obligation only when it returns true:

static <T> T timedCall(Semaphore gate, Callable<T> operation)
        throws Exception {
    boolean acquired = gate.tryAcquire(100, TimeUnit.MILLISECONDS);
    if (!acquired) {
        throw new TimeoutException("capacity unavailable");
    }
    try {
        return operation.call();
    } finally {
        gate.release();
    }
}

The 100-millisecond timeout is an original exercise setting. A false result means no reservation was made, so the method must not release. An interruption during the timed wait likewise reaches neither the operation nor its cleanup block.

Do not write if (availablePermits() > 0) acquire() as a reservation protocol. Another caller can consume the observed permit before your acquisition. Use the atomic acquisition operation itself to decide admission.

For an original two-permit ledger, assume no other calls change the count during the trace:

PathCount movementCorrect release decision
Successful operation2 → 1 → 2Release once after exit
Operation throws after acquisition2 → 1 → 2Release once despite failure
Interrupted before successful acquisitionCount unchangedRelease zero times
Timed acquisition returns falseCount unchangedRelease zero times
Broken cleanup releases after failed acquisition2 → 3 in this isolated exampleExtra capacity is a bug

In a real saturated trace, start with zero available permits and interrupt a waiting caller. Correct cleanup leaves zero; the broken version raises the count to one. Distinguish an isolated arithmetic trace from a concurrent count snapshot.

Keep occupancy separate from request rate

Two permits limit simultaneous protected operations. They do not define requests per second. If each call completes rapidly, those permits can support many calls in one second; if calls stall, the same capacity can remain occupied for much longer.

Our recommendation is to draw a short timeline. Mark entry, exit, and active occupancy for each operation. A third call must wait while two are inside, regardless of how many earlier calls already completed. A token bucket instead replenishes admission tokens over time and controls a different requirement.

If the interviewer asks for both a concurrency ceiling and a sustained request-rate budget, state both policies and decide their admission order. Avoid holding a scarce connection while waiting for a rate token. Include the total waiting deadline so sequential admission checks do not accidentally double the caller's budget.

For weighted work, acquiring several permits represents a different capacity cost. Validate that a task's demand is within the application's configured capacity; otherwise, a request for more than the system can ever provide may wait indefinitely. Treat that validation as an application contract rather than assuming the semaphore knows your intended maximum.

Acquire inside the worker when limiting active calls

For an original executor design, the simplest ownership boundary is to acquire when the task starts its protected operation:

static <T> Future<T> submitLimited(
        ExecutorService pool, Semaphore gate, Callable<T> operation) {
    return pool.submit(() -> {
        gate.acquire();
        try {
            return operation.call();
        } finally {
            gate.release();
        }
    });
}

If submission is rejected, no worker acquired a permit. If a future is cancelled before the callable runs, there is no reservation to return. If the worker is interrupted while waiting for capacity, acquisition throws before the protected try.

This pattern limits protected active operations, not all queued tasks or worker threads. Some pool threads can be parked in acquisition. Use bounded executor admission separately when queued work must be capped.

Official fact: ThreadPoolExecutor documents finite queue saturation and several rejection policies; the default AbortPolicy throws on rejection. ThreadPoolExecutor reference.

A pool can also deadlock if all its workers wait for permits whose release depends on tasks queued in that same pool. Explain the dependency, then choose an execution or admission design that can make progress. Increasing the permit count without understanding who returns it only hides the defect.

Explain pre-submission reservations as ownership transfer

Suppose the requirement instead limits admitted queued-plus-running work. You may acquire before submission, but that reservation starts earlier and needs a terminal-accounting protocol beyond the worker's finally.

Our original ownership design distinguishes three states: reserved but not running, running, and released. Submission rejection returns an untransferred reservation. Cancellation before execution competes atomically with the transition into running. Whichever side wins determines whether cancellation cleanup or worker exit owns the one release.

Once running wins, cancellation may request an interrupt, but worker exit still owns the release. A cancellation callback and worker finally must not both return the same permit. Show the competing transitions before proposing atomics or a custom task wrapper.

Official fact: cancelling a running Future does not prove its computation has stopped. Future cancellation contract.

Consequently, a caller timing out must not immediately return a permit while an uncooperative dependency call still occupies the resource. That would allow replacement work to overlap with the original call and violate the physical limit.

Official fact: FutureTask's done() hook runs when the task transitions into done status, including cancellation. FutureTask reference. Using that hook for unconditional release is therefore insufficient when cancelled work can continue running. The custom protocol must distinguish logical completion from actual protected-region exit.

Answer fairness and mutex follow-ups precisely

Official fact: untimed tryAcquire() can take available permits even on a fair semaphore, bypassing the fairness setting. Fairness applies at documented acquisition ordering points, not necessarily the order threads were created. Semaphore fairness contract.

Our interview recommendation is to choose fairness according to starvation and throughput requirements, then state how it would be measured. A fair setting is not a proof that every operation finishes on time; holders can still stall inside the protected region.

A one-permit semaphore can express mutual exclusion by convention, but its lack of ownership matters. A lock interface may provide different ownership and condition-waiting behavior. Do not claim all locks are interchangeable with a binary semaphore. JDK Lock reference.

Likewise, semaphores are not automatically reentrant. A task holding the sole permit and trying to acquire it again can block itself. An original test should exercise that nested call boundary or explicitly forbid it in the API contract.

Decide what a retry holds

Our original retry exercise asks whether the permit covers a single dependency attempt or the whole logical request. Holding it across a backoff sleep reserves capacity while no call is active. Releasing between attempts permits other callers to run, but every retry must acquire again and obey the remaining deadline. State that policy before adding retries to either example.

A nested retry helper must not acquire the same one-permit semaphore while its caller already holds it. Trace the two acquisition sites: the inner attempt cannot enter, and the outer request cannot reach its release. One owner should define the protected lifetime.

For diagnosis, record admission wait, protected-operation duration, and actual exit separately. A fast caller timeout with a long protected duration points toward continuing work, not necessarily a missing release. These measurements help distinguish a capacity shortage from a cleanup defect.

Test the obligation on every terminal path

Design deterministic tests for success, an operation exception, failed timed admission, interrupted acquisition, queued cancellation, running cancellation, and rejected submission. Use gates to hold the operation inside its protected region, then measure active calls and verify they never exceed capacity.

Wait for actual operation exit before asserting that permits have returned. Assert the original count only when the test has reached a controlled quiescent state; a mid-flight snapshot is not a reliable end condition.

For every failure path, name the owner of the release obligation and show why that path neither leaks nor creates a permit.

Sources and Further Reading

Research checked October 4, 2026. Permit traces and design recommendations are original exercises; Java source review is not an executed race or stress test.