7 Practical java.util.concurrent Patterns: From Thread Pools to Concurrency Control

7 Practical java.util.concurrent Patterns: From Thread Pools to Concurrency Control


Introduction

When writing multithreaded code, relying solely on synchronized quickly hits a wall. Performance bottlenecks, deadlocks, callback hell — Java provides the java.util.concurrent package to solve these problems.

The issue is there are too many classes in this package. It’s hard to know what to use when. This post covers 7 classes that are actually used frequently in production, explaining when and why each one is useful with working code.


1. ExecutorService — Thread Pool Management

Why Do You Need It?

Creating a new Thread() per request wastes resources on thread creation/destruction overhead, and thousands spawned simultaneously can cause OOM. A thread pool pre-creates threads and reuses them.

Pattern: Parallel External API Calls

A product detail page that fetches product info, reviews, and recommendations simultaneously.

ExecutorService executor = Executors.newFixedThreadPool(3);

Future<Product> productFuture = executor.submit(() -> productApi.getProduct(id));
Future<List<Review>> reviewFuture = executor.submit(() -> reviewApi.getReviews(id));
Future<List<Product>> recommendFuture = executor.submit(() -> recommendApi.get(id));

Product product = productFuture.get(3, TimeUnit.SECONDS);
List<Review> reviews = reviewFuture.get(3, TimeUnit.SECONDS);
List<Product> recommends = recommendFuture.get(3, TimeUnit.SECONDS);

Common Mistakes

MistakeConsequenceFix
Pool size too largeContext switching overhead, actually slowerCPU-bound: core count, I/O-bound: core count × 2–4
Forgetting executor.shutdown()Threads stay alive, app won’t terminateAlways shutdown in try-finally
Overusing Executors.newCachedThreadPool()Traffic spike → unlimited thread creation → OOMUse newFixedThreadPool or configure ThreadPoolExecutor directly

Rejection Policy (RejectedExecutionHandler)

When all threads in the pool are busy and the work queue is also full, what happens to a new incoming task? This is decided by the rejection policy.

PolicyBehaviorBest For
AbortPolicy (default)Throws RejectedExecutionExceptionWhen task loss is unacceptable
CallerRunsPolicyThe submitting thread runs the task itselfWhen tasks must not be dropped and you want natural slowdown
DiscardPolicySilently drops the task (no exception)When some loss is acceptable (e.g., log collection)
DiscardOldestPolicyDrops the oldest task in the queue and adds the new oneWhen the latest data matters more
// Rejection policy configuration example
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    3, 10, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(50),
    new ThreadPoolExecutor.CallerRunsPolicy() // caller runs when pool is full
);

CallerRunsPolicy is the most commonly used policy in production. When the pool is overloaded, the calling thread (usually the request thread) runs the task directly, creating natural backpressure — incoming requests slow down, preventing the system from spiraling out of control.

How to Size the Pool

There’s no magic formula, but the widely accepted guideline depends on the type of work.

TypeCharacteristicscorePoolSize Guideline
CPU-boundComputation, encryption, compression — CPU stays busycore count or core count + 1
I/O-boundDB queries, API calls, file reads — mostly waitingcore count × 2 ~ core count × 4

Why the difference?

  • CPU-bound tasks keep the CPU occupied → more threads than cores just adds context-switching overhead
  • I/O-bound tasks release the CPU while waiting → more threads can take turns using the CPU

Most Spring Boot apps are I/O-bound (DB queries, external API calls), so use this as a starting point:

int cpuCores = Runtime.getRuntime().availableProcessors(); // e.g., 4

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(cpuCores * 2);    // 8  — threads maintained at steady state
executor.setMaxPoolSize(cpuCores * 4);     // 16 — max threads during traffic spikes
executor.setQueueCapacity(100);            // work queue size

These values are a starting point, not the answer. In production, tune them through load testing (nGrinder, k6, etc.).

How corePoolSize, maxPoolSize, and queueCapacity Interact

Understanding the order in which these three values kick in is critical.

New task arrives

Core thread available? → YES → core thread handles it
  ↓ NO
Room in the queue?     → YES → enqueue and wait
  ↓ NO
Below max pool size?   → YES → create new thread to handle it
  ↓ NO
Rejection policy fires (CallerRunsPolicy, etc.)

