Common multithreading issues in Java

Introduction

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:

  1. Race Conditions
  2. Memory Visibility Issues
  3. Deadlocks
  4. 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:

  1. Read the current value of count
  2. Increment the value
  3. 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:

  1. Java Thread Dumps: Analyze thread dumps when application behavior is inconsistent
  2. Code Reviews: Look for shared mutable state accessed by multiple threads
  3. 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:

  1. Thread A reads account balance (1000)
  2. Thread B reads the same account balance (1000)
  3. Thread A calculates new balance (1000 + 200 = 1200)
  4. Thread B calculates new balance (1000 + 500 = 1500)
  5. Thread A saves the new balance (1200)
  6. 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:

  1. Thread A begins transaction and reads account (1000)
  2. Thread B begins transaction and tries to read the same account
  3. Thread B is blocked (or gets a version error, depending on implementation) until Thread A completes
  4. Thread A updates balance to 1200 and commits
  5. Thread B now reads the updated account (1200)
  6. Thread B updates balance to 1700 (1200 + 500) and commits

When implementing this solution, be aware that SERIALIZABLE has performance implications:

  1. Concurrency reduction: It reduces the number of concurrent operations the system can perform
  2. Deadlock risk: Higher isolation levels increase the risk of deadlocks
  3. 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

  1. Version Tracking: Each record keeps track of its version number
  2. Read Phase: When reading a record, we store both its data AND version number
  3. Update Phase: When saving, we check if the version is still the same
  4. 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:

  1. Thread A reads account (balance = 1000, version = 1)
  2. Thread B reads account (balance = 1000, version = 1)
  3. Thread A calculates new balance (1000 + 200 = 1200)
  4. Thread B calculates new balance (1000 + 500 = 1500)
  5. Thread A saves changes → success (balance = 1200, version = 2)
  6. Thread B tries to save → FAILS because version changed
  7. Thread B retries with fresh data (balance = 1200, version = 2)
  8. Thread B calculates new balance (1200 + 500 = 1700)
  9. Thread B saves changes → success (balance = 1700, version = 3)

The key advantages of optimistic locking over SERIALIZABLE isolation:

  1. Better Performance: No need to lock records - we only check versions when saving
  2. Higher Throughput: Multiple transactions can read the same data simultaneously
  3. Deadlock Prevention: No locks means no deadlocks
  4. 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):

  1. Mutual Exclusion: Resources cannot be shared simultaneously
  2. Hold and Wait: Threads hold resources while waiting for others
  3. No Preemption: Resources cannot be forcibly taken from threads
  4. 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 from Foster's Home For Imaginary Friends

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:

  1. Thread 1 calls transferAtoB() and acquires the lock on account A
  2. Thread 2 calls transferBtoA() and acquires the lock on account B
  3. Thread 1 tries to lock account B, but it's already locked by Thread 2 → blocks
  4. Thread 2 tries to lock account A, but it's already locked by Thread 1 → blocks
  5. 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:

  1. Thread starvation: When all threads are busy and tasks queue up
  2. 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:

  1. 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.

  2. 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.

  3. 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:

  1. Thousands of orders arrive per second
  2. All orders get accepted into memory
  3. Java heap grows indefinitely
  4. 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:

  1. First 500 excess tasks go to the queue
  2. When queue fills, CallerRunsPolicy makes the caller thread execute the task
  3. This naturally slows down upstream systems (API Gateway, Load Balancer)
  4. 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