Your Java code works perfectly with one thread. The tests pass, the output is correct, and performance looks good.
But what happens when 1,000 threads execute the same code simultaneously?
Concurrency introduces problems that are difficult to reproduce in development: race conditions, deadlocks, thread starvation, shared-state corruption, slow asynchronous pipelines, and resource exhaustion.
For senior Java developers, knowing how to create a thread is not enough. You must understand how threads share data, coordinate execution, compete for resources, and behave under production load.
This guide explores 15 real-world Java concurrency problems with detailed answers, practical code examples, and production troubleshooting advice.
Scenario: Two threads increment a shared counter 1,000 times each. You expect 2,000, but the final value is smaller.
The expression counter++ is not an atomic operation. Conceptually, it involves reading the current value, calculating the next value, and writing the result.
Thread A reads counter = 10
Thread B reads counter = 10
Thread A writes counter = 11
Thread B writes counter = 11
Expected: 12
Actual: 11
This is called a race condition. Both threads operate on the same shared state, and their operations overlap in an unsafe way.
For a simple counter that requires atomic increments, use AtomicInteger:
private final AtomicInteger counter =
new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
For more complex operations involving multiple related fields, use a suitable lock or synchronization strategy:
private int counter;
public synchronized void increment() {
counter++;
}
For high-contention statistical counters, LongAdder may offer better throughput than a single atomic counter, although reading its value is not an atomic snapshot of all concurrent updates.
Production lesson: Use atomic operations for simple shared updates and locks when a group of operations must maintain a shared invariant.
Scenario: Several threads read and write to the same HashMap. The application experiences inconsistent data or unexpected behavior.
HashMap is not designed for unsynchronized concurrent modification. Concurrent reads and writes can lead to lost updates, inconsistent observations, and other incorrect behavior.
Map<String, Integer> counts =
new HashMap<>();
counts.put("orders", 10);
// Multiple threads update counts concurrently.
Even replacing the map with a thread-safe implementation does not automatically make every compound operation safe.
For a shared map with concurrent access, choose a data structure based on the operation.
Option 1: ConcurrentHashMap
private final Map<String, Integer> counts =
new ConcurrentHashMap<>();
public void increment(String key) {
counts.merge(key, 1, Integer::sum);
}
merge() provides an atomic update for the specified key. The example avoids a separate read followed by a write.
Option 2: Synchronization
If a business operation must coordinate multiple map entries or other shared state, use a suitable lock or synchronized critical section.
Production lesson: Thread-safe containers protect their supported operations. They do not automatically make an entire business workflow atomic.
Scenario: The application stops responding. Two threads are blocked, and neither can continue.
This is a classic deadlock. Each thread holds a resource that the other thread needs.
Thread A:
Acquires Lock 1
Waits for Lock 2
Thread B:
Acquires Lock 2
Waits for Lock 1
Neither thread can proceed because each waits for the other to release its lock.
For example, if two accounts must be locked during a transfer, always acquire their locks in a stable order based on account ID rather than the direction of the transfer.
For explicit locks, tryLock() can help implement bounded waiting and recovery logic.
Capture multiple thread dumps and look for threads in BLOCKED states, lock ownership, and cycles of threads waiting for locks held by one another.
Production lesson: Deadlocks are best prevented through lock-ordering rules and careful critical-section design. Thread dumps help confirm the cause.
Scenario: A synchronized service method protects shared data, but response times increase when concurrent traffic grows.
Only one thread at a time can execute a synchronized instance method for the same object monitor. Other threads attempting to enter that critical section must wait.
Thread A ----> Enters synchronized method
Thread B ----> Waits
Thread C ----> Waits
Thread D ----> Waits
If the synchronized method performs slow database queries or external API calls, the lock may be held much longer than necessary.
For example, do not hold a lock while waiting for a remote service if the protected state can be updated safely in a shorter critical section.
Production lesson: Synchronization provides correctness, but overly broad locking can destroy throughput. Optimize only after identifying the contention.
Scenario: A developer marks a counter as volatile but still observes lost increments.
volatile provides visibility and ordering guarantees for accesses to that variable. It does not turn a compound read-modify-write operation into an atomic operation.
private volatile int counter;
public void increment() {
counter++;
}
Two threads can still read the same value and overwrite one another's increments.
Use an atomic type for an independently updated counter:
private final AtomicInteger counter =
new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
Use synchronized or a lock when multiple operations must be performed as one critical section.
A volatile flag remains useful for communicating a simple state change between threads:
private volatile boolean shutdownRequested;
However, a flag alone may not be enough to coordinate thread termination. Use interruption, executor shutdown, or another suitable coordination mechanism when required.
Production lesson: Visibility and atomicity are different properties. Choose a concurrency primitive that guarantees the behavior your code needs.
Scenario: An API combines customer, order, and payment information asynchronously. One task fails, and the final response is incomplete or fails unexpectedly.
A CompletableFuture pipeline propagates normal results and exceptional completion according to how its stages are composed. If an earlier stage fails, dependent stages that require its result may not execute normally.
CompletableFuture<Customer> customer =
loadCustomer();
CompletableFuture<Orders> orders =
loadOrders();
CompletableFuture<Payment> payment =
loadPayment();
Each future needs a clear failure policy. Is the entire request invalid if payment details are unavailable, or can the API return customer and order data with a partial result?
Use the appropriate completion-stage methods:
exceptionally() to provide a fallback result after failure.handle() to process either a result or an exception.whenComplete() for observing completion without replacing the result by design.thenCompose() to chain another asynchronous operation.thenCombine() to combine results from independent futures.CompletableFuture<String> future =
CompletableFuture.supplyAsync(
this::loadPaymentStatus,
executor
).exceptionally(ex -> {
log.error("Payment lookup failed", ex);
return "UNKNOWN";
});
Use a fallback only when it is semantically valid. Returning UNKNOWN is safer than falsely claiming that a payment succeeded.
For independent operations, allOf() can coordinate completion, but the application must still inspect individual results and decide how failures should affect the response.
Production lesson: Every asynchronous pipeline needs explicit rules for failure, timeout, cancellation, and partial results.
Scenario: A task is submitted to an executor, but its future never completes.
Start with executor metrics and thread dumps.
Check active thread count, queue depth, completed tasks, rejected tasks, and task duration. Inspect worker stack traces to see whether threads are running, blocked, or waiting.
For a ThreadPoolExecutor, choose bounded queues and an explicit rejection policy based on the business workload.
For tasks waiting on external services, configure timeouts. For interruptible blocking operations, support interruption and cancellation correctly.
Do not simply increase the number of worker threads. If the real problem is a blocked dependency or a lock cycle, more threads may only increase resource consumption.
Production lesson: A task that never finishes is often a symptom of blocked resources, dependency failure, or executor starvation.
Scenario: All worker threads execute parent tasks. Each parent task submits a child task to the same executor and waits for it. The child tasks never start.
This is a form of thread starvation or executor deadlock.
Executor has 4 worker threads
Worker 1: Parent waits for Child 1
Worker 2: Parent waits for Child 2
Worker 3: Parent waits for Child 3
Worker 4: Parent waits for Child 4
All workers are occupied.
Child tasks remain queued.
The executor cannot run the child tasks because every worker is blocked waiting for them.
get() or join() unnecessarily.For example, use thenCompose() to chain a dependent asynchronous operation without occupying a worker solely to wait for another future.
Simply creating a second executor can help isolate workloads, but it does not fix every dependency cycle or resource bottleneck.
Production lesson: Design executors around task behavior and dependencies, not just the number of available CPU cores.
Scenario: Multiple threads access a shared map. You need to select a suitable thread-safe implementation.
| Feature | ConcurrentHashMap | Collections.synchronizedMap() |
|---|---|---|
| Concurrency model | Supports concurrent retrievals and updates with finer-grained coordination | Wraps the map with synchronized access |
| Concurrent reads | Designed to support concurrent access efficiently | Access through the wrapper is synchronized |
| Null keys and values | Does not permit null keys or values | Depends on the wrapped map's behavior, subject to synchronization |
| Compound operations | Provides atomic methods such as compute and merge | Compound workflows need appropriate external synchronization |
Example using ConcurrentHashMap:
ConcurrentHashMap<String, Integer> counts =
new ConcurrentHashMap<>();
counts.merge("orders", 1, Integer::sum);
With synchronizedMap(), iteration generally requires synchronizing on the returned map wrapper:
Map<String, Integer> map =
Collections.synchronizedMap(new HashMap<>());
synchronized (map) {
for (var entry : map.entrySet()) {
System.out.println(entry);
}
}
Choose based on access patterns and correctness requirements. If several map operations and other shared state must change together, a concurrent map alone may not be enough.
Production lesson: Thread-safe collection access does not guarantee atomicity for an arbitrary sequence of operations.
Scenario: A developer changes stream() to parallelStream(), expecting a performance improvement, but the API becomes slower under production load.
Parallel streams split work across multiple tasks, which introduces scheduling, coordination, and result-combination overhead.
They may perform worse when:
Parallel streams commonly use the shared ForkJoinPool for their execution model. Blocking operations can interfere with other work that relies on the same pool.
Benchmark representative workloads and measure total latency, throughput, CPU usage, and contention. Compare sequential and parallel implementations under realistic concurrent traffic.
For I/O-heavy workloads, an explicit executor or an asynchronous design with controlled concurrency may be more appropriate.
Production lesson: Parallel execution is not automatically faster. Use it when the workload and measurements justify it.
Scenario: A worker thread must not start processing until another thread finishes loading required configuration.
For one-time coordination between threads, CountDownLatch is often a good fit.
CountDownLatch initialized = new CountDownLatch(1);
void initialize() {
loadConfiguration();
initialized.countDown();
}
void process() throws InterruptedException {
initialized.await();
startProcessing();
}
The worker waits at await() until the latch reaches zero. The latch is one-shot: once opened, it cannot be reset for another initialization cycle.
For other coordination requirements, choose the appropriate tool:
CountDownLatch: Wait for one or more events to complete once.Semaphore: Limit concurrent access to a resource.BlockingQueue: Coordinate producers and consumers through data transfer.CompletableFuture: Represent and compose asynchronous results.Condition: Wait for a specific state change associated with a lock.Always consider failure behavior. If initialization fails and the latch is never released, waiting threads may remain blocked indefinitely. Use timeouts or an explicit failure signal where appropriate.
Production lesson: Select synchronization utilities based on the coordination requirement rather than using sleep() to guess when another thread will finish.
Scenario: Several worker threads process separate parts of a task. None should move to the next phase until all workers finish the current phase.
A CyclicBarrier is designed for a fixed group of threads that must meet at a synchronization point before proceeding.
CyclicBarrier barrier = new CyclicBarrier(3);
Runnable worker = () -> {
try {
performPhaseOne();
barrier.await();
performPhaseTwo();
} catch (Exception ex) {
log.error("Worker failed", ex);
}
};
Each of the three participating workers waits at the barrier. When all parties arrive, they can proceed to the next phase.
A Phaser can be more appropriate when the number of participants changes or when there are multiple synchronization phases with more flexible registration and deregistration.
Important: If one worker fails before reaching the barrier, the other workers may wait indefinitely unless failure handling, timeouts, or cancellation are designed appropriately.
Production lesson: Coordination utilities can simplify multi-stage concurrency, but the design must account for worker failure and cancellation.
Scenario: A service receives data faster than downstream workers can process it. Queue size and memory usage continue to increase.
If producers can submit unlimited work while consumers process at a fixed rate, the backlog grows until latency, memory pressure, or resource exhaustion becomes a problem.
Producer rate: 10,000 tasks/second
Consumer capacity: 4,000 tasks/second
Backlog grows by approximately:
6,000 tasks/second
A larger queue may delay failure, but it does not fix a sustained mismatch between production and consumption rates.
Use bounded buffering and define what happens when capacity is reached.
BlockingQueue<Task> queue =
new ArrayBlockingQueue<>(1000);
boolean accepted = queue.offer(task);
if (!accepted) {
// Reject, defer, or apply an explicit overload policy.
}
Possible strategies include:
Do not use an unbounded queue as a substitute for capacity planning.
Production lesson: Backpressure protects the system by preventing incoming work from exceeding what downstream components can safely handle.
Scenario: A Java application uses virtual threads to handle a large number of concurrent requests, but response times increase when database traffic grows.
Virtual threads reduce the cost of maintaining large numbers of concurrent threads, particularly for workloads that spend significant time waiting on supported blocking operations.
They do not increase the capacity of the database, external APIs, CPU, or connection pool.
Many Virtual Threads
|
v
Limited Database Connections
|
v
Requests Wait for Connections
|
v
High Response Time
If an application can run thousands of concurrent tasks but has a limited number of database connections, many tasks may wait for a connection.
Other bottlenecks include:
Measure database connection wait time, query latency, active requests, pool utilization, and downstream response times. Apply admission control or concurrency limits where necessary.
Virtual threads are not a reason to remove resource limits. The application must still respect the capacity of the resources it uses.
Production lesson: Virtual threads can improve concurrency scalability, but they cannot make a constrained database process unlimited work.
Scenario: A production Spring Boot application becomes unresponsive. Request latency increases, and thread dumps show hundreds of waiting or blocked threads.
Common Java thread states include:
RUNNABLE: Executing or otherwise runnable at the JVM level; the thread may still be waiting for operating-system resources.BLOCKED: Waiting to acquire an intrinsic monitor.WAITING: Waiting indefinitely for another thread or synchronization event.TIMED_WAITING: Waiting for a bounded period.Thread state alone is not enough to identify the cause. Examine stack traces and lock information.
For a HotSpot-based JVM, you can use:
jcmd <pid> Thread.print
Capture several dumps a short time apart, where operationally appropriate. Comparing them helps distinguish persistent blocking from normal short-lived waiting.
Inspect active request count, executor queue depth, database connection pool usage, downstream latency, CPU usage, GC activity, and p95/p99 response times.
For deeper analysis, use Java Flight Recorder or a profiler to investigate CPU consumption, lock contention, and other runtime events.
Production lesson: Thread dumps show what threads are doing at a moment in time. Combining them with metrics and traces reveals why requests are slow.
When a concurrency-related production incident occurs, use a repeatable investigation process.
volatile makes compound operations atomic.HashMap concurrently without suitable coordination.parallelStream() always improves performance.Thread.sleep() instead of proper synchronization.Race conditions and unsafe shared mutable state are common problems. Other important failure modes include deadlocks, thread starvation, executor exhaustion, and unbounded work queues.
volatile provides visibility and ordering guarantees for variable access but does not make compound updates atomic. AtomicInteger provides atomic operations such as incrementing and compare-and-set.
Use it when multiple threads need efficient concurrent access to a shared map and its supported per-key operations fit your requirements. If a workflow must update multiple entries or other state atomically, additional coordination may be required.
Workers may be blocked on I/O, locks, or other tasks, or the executor may receive work faster than it can process it. Unbounded waiting and dependencies between tasks in the same pool can also cause starvation.
No. Virtual threads change how concurrent tasks can be represented and scheduled, but applications still need lifecycle management, resource limits, cancellation, timeouts, and overload control. Executors remain useful for managing task execution.
Capture thread dumps and inspect the locks each thread owns and waits for. Look for a cycle of dependencies in which each thread waits for a resource held by another thread. Then review lock ordering and critical sections.
Java concurrency is not just about creating threads or using an executor.
It is about controlling shared state, coordinating execution, managing limited resources, and ensuring the application continues to behave correctly under simultaneous requests and partial failures.
A counter can lose updates. A thread-safe map can still be used incorrectly. A future can fail without the caller noticing. A thread pool can become exhausted even when the machine has available CPU. Virtual threads can make concurrency easier while the database remains the bottleneck.
Senior developers understand not only how concurrency works, but also how it fails.
That understanding helps them design systems that are correct under load, easier to troubleshoot, and more reliable in production.
Which of these 15 concurrency problems would you find hardest to debug in a production environment?
0 Comments