Watch out: When core threads are busy, the pool doesn’t immediately scale up to max — the queue fills first. Max threads are only created after the queue is completely full. Misunderstanding this order leads to “I increased maxPoolSize but no new threads are being created” confusion.

In Spring Boot?

In Spring Boot, you don’t create ExecutorService directly. Instead, register a ThreadPoolTaskExecutor as a bean and delegate async execution with @Async.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "apiExecutor")
    public TaskExecutor apiExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("api-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

@Service
public class ProductService {

    @Async("apiExecutor")
    public CompletableFuture<Product> getProduct(Long id) {
        return CompletableFuture.completedFuture(productApi.getProduct(id));
    }
}

Why is the Spring approach better?

  • Spring manages thread pool lifecycle (shutdown) — no manual try-finally needed
  • Pool size configurable externally via application.yml
  • Rejection policy (RejectedExecutionHandler) set declaratively — uses the same pure Java classes like CallerRunsPolicy described above

When you still need the raw API: In test code requiring fine-grained thread control, or batch utilities running outside the Spring context.


2. CompletableFuture — Async Composition

Why Do You Need It?

Future.get() is blocking. The calling thread sits idle while waiting. CompletableFuture enables non-blocking async processing through callback chaining.

Pattern: Async Pipeline

Order creation → payment → notification sent sequentially, but without blocking the calling thread.

CompletableFuture
    .supplyAsync(() -> orderService.create(request))
    .thenApplyAsync(order -> paymentService.pay(order))
    .thenAcceptAsync(payment -> notificationService.send(payment))
    .exceptionally(ex -> {
        log.error("Order processing failed", ex);
        return null;
    });

Chaining Method Roles

The methods above are distinguished by whether they take input and produce output.

MethodInputOutputRoleIn the example above
supplyAsyncNoneYes (T)Starting point of the chain. Produces a valueCreate order → returns Order
thenApplyAsyncYes (T)Yes (U)Receives previous result and transforms itOrder → payment → returns Payment
thenAcceptAsyncYes (T)None (void)Receives previous result and consumes it (no return)Payment → send notification
thenRunAsyncNoneNone (void)Runs regardless of previous result(e.g., logging, incrementing counters)

What the Async suffix means: thenApply may run on the same thread as the previous stage, while thenApplyAsync is guaranteed to run on a separate thread (ForkJoinPool or a specified Executor). For tasks involving I/O, the Async variant is the safer choice.

Running Multiple Tasks and Combining Results — thenCombine

thenApply above transforms a single result. But in practice, you often need to run two independent tasks simultaneously and merge the results when both finish. That’s what thenCombine is for.

thenApply:    A result ──→ transform ──→ B
thenCombine:  A result ─┐
                         ├─→ merge ──→ C
              B result ─┘

Example: fetching product info and reviews simultaneously on a product detail page, then combining them into a single DTO:

CompletableFuture<Product> productCf = CompletableFuture
    .supplyAsync(() -> productApi.getProduct(id));
CompletableFuture<List<Review>> reviewCf = CompletableFuture
    .supplyAsync(() -> reviewApi.getReviews(id));

// Combine when both complete
CompletableFuture<ProductDetail> detailCf = productCf
    .thenCombine(reviewCf, (product, reviews) -> new ProductDetail(product, reviews));

Without thenCombine? You’d have to block with get().

// ❌ Blocking approach — calling thread is stuck
Product product = productCf.get();
List<Review> reviews = reviewCf.get();
ProductDetail detail = new ProductDetail(product, reviews);

thenCombine automatically merges results the moment both complete, without blocking the calling thread.

Future vs CompletableFuture

AspectFutureCompletableFuture
Getting resultsget() blocksthenApply() non-blocking
ChainingNot possiblethenApply → thenCompose → thenCombine
Error handlingWrap in try-catchexceptionally(), handle()
Combining tasksManual implementationallOf(), anyOf(), thenCombine()

In Spring Boot?

When the @Async methods from Section 1 return CompletableFuture, all chaining works identically to pure Java.

@Service
public class ProductFacade {

    private final ProductService productService;
    private final ReviewService reviewService;

    public CompletableFuture<ProductDetail> getDetail(Long id) {
        CompletableFuture<Product> productCf = productService.getProduct(id);   // @Async
        CompletableFuture<List<Review>> reviewCf = reviewService.getReviews(id); // @Async

        return productCf.thenCombine(reviewCf, ProductDetail::new);
    }
}

Key insight: @Async only determines which thread pool executes the method — Spring handles that part. The returned CompletableFuture’s chaining API (thenApply, thenCombine, exceptionally) is pure Java. The composition patterns from Section 2 apply identically in Spring Boot.


3. CountDownLatch — Simultaneous Start / Completion Wait

Why Do You Need It?

When you need “N threads to start simultaneously” or “wait until N tasks all complete.”

Pattern: Concurrency Testing

Testing 100 simultaneous purchase requests in a FCFS system.

int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch ready = new CountDownLatch(threadCount);  // wait until all ready
CountDownLatch start = new CountDownLatch(1);             // simultaneous start signal
CountDownLatch done = new CountDownLatch(threadCount);    // wait until all finish

for (int i = 0; i < threadCount; i++) {
    executor.submit(() -> {
        ready.countDown();   // "I'm ready"
        start.await();       // wait for start signal
        try {
            purchaseService.buy(productId, userId);
        } finally {
            done.countDown(); // "I'm done"
        }
    });
}

ready.await();     // wait for all 100 threads to be ready
start.countDown(); // GO!
done.await();      // wait for all 100 threads to finish

assertThat(product.getStock()).isEqualTo(0);

How countDown() and await() Work

CountDownLatch holds a single count number internally. The two methods operate around this number.

  • countDown() — Decrements the count by 1. It never goes below 0.
  • await()Blocks the current thread until the count reaches 0. If already 0, it passes through immediately.

Here’s the flow of the code above in chronological order:

[Phase 1: Ready]
Worker thread 1   → ready.countDown()  → blocks at start.await()
Worker thread 2   → ready.countDown()  → blocks at start.await()
  ...
Worker thread 100 → ready.countDown()  → blocks at start.await()

           ready count reaches 0

[Phase 2: Simultaneous Start]
Main thread       → ready.await() passes → start.countDown()

                                   start count reaches 0

                                   All 100 threads wake up simultaneously

[Phase 3: Wait for Completion]
Worker threads    → purchaseService.buy() runs → done.countDown()

                                         done count reaches 0

Main thread       → done.await() passes → assertThat runs

Why 3 latches? Each has a distinct role.

LatchInitial ValueWho calls countDownWho calls awaitPurpose
ready100Worker threadsMain threadConfirm all threads are created and waiting
start1Main threadWorker threads”GO!” signal — wake all at once
done100Worker threadsMain threadConfirm all tasks have finished

Additional Points

100 Threads but Only 4 CPU Cores — Is the Concurrency Test Valid?

In Section 1, we said “I/O-bound: core count × 2–4.” But here we’re creating 100 threads. Only the number of CPU cores (e.g., 4) can physically run simultaneously. Is this test even valid?

Yes, it is. The pool size from Section 1 and the thread count here serve entirely different purposes.

Section 1 (Production Thread Pool)Section 3 (Concurrency Test)
GoalOptimize throughputReproduce “requests flooding in simultaneously”
ConcernUsing CPU efficiently100 requests entering at the same instant
What threads doAPI calls, computationMostly waiting on DB locks (I/O-bound)

purchaseService.buy() sends a query to the DB and spends most of its time waiting for a response. During that wait, the thread releases the CPU, and the OS hands it to another thread.

Threads 1–4:  Run on CPU → call buy() → wait on DB lock (release CPU)
Threads 5–8:  Get CPU    → call buy() → wait on DB lock (release CPU)
  ...
Result: ~100 threads hit the same DB row nearly simultaneously
        → This is exactly the scenario we want to test

If this were a pure CPU-bound operation, the story would be different — only 4 would run at a time while the other 96 wait, so the “simultaneous flood” wouldn’t be reproduced. But concurrency tests almost always target DB, cache, or external API operations (I/O), making the 100-thread approach valid.

  • It’s a one-shot tool. Once the count reaches 0, it can’t be reused. Use CyclicBarrier if you need reusability.

In Spring Boot?

There is no Spring wrapper for CountDownLatch. In @SpringBootTest, using it directly is the standard approach.

@SpringBootTest
class PurchaseConcurrencyTest {

    @Autowired
    private PurchaseService purchaseService;

    @Test
    void concurrent_100_purchases() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch ready = new CountDownLatch(threadCount);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            final long userId = i;
            executor.submit(() -> {
                ready.countDown();
                start.await();
                try {
                    purchaseService.buy(productId, userId);
                } finally {
                    done.countDown();
                }
                return null;
            });
        }

        ready.await();
        start.countDown();
        done.await();

        assertThat(product.getStock()).isEqualTo(0);
        executor.shutdown();
    }
}

