QA How-To
Java Semaphore Control Parallel Test Load Tutorial (2026)
Learn java semaphore control parallel test load patterns with runnable JUnit 5 code, fair permits, safe cleanup, timeouts, metrics, and practical tuning.
17 min read | 2,632 words
TL;DR
Create one shared Java Semaphore for each constrained resource, acquire a permit before using that resource, and always release it in finally. Use timed acquisition and observable counters so parallel test failures remain bounded and diagnosable.
Key Takeaways
- Use a shared Semaphore when test execution may stay parallel but calls to one constrained dependency must be capped.
- Acquire a permit immediately before the scarce operation and release it in a finally block.
- Prefer tryAcquire with a timeout in test code so capacity problems fail with useful diagnostics instead of hanging.
- Choose permit count from measured dependency capacity, not from the test runner thread count.
- Use a fair semaphore when predictable waiting order matters more than maximum throughput.
- Record active and peak permit usage so the throttle can be verified and tuned in CI.
Java semaphore control parallel test load is the right pattern when your test runner should remain parallel but a database, API sandbox, browser grid, or test account pool can safely handle only a fixed number of simultaneous operations. A Semaphore separates runner concurrency from dependency concurrency by allowing only permit holders into the constrained section.
This tutorial builds a runnable JUnit 5 project that proves the cap under real parallel execution. For the broader decision model around executors, futures, locks, and test isolation, read the Java concurrency for test automation complete guide.
You will start with a deliberately overloaded fake service, introduce a reusable timed permit guard, run concurrent tests, and add metrics that prove the configured limit was respected. The same design can wrap an HTTP client call, Selenium session creation, database fixture setup, or any operation whose capacity is lower than your worker count. If parallel execution is new to your project, review the JUnit 5 tutorial for beginners before enabling it across the suite.
TL;DR
| Concern | Recommended choice | Reason |
|---|---|---|
| Shared capacity limit | One static or injected Semaphore per resource |
Every competing test observes the same permits |
| Test acquisition | tryAcquire(timeout, unit) |
A bad capacity setting fails instead of hanging forever |
| Cleanup | release() in finally |
Exceptions cannot leak permits |
| Ordering | Fair semaphore when starvation is unacceptable | Waiting threads generally receive permits FIFO |
| Verification | Active and peak counters | Tests prove the cap, not merely assume it |
Do not reduce all JUnit parallelism just because one dependency is fragile. Keep independent work concurrent and place the semaphore around the smallest operation that consumes scarce capacity.
What You Will Build
You will create a small Maven project with:
- JUnit Jupiter tests executing concurrently on a fixed parallel pool.
- A
LimitedServicethat simulates a dependency with a hard concurrency ceiling. - A reusable
PermitGuardthat acquires with a timeout and supports try-with-resources. - A load test that submits more work than the permitted capacity.
- Assertions and console metrics proving observed concurrency never exceeds three.
The final structure is intentionally small: pom.xml, one production helper, one simulated service, one test class, and one JUnit configuration file. Replace the simulated call with your real client operation after the behavior is verified.
Prerequisites
Use Java 25 or later and Maven 3.9 or later. Java 25 is the current long-term-support baseline and provides all APIs used here. Check your tools:
java -version
mvn -version
Create an empty Maven project and these directories:
mkdir -p semaphore-load-control/src/main/java/example/load
mkdir -p semaphore-load-control/src/test/java/example/load
mkdir -p semaphore-load-control/src/test/resources
cd semaphore-load-control
You need no running service because the example uses a deterministic local simulator. The tests use JUnit Jupiter 5.12.2 and Maven Surefire 3.5.2. If your organization centrally manages newer compatible releases in 2026, keep those managed versions.
Verification: java -version should report 25 or newer, and mvn -version should show Maven 3.9 or newer with the intended JDK.
Step 1: Configure Parallel JUnit 5 Execution
Create pom.xml at the project root. The compiler release keeps bytecode aligned with Java 21, while Surefire discovers and runs Jupiter tests.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>semaphore-load-control</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.12.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
</plugins>
</build>
</project>
Now create src/test/resources/junit-platform.properties:
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=8
junit.jupiter.execution.parallel.config.fixed.max-pool-size=8
Eight JUnit workers intentionally exceed the dependency limit you will set later. This difference is the point: runner parallelism controls how much test work can proceed, while semaphore permits control entry into one scarce operation.
Verification: run mvn -q test. Maven should complete successfully with a message indicating that no tests exist yet. A dependency-resolution failure means Maven cannot reach your configured repository, not that the JUnit settings are wrong.
Step 2: Reproduce an Overloaded Shared Dependency
Create src/main/java/example/load/LimitedService.java. It counts calls in progress, records the highest observed count, and throws if more callers enter than its capacity. Sleep represents an HTTP request or other remote operation.
package example.load;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
public final class LimitedService {
private final int capacity;
private final Duration latency;
private final AtomicInteger active = new AtomicInteger();
private final AtomicInteger peak = new AtomicInteger();
public LimitedService(int capacity, Duration latency) {
if (capacity < 1) {
throw new IllegalArgumentException("capacity must be positive");
}
this.capacity = capacity;
this.latency = latency;
}
public String call(int requestId) throws InterruptedException {
int nowActive = active.incrementAndGet();
peak.accumulateAndGet(nowActive, Math::max);
try {
if (nowActive > capacity) {
throw new IllegalStateException(
"service overloaded: active=" + nowActive);
}
Thread.sleep(latency);
return "result-" + requestId;
} finally {
active.decrementAndGet();
}
}
public int activeCalls() {
return active.get();
}
public int peakCalls() {
return peak.get();
}
}
The increment and decrement belong inside the simulator, not the semaphore wrapper, so measurements describe actual service entry. AtomicInteger makes those measurements safe when many threads update them. The simulator fails fast above capacity, making an accidental missing throttle visible.
Verification: run mvn -q -DskipTests package. The project should compile. If Java reports that Thread.sleep(Duration) is unavailable, your Maven process is using a JDK older than 19; point JAVA_HOME at JDK 25 or replace that one line with Thread.sleep(latency.toMillis()).
Step 3: Java Semaphore Control Parallel Test Load
Create src/main/java/example/load/PermitGuard.java. This guard centralizes timed acquisition and guarantees that only successfully acquired permits can be released.
package example.load;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class PermitGuard implements AutoCloseable {
private final Semaphore semaphore;
private boolean closed;
private PermitGuard(Semaphore semaphore) {
this.semaphore = semaphore;
}
public static PermitGuard acquire(
Semaphore semaphore, Duration timeout)
throws InterruptedException, TimeoutException {
Objects.requireNonNull(semaphore, "semaphore");
Objects.requireNonNull(timeout, "timeout");
boolean acquired = semaphore.tryAcquire(
timeout.toNanos(), TimeUnit.NANOSECONDS);
if (!acquired) {
throw new TimeoutException(
"permit unavailable after " + timeout
+ "; queued=" + semaphore.getQueueLength());
}
return new PermitGuard(semaphore);
}
@Override
public void close() {
if (!closed) {
closed = true;
semaphore.release();
}
}
}
AutoCloseable lets the caller use try-with-resources, which is the safest equivalent of releasing in finally. The closed check prevents an accidental double release from increasing capacity. getQueueLength() is an estimate, but it adds useful context to a timeout message.
A test semaphore often benefits from fairness: new Semaphore(3, true). Fair mode generally grants permits in arrival order at defined acquisition points, which reduces starvation and makes sustained load easier to reason about. It can reduce throughput, so use nonfair mode when maximum throughput matters and starvation has not appeared.
Verification: run mvn -q -DskipTests package again. Compilation should succeed with no unchecked warnings. Inspect the class and confirm there is exactly one release() path and it can run only after acquisition succeeded.
Step 4: Protect Only the Scarce Operation
Create src/test/java/example/load/SemaphoreLoadTest.java. The test starts twelve tasks together, but only three may enter service.call at once. Preparation occurs outside the guard so it remains parallel.
package example.load;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.StructuredTaskScope;
import org.junit.jupiter.api.Test;
class SemaphoreLoadTest {
private static final int PERMITS = 3;
private final Semaphore servicePermits = new Semaphore(PERMITS, true);
private final LimitedService service =
new LimitedService(PERMITS, Duration.ofMillis(100));
@Test
void capsCallsWithoutSerializingAllTestWork() throws Exception {
int taskCount = 12;
CountDownLatch ready = new CountDownLatch(taskCount);
CountDownLatch start = new CountDownLatch(1);
List<StructuredTaskScope.Subtask<String>> tasks = new ArrayList<>();
try (var scope = StructuredTaskScope.open()) {
for (int id = 0; id < taskCount; id++) {
int requestId = id;
tasks.add(scope.fork(() -> {
ready.countDown();
start.await();
String prepared = "request-" + requestId;
assertTrue(prepared.startsWith("request-"));
try (PermitGuard ignored = PermitGuard.acquire(
servicePermits, Duration.ofSeconds(2))) {
return service.call(requestId);
}
}));
}
assertTrue(ready.await(Duration.ofSeconds(2)));
start.countDown();
scope.join();
scope.throwIfFailed();
}
assertEquals(taskCount, tasks.size());
for (int id = 0; id < taskCount; id++) {
assertEquals("result-" + id, tasks.get(id).get());
}
assertEquals(PERMITS, service.peakCalls());
assertEquals(PERMITS, servicePermits.availablePermits());
assertEquals(0, service.activeCalls());
}
}
This example uses structured concurrency, which is a preview API in Java 25. Add preview flags to the compiler and test runtime. Inside the existing Maven plugin configuration, add <enablePreview>true</enablePreview> to maven-compiler-plugin and <argLine>--enable-preview</argLine> to Surefire. To avoid preview APIs in a conservative test suite, use the executor version in the next step instead.
Verification: after enabling preview, run mvn -q test. The test should pass, peakCalls() should equal three, all permits should be restored, and no active calls should remain. A peak below three can occur on a heavily constrained machine; if so, assert peakCalls() <= PERMITS and retain a separate assertion that it is greater than one.
Step 5: Use a Stable Executor Alternative
Most test repositories should avoid preview compilation unless structured concurrency is already an intentional project choice. Replace the previous test method with this stable Java implementation and remove the StructuredTaskScope import. Add imports for Callable, Executors, Future, and TimeUnit.
@Test
void capsCallsWithAStableExecutor() throws Exception {
int taskCount = 12;
CountDownLatch ready = new CountDownLatch(taskCount);
CountDownLatch start = new CountDownLatch(1);
List<Callable<String>> work = new ArrayList<>();
for (int id = 0; id < taskCount; id++) {
int requestId = id;
work.add(() -> {
ready.countDown();
start.await();
try (PermitGuard ignored = PermitGuard.acquire(
servicePermits, Duration.ofSeconds(2))) {
return service.call(requestId);
}
});
}
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (Callable<String> task : work) {
futures.add(executor.submit(task));
}
assertTrue(ready.await(2, TimeUnit.SECONDS));
start.countDown();
for (int id = 0; id < taskCount; id++) {
assertEquals("result-" + id, futures.get(id).get());
}
}
assertTrue(service.peakCalls() <= PERMITS);
assertTrue(service.peakCalls() > 1);
assertEquals(PERMITS, servicePermits.availablePermits());
assertEquals(0, service.activeCalls());
}
Virtual threads allow many waiting tasks without assigning a platform thread to every blocked test operation. They do not increase the downstream system's capacity, so the semaphore remains necessary. For a deeper runner design, see virtual threads for parallel API tests.
Verification: run mvn -q test without preview flags. The build should pass on Java 25. The assertions prove that calls overlapped, never exceeded the cap, and returned every permit.
Step 6: Prove Exceptions Do Not Leak Permits
A throttle is unsafe if a failed API call permanently consumes capacity. Add this test to SemaphoreLoadTest. It throws while holding a permit, then checks that try-with-resources returned it.
@Test
void releasesPermitWhenTheOperationFails() {
Semaphore onePermit = new Semaphore(1, true);
IllegalStateException failure = org.junit.jupiter.api.Assertions.assertThrows(
IllegalStateException.class, () -> {
try (PermitGuard ignored = PermitGuard.acquire(
onePermit, Duration.ofMillis(200))) {
throw new IllegalStateException("simulated API failure");
}
});
assertEquals("simulated API failure", failure.getMessage());
assertEquals(1, onePermit.availablePermits());
}
Do not release in both a catch block and a finally block. A Java semaphore does not track ownership, so an extra release silently creates an extra permit. Encapsulating release in an idempotent guard reduces that risk and keeps call sites short.
Verification: run mvn -q -Dtest=SemaphoreLoadTest test. Both tests should pass. Temporarily remove try-with-resources and observe that availablePermits() becomes zero; restore it immediately after confirming the assertion catches the leak.
Step 7: Add a Timeout Test and Actionable Diagnostics
Acquisition without a timeout can leave CI waiting indefinitely when permits leak or the dependency stalls. Add a deterministic test that holds the only permit while another acquisition times out.
@Test
void reportsTimeoutWhenCapacityIsUnavailable() throws Exception {
Semaphore onePermit = new Semaphore(1, true);
onePermit.acquire();
try {
java.util.concurrent.TimeoutException failure =
org.junit.jupiter.api.Assertions.assertThrows(
java.util.concurrent.TimeoutException.class,
() -> PermitGuard.acquire(
onePermit, Duration.ofMillis(50)));
assertTrue(failure.getMessage().contains("permit unavailable"));
assertEquals(0, onePermit.availablePermits());
} finally {
onePermit.release();
}
assertEquals(1, onePermit.availablePermits());
}
Set the acquisition timeout longer than normal queueing but shorter than the overall test timeout. If a service call normally takes two seconds and ten requests share two permits, a 500 millisecond permit timeout will reject healthy queued work. Measure queue duration in CI before choosing the value.
Preserve interruption by allowing InterruptedException to propagate from test helpers. Production-style wrappers may catch it only to restore the interrupt with Thread.currentThread().interrupt() and then fail or return according to a documented policy.
Verification: run the class repeatedly, for example mvn -q -Dtest=SemaphoreLoadTest -Dsurefire.rerunFailingTestsCount=0 test. The timeout test should consistently finish in roughly the configured bounded interval, and the final permit assertion should pass.
Step 8: Tune Java Semaphore Control Parallel Test Load from Evidence
Start with the actual capacity assigned to this test suite, not the vendor's theoretical maximum. If an environment supports ten concurrent calls but two other pipelines share it, giving your suite ten permits still overloads the environment. Reserve headroom for setup traffic, retries, health checks, and neighboring jobs.
Use these signals during a controlled tuning run:
| Signal | Too few permits | Too many permits |
|---|---|---|
| Permit wait time | High and stable | Usually low until failures begin |
| Dependency latency | Healthy but workers queue | Rises as load increases |
| HTTP 429 or capacity errors | Rare | Increasing |
| Suite duration | Longer than necessary | May become slower and flaky |
| Peak active operations | Always reaches cap | Reaches cap alongside errors |
Expose the configured cap in test logs and report peak active calls after the suite. For a real HTTP client, record permit wait duration separately from request duration. That distinction tells you whether the bottleneck is your local throttle or the dependency itself. Never resize a live semaphore by calling release() merely to increase capacity unless the lifecycle and accounting are rigorously controlled. Recreate an immutable resource gate between suites instead.
A semaphore controls concurrency, not request rate. Three permits can still produce hundreds of short calls per second. If the service contract is expressed as requests per second, pair the concurrency gate with an approved rate limiter or paced request scheduler.
Verification: run the load test with PERMITS set to one, three, and five while keeping simulator capacity at three. One should pass with a peak of one, three should pass with a peak up to three, and five should trigger the simulator overload. Restore the production choice of three. For build-phase separation in larger suites, use the Maven Surefire vs Failsafe guide to keep unit-style checks and integration tests in the correct lifecycle phases.
Troubleshooting
Tests still overload the service -> Confirm every caller uses the same semaphore instance. A semaphore created inside each test method gives each test its own permits and does not impose a global cap. Inject one gate per dependency or keep a carefully reset static fixture.
The suite hangs after one failure -> Search for acquire() calls whose matching release() is not in finally or try-with-resources. Replace indefinite acquisition with timed tryAcquire, assert permit restoration, and capture thread dumps when the suite timeout fires.
Available permits exceed the configured count -> A caller released without acquiring or released twice. Java permits have no ownership identity. Keep direct release() out of test bodies and use an idempotent guard created only after successful acquisition.
The timeout test flakes in CI -> Do not assert exact elapsed milliseconds. Assert the exception and a reasonable upper bound at most, then choose a timeout with scheduling margin. CPU-starved agents can resume a waiting thread later than requested.
Fair mode is slower than expected -> Fair ordering has coordination cost and prevents some barging. Benchmark under representative load. Switch to nonfair only if throughput matters more than ordering and your metrics show no starvation.
Parallel JUnit settings appear ignored -> Ensure junit-platform.properties is under src/test/resources, parallel execution is enabled, and tests are not forced to the same thread by lifecycle or resource-lock annotations. Check the Maven process uses the intended Surefire and Jupiter versions.
Common Mistakes and Best Practices
- Do create one semaphore for one clearly named constrained resource. Do not share an unrelated global gate across all external systems.
- Do acquire immediately before the limited operation. Do not hold permits during JSON construction, random data generation, assertions, screenshots, or unrelated cleanup.
- Do release in try-with-resources or
finally. Do not rely on the happy path. - Do use a bounded acquisition in tests. Do not let a leaked permit consume the entire CI timeout.
- Do keep semaphore capacity independent from JUnit worker count. They represent different constraints.
- Do measure wait time, operation time, failures, and peak concurrency. Do not tune from suite duration alone.
- Do decide whether one permit covers one request, one session, or one transaction. Do not change that unit across callers.
- Do reset metrics and resource fixtures between test suites. Do not hide state leakage by increasing permits.
If preparation itself uses the same constrained service, protect that call separately rather than extending the permit across the whole test. When setup has independent asynchronous work, CompletableFuture test data setup shows how to compose it without blocking unrelated tasks. If observed counts seem impossible, use the workflow for debugging Java test automation race conditions before changing the cap.
Where To Go Next
Apply the guard to one real bottleneck and run a controlled CI job. Log queue duration, request duration, peak usage, permit timeouts, and downstream capacity errors. Review those signals before raising the permit count.
Continue with these guides:
- Java concurrency for test automation complete guide for choosing among semaphores, locks, executors, futures, and concurrent collections.
- Java virtual threads for parallel API tests for scaling waiting tasks while retaining downstream limits.
- Java CompletableFuture test data setup for composing independent fixture operations.
- Debug Java test automation race conditions for tracing unsafe shared state and timing-dependent failures.
The next production improvement is to package PermitGuard with a named dependency gate and metrics hook. Keep policy, timeout, and capacity configuration in one place so individual tests cannot silently choose conflicting limits.
Interview Questions and Answers
Q: Why use a Semaphore instead of lowering JUnit parallelism?
Lowering runner parallelism slows every test, including work that does not use the constrained dependency. A semaphore limits only the protected critical section, so preparation and unrelated tests can remain concurrent.
Q: Why is tryAcquire usually better than acquire in test automation?
tryAcquire with a timeout bounds how long a test waits for capacity. It turns leaks, stalls, and undersized limits into diagnosable failures rather than suite-wide hangs.
Q: Should a test semaphore be fair?
Fair mode is useful when predictable ordering and reduced starvation matter. Nonfair mode can provide better throughput, so the choice should follow measured behavior and suite priorities.
Q: What happens if release is called twice?
The semaphore count increases twice because Java does not enforce permit ownership. The gate can then admit more callers than intended, which is why release should be encapsulated in an idempotent guard.
Q: Can Semaphore enforce requests per second?
No. It limits simultaneous permit holders, not how quickly permits are reused. Rate limits need a separate rate limiter or pacing mechanism.
Q: How do you select the permit count?
Start from the capacity allocated to the suite, account for other consumers and retry traffic, then tune using wait time, service latency, throttling errors, and peak concurrency. The executor thread count is not the dependency capacity.
Conclusion
Java semaphore control parallel test load works when one shared operation needs a smaller concurrency ceiling than the rest of the test suite. Use one shared gate per constrained resource, acquire with a realistic timeout, keep the protected region narrow, and release through try-with-resources.
Run the completed test under CI-like load and use its peak, wait, and failure signals to choose permits. Once the cap is proven locally, replace the simulator call with your API, grid, database, or account-pool operation and keep the same restoration assertions.
Interview Questions and Answers
Why would you use a Semaphore in a parallel test suite?
I use a semaphore when the runner can execute many tests but one shared dependency has lower safe concurrency. Tests acquire a permit only around that dependency call, which preserves parallel work elsewhere. I verify the design with peak-concurrency metrics and permit-restoration assertions.
What is the difference between Semaphore permits and executor pool size?
Executor size limits how many tasks can run or be scheduled by that executor. Semaphore permits limit how many callers can enter a particular protected operation, even when callers come from multiple executors or test classes. They model separate constraints and should be configured independently.
How do you safely release a Semaphore permit after a test failure?
I release in a finally block or wrap successful acquisition in an AutoCloseable guard used with try-with-resources. I never release from multiple branches. I also test the exception path and assert that the full permit count is restored.
When would you choose a fair Semaphore?
I choose fair mode when sustained contention makes starvation or unpredictable ordering a practical concern. Fairness usually provides FIFO-style acquisition at queue points but has a throughput cost. I compare both modes under representative CI load before deciding.
Why use timed tryAcquire in automated tests?
A timed acquisition prevents a leaked permit or stalled dependency from hanging the suite indefinitely. The timeout failure can include resource name, configured capacity, and estimated queue length. Its duration should exceed normal measured queueing but remain below the overall test timeout.
Does Semaphore solve API rate limiting?
It solves concurrent in-flight request limiting, not requests per time window. Fast calls can reuse permits and still exceed an API's requests-per-second contract. I combine the semaphore with an appropriate rate limiter when both constraints exist.
Frequently Asked Questions
How does a Java Semaphore control parallel test load?
A semaphore exposes a fixed number of permits. Each test must acquire one before entering the constrained operation and release it afterward, so no more than the configured number can execute that operation simultaneously.
Should the Semaphore be static in JUnit 5 tests?
It must be shared by every test competing for the same resource. A static field can provide that scope, but dependency injection or a JUnit extension often gives clearer lifecycle control and easier reset behavior.
What is a good permit count for parallel API tests?
Use the concurrency capacity actually allocated to your suite, with headroom for other pipelines, retries, and setup traffic. Validate the choice using queue time, API latency, throttling responses, and peak active requests.
Is a fair Semaphore better for test automation?
Fair mode generally serves waiting threads in arrival order and can reduce starvation, which helps predictable test behavior. It may reduce throughput, so use it when ordering matters and confirm the tradeoff with representative measurements.
How do I prevent leaked Semaphore permits?
Acquire through a guard that implements AutoCloseable and use try-with-resources. Add failure-path tests that throw inside the protected block and assert that available permits return to the configured count.
Can virtual threads replace a Semaphore?
No. Virtual threads make blocking tasks cheaper, but they do not increase the capacity of an API, database, grid, or account pool. Use virtual threads for scalable task execution and a semaphore for downstream concurrency control.
Related Guides
- Java Concurrency for Test Automation Complete Guide (2026)
- Java for Testers: Builder pattern for test data (2026)
- Java for Testers: TestNG groups and parallel (2026)
- k6 Test Server Sent Events Streams: Load Testing Tutorial
- Locust load testing tutorial (2026)
- Parallel test sharding in CI: Step by Step (2026)