Common multithreading issues in Java
Apr 2, 2025 - ⧖ 25 minIntroduction
Multithreading in Java enables concurrent execution of multiple threads within a single application, potentially improving performance and resource utilization. However, improper thread management can cause a lot of bugs that only manifest under specific conditions.
In this post, I'll walk through some of the multithreading pitfalls I've encountered, along with some solutions and usecases/anti-patterns to help you avoid the same mistakes I've mapped here, in the following order:
- Race Conditions
- Memory Visibility Issues
- Deadlocks
- Thread Pool Configuration
NOTE: Maybe in a future post, I'll cover Spring-specific multithreading issues like @Async limitations, transaction boundaries, and event processing.*
Race Conditions
What Are Race Conditions?
A race condition occurs when multiple threads access and modify shared data concurrently, leading to unpredictable behavior. The outcome depends on thread execution timing, making it difficult to reproduce and debug. According to a study published in the IEEE Transactions on Software Engineering, race conditions account for approximately 29% of all concurrency bugs in production systems [1]. In my experience, they account for about 99% of my hair lost.
Example
Consider a simple counter implementation:
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
When multiple threads call increment()
concurrently, the expected value might differ from actual results. This happens because count++
is not atomic—it involves three operations:
- Read the current value of count
- Increment the value
- Write the new value back to count
So basically if Trhead A and Thread B both read the initial value before either updates it, one increment will be lost.
Detecting Race Conditions
Race conditions can be detected using:
- Java Thread Dumps: Analyze thread dumps when application behavior is inconsistent
- Code Reviews: Look for shared mutable state accessed by multiple threads
- Testing Tools: Tools like Java PathFinder, FindBugs, and JCStress can detect potential race conditions
Solving Race Conditions in Java
1. Using Synchronized Keyword
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
The synchronized
keyword ensures that only one thread can execute the method at a time. It achieves this by acquiring an intrinsic lock (monitor) on the object instance. However, synchronization introduces overhead as threads must wait for the lock to be released (think of it as putting a bouncer at the door who only lets one person in at a time — secure, but creates a line).
2. Using AtomicInteger
Java's java.util.concurrent.atomic
package provides thread-safe primitive types:
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
AtomicInteger
uses Compare-And-Swap (CAS) operations, which are typically more efficient than synchronization for simple operations.
3. Using Lock Interface
For more complex scenarios, the java.util.concurrent.locks
package offers more flexible locking mechanisms (when you need a more sophisticated bouncer):
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Real-World Example
Consider a payment processing gateway that handles high-volume financial transactions. In this scenario, a transaction processing system occasionally showed incorrect account balances. Analysis of production logs revealed a classic race condition where multiple threads were updating the same account simultaneously(I took this example from an interview I did couple of years ago):
public void processPayment(Long accountId, BigDecimal amount) {
Account account = accountRepository.findById(accountId).orElseThrow();
BigDecimal newBalance = account.getBalance().add(amount);
account.setBalance(newBalance);
accountRepository.save(account);
}
This implementation has a critical race condition:
- Thread A reads account balance (1000)
- Thread B reads the same account balance (1000)
- Thread A calculates new balance (1000 + 200 = 1200)
- Thread B calculates new balance (1000 + 500 = 1500)
- Thread A saves the new balance (1200)
- Thread B saves the new balance (1500)
In this scenario, the 200 payment processed by Thread A is effectively lost because Thread B overwrote it with its calculation based on the original balance. The system has lost 200 units of currency - a serious financial discrepancy.
The solution requires implementing proper transaction isolation. In database terms, this is precisely what the SERIALIZABLE isolation level is designed to prevent:
@Transactional(isolation = Isolation.SERIALIZABLE)
public void processPayment(Long accountId, BigDecimal amount) {
Account account = accountRepository.findById(accountId).orElseThrow();
BigDecimal newBalance = account.getBalance().add(amount);
account.setBalance(newBalance);
accountRepository.save(account);
}
With SERIALIZABLE isolation, the database ensures that concurrent transactions behave as if they were executed sequentially. This prevents the race condition by ensuring that:
- Thread A begins transaction and reads account (1000)
- Thread B begins transaction and tries to read the same account
- Thread B is blocked (or gets a version error, depending on implementation) until Thread A completes
- Thread A updates balance to 1200 and commits
- Thread B now reads the updated account (1200)
- Thread B updates balance to 1700 (1200 + 500) and commits
When implementing this solution, be aware that SERIALIZABLE has performance implications:
- Concurrency reduction: It reduces the number of concurrent operations the system can perform
- Deadlock risk: Higher isolation levels increase the risk of deadlocks
- Performance cost: There's a tradeoff between consistency and throughput
For some financial systems, a more scalable approach is optimistic locking with version control. Think of it like a Google Doc's version history - if two people edit the same document simultaneously, the system detects the conflict and handles it gracefully.
How Optimistic Locking Works
- Version Tracking: Each record keeps track of its version number
- Read Phase: When reading a record, we store both its data AND version number
- Update Phase: When saving, we check if the version is still the same
- Conflict Detection: If someone else changed the record (version mismatch), we handle the conflict
Here's a practical implementation using Spring:
@Entity
public class Account {
@Id
private Long id;
private BigDecimal balance;
@Version // this annotation tells JPA to handle versioning
private Long version; // automatically incremented on each update
}
@Service
@Transactional(isolation = Isolation.READ_COMMITTED)
public class PaymentService {
private final AccountRepository accountRepository;
public void processPayment(Long accountId, BigDecimal amount) {
// the first thread reads version = 1
Account account = accountRepository.findById(accountId).orElseThrow();
BigDecimal newBalance = account.getBalance().add(amount);
account.setBalance(newBalance);
try {
// if another thread updated the account (now version = 2),
// this save will fail with OptimisticLockException
accountRepository.save(account);
} catch (OptimisticLockException e) {
// handle the conflict(like a retry or something)
throw new PaymentConflictException("Payment failed - please retry");
}
}
// retry example
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 100))
public void processPaymentWithRetry(Long accountId, BigDecimal amount) {
processPayment(accountId, amount);
}
}
Let's see how this prevents the race condition:
- Thread A reads account (balance = 1000, version = 1)
- Thread B reads account (balance = 1000, version = 1)
- Thread A calculates new balance (1000 + 200 = 1200)
- Thread B calculates new balance (1000 + 500 = 1500)
- Thread A saves changes → success (balance = 1200, version = 2)
- Thread B tries to save → FAILS because version changed
- Thread B retries with fresh data (balance = 1200, version = 2)
- Thread B calculates new balance (1200 + 500 = 1700)
- Thread B saves changes → success (balance = 1700, version = 3)
The key advantages of optimistic locking over SERIALIZABLE isolation:
- Better Performance: No need to lock records - we only check versions when saving
- Higher Throughput: Multiple transactions can read the same data simultaneously
- Deadlock Prevention: No locks means no deadlocks
- Automatic Conflict Detection: The database handles version checking automatically
The main trade-off is that you need to handle the retry logic when conflicts occur. This is usually acceptable because conflicts are typically rare in real-world scenarios - they only happen when two users try to modify the exact same record at the exact same time.
Memory Visibility Issues
What Are Memory Visibility Issues?
In Java's memory model, threads may cache variables locally instead of reading them from main memory. This can lead to a situation where updates made by one thread aren't visible to others. In the Java Memory Model (JMM) specification, variable updates without proper synchronization aren't guaranteed to be visible across threads.
Nerds alert: If you want to go for a deep dive into memory visibility, you'll need to dive into the Java Memory Model (JMM). For a comprehensive explanation, you might want to check "Java Concurrency in Practice" by Brian Goetz [4]. For the brave souls who want the formal specification(which I can't even explain explicitly), you'll be able to find it in Chapter 17.4 of the oracle doc(link).
Example of Memory Visibility Issues
Consider a flag to control thread execution:
public class TaskManager {
private boolean stopped = false;
public void stop() {
stopped = true;
}
public void runTask() {
while (!stopped) {
//do sometihng
}
}
}
Thread A could call stop()
but Thread B might never see the update, resulting in an infinite loop. This is a classic memory visibility problem – the thread executing runTask()
may maintain a local copy of stopped
in its cache and never see the update made by another thread.
Solving Memory Visibility Issues
There are three main approaches to solving memory visibility issues, each with specific use cases:
1. Using volatile Keyword
The volatile
keyword ensures that reads and writes go directly to main memory(like a "no-cache" flag):
public class TaskManager {
private volatile boolean stopped = false;
public void stop() {
stopped = true;
}
public void runTask() {
while (!stopped) {
//do sometihng
}
}
}
When to use volatile
:
- For simple variables that function as flags/signals
- When you only need visibility guarantees (without atomicity)
- When performance is critical (it's the lightest solution)
- Important: Doesn't guarantee atomicity for compound operations like
i++
(read + increment + write)
2. Using Synchronized Access
synchronized
blocks provide both visibility guarantees and mutual exclusion (only one thread can execute the code at a time):
public class TaskManager {
private boolean stopped = false;
public synchronized void stop() {
stopped = true;
}
public void runTask() {
while (!isStopped()) {
//do sometihng
}
}
private synchronized boolean isStopped() { // < -- thread safe access
return stopped;
}
}
When to use synchronized
:
- When you need mutual exclusion in addition to visibility
- To protect compound operations that must be atomic
- When the same thread needs to check and update multiple related variables
- Disadvantage: Less peformartic when compared to
volatile
3. Using Atomic Variables
Classes from the java.util.concurrent.atomic
package combine visibility with atomic operations(the CAS):
import java.util.concurrent.atomic.AtomicBoolean;
public class TaskManager {
private AtomicBoolean stopped = new AtomicBoolean(false);
public void stop() {
stopped.set(true);
}
public void runTask() {
while (!stopped.get()) {
//do sometihng
}
}
}
When to use Atomic
:
- When you need atomic operations without the overhead of full locking
- For counters, accumulators, and flags that need atomic operations
- To implement high-performance lock-free algorithms
- When you need atomic compound operations like compareAndSet()
- Advantage: Better scalability under high contention compared to
synchronized
Comparison between the approaches
Approach | Visibility | Atomicity | Locking | Performance | Use Cases |
---|---|---|---|---|---|
volatile |
✅ | ❌ | ❌ | Excellent | Simple flags, visibility only |
synchronized |
✅ | ✅ | ✅ | Good/Medium | Protecting complex shared state |
Atomic |
✅ | ✅ | ❌ | Very good | Counters, CAS operations, high concurrency |
Deadlocks
What Are Deadlocks?
In technical terms, a deadlock occurs when two or more threads each hold a resource that the other needs to continue execution. This creates a circular dependency where each thread is blocked indefinitely, waiting for resources that will never be released.
The four necessary conditions for a deadlock (all must be present):
- Mutual Exclusion: Resources cannot be shared simultaneously
- Hold and Wait: Threads hold resources while waiting for others
- No Preemption: Resources cannot be forcibly taken from threads
- Circular Wait: A circular chain of threads, each waiting for a resource held by the next
I think of deadlocks like an episode of Foster's Home For Imaginary Friends(which I watched as a kid), where Wilt (or Minguado in Portuguese) is that super polite imaginary friend who gets stuck at a doorway: "After you!" "No, after you!" "I insist, after you!" "No, after you!" So he remains stuck forever. In real life, social awkwardness would eventually break this standoff as someone gives in. But in your application, there's no episode ending or social pressure to resolve the situation—just an unresponsive system that needs rebooting.

Wilt - or Minguado for brazilians.
Example of Deadlock
Here's a classic deadlock scenario with two resources and two threads(This one I also took from an interview I did some time ago):
public class BankTransferDeadlock {
private final Object accountALock = new Object();
private final Object accountBLock = new Object();
private double accountABalance = 1000;
private double accountBBalance = 1000;
// Thread 1 IS EXECUTING THIS
public void transferAtoB(double amount) {
synchronized(accountALock) { // acquire lock on account A
System.out.println("Thread 1: Locked account A");
// simulate some work before trying to acquire second lock
try { Thread.sleep(100); } catch (InterruptedException e) {}
accountABalance -= amount;
synchronized(accountBLock) { // try to acquire lock on account B
System.out.println("Thread 1: Locked account B");
accountBBalance += amount;
System.out.println("Transfer from A to B complete");
}
}
}
// Thread 2 EXECUITING THIS
public void transferBtoA(double amount) {
synchronized(accountBLock) { // acquire lock on account B
System.out.println("Thread 2: Locked account B");
// simulate some work before trying to acquire second lock
try { Thread.sleep(100); } catch (InterruptedException e) {}
accountBBalance -= amount;
synchronized(accountALock) { // try to acquire lock on account A
System.out.println("Thread 2: Locked account A");
accountABalance += amount;
System.out.println("Transfer from B to A complete");
}
}
}
}
Here's what happens when this deadlock occurs:
- Thread 1 calls
transferAtoB()
and acquires the lock on account A - Thread 2 calls
transferBtoA()
and acquires the lock on account B - Thread 1 tries to lock account B, but it's already locked by Thread 2 → blocks
- Thread 2 tries to lock account A, but it's already locked by Thread 1 → blocks
- Both threads are now waiting for resources held by the other.
The application appears to freeze with no error message - one of the most frustrating bugs to troubleshoot. In production systems, this often manifests as an application that suddenly stops responding and requires a restart. Unless saying that your algorithm runs on O(∞) in the worst case is acceptable, you should avoid it.
Preventing Deadlocks
1. Lock Ordering
Always acquire locks in the same order:
public class ResourceManager {
private final Object resourceA = new Object();
private final Object resourceB = new Object();
public void process1() {
synchronized(resourceA) {
synchronized(resourceB) {
// do something with both resources
}
}
}
public void process2() {
synchronized(resourceA) { // now acquiring in same order as process1
synchronized(resourceB) {
// do something with both resources
}
}
}
}
2. Using tryLock with Timeout
The Lock
interface provides tryLock()
with timeout to avoid indefinite waiting:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class ResourceManager {
private final Lock lockA = new ReentrantLock();
private final Lock lockB = new ReentrantLock();
public void process() throws InterruptedException {
boolean gotBothLocks = false;
try {
boolean gotLockA = lockA.tryLock(1, TimeUnit.SECONDS);
if (gotLockA) {
try {
boolean gotLockB = lockB.tryLock(1, TimeUnit.SECONDS);
gotBothLocks = gotLockB;
} finally {
if (!gotBothLocks) {
lockA.unlock(); // release first lock if couldn't get second
}
}
}
if (gotBothLocks) {
// do something with both resources
} else {
log.warn("Failed to acquire locks, will retry later");
}
} finally {
if (gotBothLocks) {
lockB.unlock();
lockA.unlock();
}
}
}
}
3. Using java.util.concurrent Classes
Higher-level concurrency utilities often handle lock management internally (remember the inversion of control):
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ConcurrentHashMap;
public class ResourceManager {
private final ExecutorService executor = Executors.newFixedThreadPool(10);
private final ConcurrentHashMap<String, Resource> resources = new ConcurrentHashMap<>();
// no explicit locking needed for many operations
}
Thread Pool Configuration Issues
Common Thread Pool Problems
Java applications often use thread pools via ExecutorService. Incorrect configuration can lead to:
- Thread starvation: When all threads are busy and tasks queue up
- Resource exhaustion: When too many threads consume too much memory
Thread Pool Best Practices
1. Size Thread Pools Appropriately
For CPU-bound tasks:
int cpuCores = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(cpuCores);
For I/O-bound tasks (things that wait a lot):
// I/O bound tasks can benefit from more threads
// (because threads spend most of their time waiting)
int threadPoolSize = Runtime.getRuntime().availableProcessors() * 2;
ExecutorService executor = Executors.newFixedThreadPool(threadPoolSize);
2. Use Different Thread Pools for Different Types of Tasks
// image the scenario you make your ferrari wait behind a garbage truck
ExecutorService cpuBoundTasks = Executors.newFixedThreadPool(cpuCores);
ExecutorService ioBoundTasks = Executors.newFixedThreadPool(cpuCores * 2);
3. Use Bounded Queues
Choosing the right queue type and size is crucial for thread pool performance. The queue acts as a buffer between task submission and execution, but an unbounded queue can lead to OutOfMemoryError if tasks are submitted faster than they can be processed.
There are three main queuing strategies:
-
Direct handoff (SynchronousQueue): Tasks are handed directly to threads. If no thread is available, the task submission is rejected. Best for CPU-intensive tasks where queuing would just add overhead.
-
Bounded queue (ArrayBlockingQueue): Provides a buffer but with a limit, preventing resource exhaustion. Best for mixed workloads where some queuing helps smooth out bursts of requests.
-
Unbounded queue (LinkedBlockingQueue): Can grow indefinitely. Only appropriate when task submission rate is naturally limited or when you have infinite memory (spoiler: you don't).
Here's how to implement a bounded queue with appropriate rejection handling:
int corePoolSize = 5;
int maxPoolSize = 10;
long keepAliveTime = 60L;
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(100);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
workQueue,
new ThreadPoolExecutor.CallerRunsPolicy()); // saturation policy
The rejection/saturation policy determines what happens when both the queue and the thread pool are full:
- CallerRunsPolicy: Executes the task in the caller's thread (as shown above)
- AbortPolicy: Throws RejectedExecutionException (default)
- DiscardPolicy: Silently drops the task
- DiscardOldestPolicy: Drops the oldest queued task to make room
Goetz recommends the CallerRunsPolicy as it provides a form of throttling - when the system is overloaded, the submitting threads start executing tasks themselves, naturally slowing down the submission rate.
A real-world sizing example:
Scenario 1: Order Processor with Unbounded Queue
@Service
public class OrderProcessor {
private final ExecutorService executor = new ThreadPoolExecutor(
10, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>() // unbounded queue - DANGER!
);
public void processOrder(Order order) {
executor.submit(() -> {
// process order (validate payment, reserve inventory, etc)
// takes ~500ms per order
});
}
}
Problem: During high-load events like Black Friday:
- Thousands of orders arrive per second
- All orders get accepted into memory
- Java heap grows indefinitely
- Eventually: OutOfMemoryError
Scenario 2: Order Processor with Bounded Queue
@Service
public class OrderProcessor {
private static final int CORE_THREADS = 10;
private static final int MAX_THREADS = 20;
// based on available memory
//(maybe in the future I can write how to calculate and monitor it based on the container you're using)
private static final int QUEUE_CAPACITY = 500;
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_THREADS,
MAX_THREADS,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAPACITY),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public void processOrder(Order order) {
executor.submit(() -> {
// do processing, but now with backpressure
});
}
}
With this configuration:
- First 500 excess tasks go to the queue
- When queue fills, CallerRunsPolicy makes the caller thread execute the task
- This naturally slows down upstream systems (API Gateway, Load Balancer)
- System remains stable even under extreme load
Conclusion
Of course that compared to Brian Goetz and Doug Lea (who literally wrote the concurrency library in Java), I'm just a protozoan on their shoe. What I've shared here is merely a light breath on the most important concepts. To truly master these topics, I strongly recommend reading Goetz's "Java Concurrency in Practice" - it's the definitive resource that helped me understand these complex concepts.
But to sum everything up, when designing java multithreaded applications always favor simplicity and proven patterns over complex custom solutions. Java's concurrency utilities in the java.util.concurrent
package, combined with a solid understanding of the principles I've covered in this post, will help you build robust, thread-safe applications.
As Goetz states: "Write thread-safe code, but don't use more synchronization than necessary." This balance between safety and performance is the key to effective concurrent programming - a lesson I'm still learning every day :)
References
-
Lu, S., Park, S., Seo, E., & Zhou, Y. "Learning from mistakes: a comprehensive study on real world concurrency bug characteristics." ACM SIGARCH Computer Architecture News, 36(1), 2008. https://dl.acm.org/doi/10.1145/1346281.1346323.
-
Manson, J., Pugh, W., & Adve, S. V. "The Java memory model." ACM SIGPLAN Notices, 40(1), 2005. https://dl.acm.org/doi/10.1145/1040305.1040336.
-
Goetz, B., Peierls, T., Bloch, J., Bowbeer, J., Holmes, D., & Lea, D. Java Concurrency in Practice. Addison-Wesley Professional, 2006. https://github.com/AngelSanchezT/books-1/blob/master/concurrency/Java%20Concurrency%20in%20Practice.pdf.