The point: The CountDownLatch + ExecutorService combo is used as-is in Spring Boot tests. This pattern is effectively the only way to simulate “N concurrent requests hitting at the same time.”


4. ConcurrentHashMap — Thread-Safe Cache

Why Do You Need It?

HashMap under concurrent put/get can cause infinite loops, data loss, and other unpredictable bugs. Collections.synchronizedMap() is safe but locks on every operation, making it slow.

ConcurrentHashMap partitions internally — reads are lock-free, writes lock only the affected segment.

Pattern: Local Cache

Caching external API results in memory, where multiple threads might request the same key simultaneously — and you want only one API call.

private final ConcurrentHashMap<String, Product> cache = new ConcurrentHashMap<>();

public Product getProduct(String id) {
    return cache.computeIfAbsent(id, key -> {
        // This block executes only once per key
        return productApi.fetch(key);
    });
}

Common Mistake

// ❌ check-then-act → two threads both see null and both put
if (!map.containsKey(key)) {
    map.put(key, value);
}

// ✅ Use atomic operations
map.putIfAbsent(key, value);
map.computeIfAbsent(key, k -> createValue(k));

Another thread can slip in between containsKey() and put(). Use ConcurrentHashMap’s atomic methods (putIfAbsent, computeIfAbsent, merge) for true thread safety.

In Spring Boot?

For local caching, Spring Cache + Caffeine is the standard approach.

// build.gradle
// implementation 'org.springframework.boot:spring-boot-starter-cache'
// implementation 'com.github.ben-manes.caffeine:caffeine'

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager("products");
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1_000)
            .expireAfterWrite(Duration.ofMinutes(10)));
        return manager;
    }
}

@Service
public class ProductService {

    @Cacheable(value = "products", key = "#id")
    public Product getProduct(String id) {
        return productApi.fetch(id); // called only on cache miss
    }
}

Why is Spring Cache better?

  • TTL, max size, and eviction policies configured declaratively
  • @CacheEvict separates invalidation logic
  • Caffeine is built on ConcurrentHashMap internally, so concurrency is guaranteed

When you still need ConcurrentHashMap: In-request memoization, private methods where cache annotations don’t work, or cache keys with complex dynamic structures.

If you need to share cache across multiple instances, or want more sophisticated TTL/eviction strategies, consider using Redis as a distributed cache. For practical strategies like the Cache-Aside pattern and cache stampede prevention, see Spring Boot Practical Guide Part 2: Caching Strategies and Redis.


5. BlockingQueue — Producer-Consumer Pattern

Why Do You Need It?

“One side puts data in, another side takes it out.” When the queue is empty, consumers automatically wait. When full, producers automatically wait. No manual wait()/notify() needed.

Pattern: Async Log Collector

Writing logs synchronously during request handling slows response time. Push logs to a queue and process them in a separate thread.

private final BlockingQueue<LogEvent> logQueue = new LinkedBlockingQueue<>(10_000);

// Producer: request-handling thread
public void log(LogEvent event) {
    if (!logQueue.offer(event)) {
        // Queue full → drop (log loss vs service outage tradeoff)
        System.err.println("Log queue overflow, dropping event");
    }
}

// Consumer: dedicated thread
public void startConsumer() {
    new Thread(() -> {
        while (!Thread.currentThread().isInterrupted()) {
            try {
                LogEvent event = logQueue.take(); // blocks until queue has data
                logWriter.write(event);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }).start();
}

Choosing an Implementation

ImplementationCharacteristicsBest For
LinkedBlockingQueueNode-based, optional size limitGeneral producer-consumer
ArrayBlockingQueueArray-based, fixed sizePredictable memory management
PriorityBlockingQueuePriority-sortedProcessing urgent tasks first
SynchronousQueueNo buffer, direct handoffUsed internally by Executors.newCachedThreadPool()

In Spring Boot?

The producer-consumer pattern can be replaced with Spring’s event system.

// Event definition
public record OrderCreatedEvent(Long orderId, String userId) {}

// Producer: publish event
@Service
public class OrderService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Order create(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId(), request.getUserId()));
        return order;
    }
}

// Consumer: async event listener
@Component
public class OrderEventListener {

    @Async("apiExecutor")
    @EventListener
    public void handleOrderCreated(OrderCreatedEvent event) {
        notificationService.send(event.orderId());
        analyticsService.track(event);
    }
}

Why is the Spring event system better?

  • Producers and consumers don’t know each other → low coupling
  • Adding @Async runs the handler on a separate thread → async processing
  • @TransactionalEventListener can execute only after transaction commit

When you still need BlockingQueue: Batch processing (accumulate and flush), backpressure control, or library code that must work without a Spring context.

What is backpressure? A mechanism that naturally slows down producers when incoming requests exceed processing capacity. With BlockingQueue, when the queue is full, put() blocks — the producer pauses, giving consumers time to catch up. Without backpressure, requests pile up indefinitely, leading to out-of-memory (OOM) failures.


6. Semaphore — Limiting Concurrent Access

Why Do You Need It?

When you need “at most N threads accessing this resource simultaneously.” synchronized allows only 1, but Semaphore allows N.

Pattern: External API Concurrency Limit

An external payment API that only allows 10 concurrent requests.

private final Semaphore apiLimit = new Semaphore(10);

public PaymentResult pay(PaymentRequest request) throws InterruptedException {
    apiLimit.acquire(); // blocks if 10 already in use
    try {
        return paymentApi.call(request);
    } finally {
        apiLimit.release(); // return the slot
    }
}

With Timeout

if (apiLimit.tryAcquire(3, TimeUnit.SECONDS)) {
    try {
        return paymentApi.call(request);
    } finally {
        apiLimit.release();
    }
} else {
    throw new RuntimeException("Payment API call timed out waiting for a slot");
}

Semaphore vs Rate Limiter

AspectSemaphoreRate Limiter (Guava/Resilience4j)
ControlsConcurrent execution count (how many running right now)Throughput per time unit (how many per second)
Example”Only 10 calls at once""Only 100 calls per second”
Slot returnOn task completion via release()Auto-refills over time

In Spring Boot?

Resilience4j’s @Bulkhead provides declarative concurrency limiting.

// build.gradle
// implementation 'io.github.resilience4j:resilience4j-spring-boot3'

// application.yml
// resilience4j:
//   bulkhead:
//     instances:
//       paymentApi:
//         maxConcurrentCalls: 10
//         maxWaitDuration: 3s

@Service
public class PaymentService {

    @Bulkhead(name = "paymentApi", fallbackMethod = "payFallback")
    public PaymentResult pay(PaymentRequest request) {
        return paymentApi.call(request);
    }

    private PaymentResult payFallback(PaymentRequest request, BulkheadFullException ex) {
        throw new ServiceUnavailableException("Payment service is temporarily overloaded");
    }
}

Why is Resilience4j better?

  • Configuration externalized to application.yml → change without redeployment
  • Fallback methods for graceful degradation
  • Actuator integration for automatic metrics (concurrent calls, waiting count)
  • Composable with Circuit Breaker, Retry, and other patterns

How to Decide the Concurrency Limit

Whether it’s Semaphore’s permits or Bulkhead’s maxConcurrentCalls, the value is typically decided by one of three criteria.

Criterion 1: External System Limits

The most common case — only send as much as the other side allows.

Payment API SLA: "Max 50 concurrent requests"
→ maxConcurrentCalls: 40–45 (keep 10–20% headroom)

Criterion 2: Protecting Your Own Resources

If one API hogs all DB connections, other features die.

DB connection pool: 20
Other features also need connections
→ maxConcurrentCalls: 10 (limit to half or less of the total)

Criterion 3: Load Test Results

When there’s no clear baseline, find it through testing.

10 → Response normal, TPS sufficient
20 → Response normal, TPS improved
30 → Response time starts increasing
→ maxConcurrentCalls: 20 (value just before degradation)

Don’t try to nail the exact number upfront — start conservatively and adjust based on monitoring. With Resilience4j + Actuator, you can see concurrent calls and waiting counts in real time, enabling data-driven tuning.

When you still need Semaphore: Simple utilities where Resilience4j is overkill, or library code that must run without framework dependencies.


7. ReentrantLock — synchronized on Steroids

Why Do You Need It?

synchronized is simple but lacks timeouts, fairness guarantees, and conditional waiting. ReentrantLock fills these gaps.

Pattern: Lock with Timeout

Preventing deadlocks by setting a time limit on lock acquisition.

private final ReentrantLock lock = new ReentrantLock();

public void transferMoney(Account from, Account to, long amount) {
    try {
        if (lock.tryLock(3, TimeUnit.SECONDS)) {
            try {
                from.withdraw(amount);
                to.deposit(amount);
            } finally {
                lock.unlock();
            }
        } else {
            throw new RuntimeException("Lock acquisition timed out — retry later");
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

synchronized vs ReentrantLock

AspectsynchronizedReentrantLock
UsageKeyword (auto-release)lock() / unlock() (manual release)
TimeoutNot possibletryLock(timeout)
FairnessNot guaranteednew ReentrantLock(true) → longest-waiting thread goes first
Conditional waitwait() / notify()Condition objects for multiple conditions
Risk of mistakesLow (auto-release)Forgetting unlock() → permanent lock. Always use finally

If you just need a simple critical section, synchronized is enough. Only reach for ReentrantLock when you need tryLock, fairness, or multiple conditions.

In Spring Boot?

A single-instance ReentrantLock is almost never sufficient in production. The moment you have multiple Pods, the lock becomes meaningless. You need a distributed lock.

// build.gradle
// implementation 'org.redisson:redisson-spring-boot-starter'

@Service
public class StockService {

    private final RedissonClient redissonClient;

    public void decrease(Long productId, int quantity) {
        RLock lock = redissonClient.getLock("stock:" + productId);

        try {
            if (lock.tryLock(5, 3, TimeUnit.SECONDS)) { // wait 5s, auto-release 3s
                try {
                    Stock stock = stockRepository.findByProductId(productId);
                    stock.decrease(quantity);
                    stockRepository.save(stock);
                } finally {
                    lock.unlock();
                }
            } else {
                throw new RuntimeException("Failed to acquire stock lock");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Why distributed locks?

  • Spring Boot apps typically run on 2+ Pods
  • JVM-level ReentrantLock only controls threads within the same process
  • Redis-based Redisson locks are shared across all Pods

When you still need ReentrantLock: Batch servers guaranteed to be single-instance, or protecting JVM-internal resources (file writes, connection pool initialization).


Summary: When to Use What?

SituationClass
Run tasks in a thread poolExecutorService
Chain and combine async tasksCompletableFuture
Simultaneous start / wait for N completionsCountDownLatch
Thread-safe Map (local cache)ConcurrentHashMap
Producer-consumer queueBlockingQueue
Limit concurrent access to NSemaphore
Lock with timeout / fairnessReentrantLock

The core principle: don’t create Thread objects directly, and don’t write wait()/notify() yourself. java.util.concurrent provides battle-tested tools. Don’t reinvent the wheel.


Spring Boot Wraps j.u.c — It Doesn’t Replace It

The 7 classes covered in this post don’t disappear in Spring Boot. Spring wraps them to make them easier to use.

Pure JavaSpring Boot Wrapper
ExecutorService@Async + ThreadPoolTaskExecutor
CompletableFutureUsed directly as @Async return type
CountDownLatchNo wrapper — used as-is in tests
ConcurrentHashMap@Cacheable + Caffeine
BlockingQueueApplicationEventPublisher + @EventListener
SemaphoreResilience4j @Bulkhead
ReentrantLockRedisson distributed locks

You need to understand the primitives to use the wrappers properly. Debugging why @Async isn’t working requires understanding ExecutorService. Knowing how Caffeine guarantees concurrency requires understanding ConcurrentHashMap. Work on top of abstractions, but understand the layer beneath.

Shop on Amazon

As an Amazon Associate, I earn from qualifying purchases.