Hello! Welcome back to our module on Advanced Concurrency and Performance.
In our last lesson, we established that a standard Spring Boot application uses a pool of threads to handle concurrent requests. This concurrency is key to performance, but it also introduces significant risks. When multiple threads operate simultaneously, what prevents them from interfering with each other and corrupting your application's data?
This lesson addresses that exact question. Your ability to answer it is a major differentiator in senior-level interviews at companies like Paypal and FAANG, as it demonstrates a fundamental understanding of how to build robust, production-ready concurrent systems.
Our learning outcome is to identify potential race conditions in concurrent code and resolve them using thread-safe constructs (e.g., ReentrantLock, Atomic classes). We'll explore what a race condition is, see how it can silently corrupt data, and then learn about the primary Java tools for preventing it.
1. What is a Race Condition?
Imagine a simple hit counter for a web page. Every time a user visits, you increment a counter. In a multi-threaded environment, multiple requests can come in at the exact same time.
A race condition occurs when the correctness of your program depends on the unpredictable sequence or timing of threads being executed. Multiple threads "race" to access and change a shared piece of data, and the final result can be wrong depending on which thread "wins."
The most common type of race condition follows a "check-then-act" or "read-modify-write" pattern. A thread reads a value, modifies it, and writes it back. The problem is that another thread can modify the same value between the read and the write.
This diagram illustrates the problem perfectly. Both processes read the initial balance of 100. P2 subtracts 10 and writes 90. Then, P1, still holding the old value of 100, adds 10 and writes 110, overwriting P2's update. The final balance is incorrect because the operations were not atomic.

Let's see this in Java code. Consider a simple counter:
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++; // This is not an atomic operation!
}
public int getCount() {
return count;
}
}
You might think that count++ is a single, safe operation. It's not. The Java compiler breaks it down into at least three steps:
- Read: Get the current value of
count. - Modify: Add 1 to the value.
- Write: Store the new value back into
count.
Now, imagine two threads, A and B, trying to increment the counter when count is 10:
- Thread A reads
count(value is 10). - The OS scheduler pauses Thread A and runs Thread B.
- Thread B reads
count(value is still 10). - Thread B modifies its value to 11.
- Thread B writes 11 back to
count. - The scheduler switches back to Thread A.
- Thread A, which already read 10, modifies its value to 11.
- Thread A writes 11 back to
count.
Even though increment() was called twice, the final count is 11, not 12. One increment was lost. This is a race condition. The section of code that accesses the shared resource (count++) is known as the critical section.
To see a live demonstration of this failure, let's watch a short video.
Java Concurrecy: Volatile vs Atomic - Java Programming
The video 'Java Concurrency: Volatile vs Atomic' clearly demonstrates this exact problem. It shows how a simple counter fails under multithreading.
Watch the segment from 05:52 to 07:44. The narrator sets up a test where five threads each increment a counter 10,000 times. Notice how the final result is not 50,000, proving the existence of a race condition.
Now that we can identify the problem, let's look at how to solve it.
2. Solution 1: Atomic Classes for Simple Updates
For simple cases like counters or flags, Java provides a highly efficient, non-blocking solution in the java.util.concurrent.atomic package.
These classes, such as AtomicInteger, AtomicLong, and AtomicBoolean, guarantee that operations on a single variable are atomic. They achieve this using a low-level hardware instruction called Compare-And-Swap (CAS).
Instead of using locks, which can cause threads to sleep, CAS is an optimistic approach:
- Read the current value of the variable (let's call it
expectedValue). - Perform the modification to get the
newValue. - Atomically, tell the CPU: "If the variable's current value is still
expectedValue, update it tonewValue." - If the update fails (because another thread changed the value in the meantime), the process is retried until it succeeds.
Let's fix our counter using AtomicInteger.
import java.util.concurrent.atomic.AtomicInteger;
public class SafeCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // This is an atomic operation
}
public int getCount() {
return count.get();
}
}
By using AtomicInteger and its incrementAndGet() method, we ensure that each increment is an indivisible, thread-safe operation.
🛡️ What is a Race Condition in Java, and how it can be ...
For another perspective, this article 'What is a Race Condition in Java' provides a great example using a donut shop.
Read the sections 'Problem 2 — Multiple threads have access to the same variable at a time' and 'Solution — AtomicInteger'. This will reinforce how race conditions happen and how AtomicInteger solves the simple case.
Atomic classes are the preferred solution for managing the state of a single shared variable due to their high performance and non-blocking nature. However, their utility is limited.
3. Solution 2: Locking for Compound Actions
What if your critical section involves more than one step? For example, checking if a bank account has enough funds and then making a withdrawal.
- Check balance.
- If balance is sufficient, withdraw the amount.
This is a classic "check-then-act" scenario. Even if the balance is an AtomicInteger, another thread could withdraw funds between your check and your withdrawal, causing an overdraft. The two operations must be performed as a single, atomic unit.
This is where locking becomes necessary. The most common locking mechanism in Java is the synchronized keyword.
When a thread enters a synchronized block or method, it must acquire the intrinsic lock (or monitor) of an object. Only one thread can hold the lock at a time. Any other thread that attempts to acquire the same lock will be blocked (put to sleep) until the lock is released. This guarantees mutual exclusion for the critical section.
🛡️ What is a Race Condition in Java, and how it can be ...
The same donut shop article demonstrates this limitation of AtomicInteger and introduces synchronized as the solution.
Now, read the sections 'Problem 3 — AtomicInteger may be not enough' and 'Solution — synchronized keyword'. Focus on why AtomicInteger fails when the operation becomes a multi-step 'check-then-act' process, and how a synchronized block correctly protects this compound action.
Let's look at a simplified bank account example:
public class BankAccount {
private double balance;
private final Object lock = new Object(); // A dedicated lock object
public void withdraw(double amount) {
// The synchronized block defines the critical section
synchronized (lock) {
if (balance >= amount) {
System.out.println(Thread.currentThread().getName() + " is withdrawing.");
balance = balance - amount;
} else {
System.out.println(Thread.currentThread().getName() + " found insufficient funds.");
}
}
}
// ... deposit and getBalance methods
}
By synchronizing on the lock object, we ensure that no other thread can execute the code inside the block until the current thread is finished. The check if (balance >= amount) and the update balance = balance - amount happen as one atomic unit.
Beyond synchronized: ReentrantLock
While synchronized is simple and effective, the java.util.concurrent.locks package provides a more powerful and flexible lock implementation: ReentrantLock.
import java.util.concurrent.locks.ReentrantLock;
public class BankAccountWithReentrantLock {
private double balance;
private final ReentrantLock lock = new ReentrantLock();
public void withdraw(double amount) {
lock.lock(); // Acquire the lock
try {
if (balance >= amount) {
balance = balance - amount;
}
} finally {
lock.unlock(); // ALWAYS release the lock in a finally block
}
}
}
It is critical to place lock.unlock() in a finally block to ensure the lock is always released, even if an exception occurs in the try block. Forgetting to do this can lead to a deadlock, where other threads wait forever for a lock that will never be released.
ReentrantLock provides the same mutual exclusion as synchronized but with additional features often asked about in interviews:
- Interruptible Waits: A waiting thread can be interrupted.
- Timed Waits: You can attempt to acquire a lock for a certain amount of time and give up if it's not available (
tryLock()). - Fairness: You can configure the lock to be "fair," meaning it grants access to the longest-waiting thread, preventing starvation.
Interview Focus: Choosing the Right Tool
A common senior-level interview question is not just "how do you solve a race condition?" but "When do you use an atomic class versus a lock?"
| Feature | Atomic Classes (e.g., AtomicInteger) | Locks (synchronized, ReentrantLock) |
|---|---|---|
| Mechanism | Non-blocking (uses CAS) | Blocking (threads may sleep) |
| Scope | Atomic updates on a single variable. | Protecting compound actions (critical sections) involving one or more variables. |
| Performance | Generally higher throughput under low-to-moderate contention. | Can become a bottleneck under high contention as threads block and wait. |
| Use Case | Implementing counters, sequence generators, or simple flags. | Enforcing complex invariants, like check-then-act logic in a bank withdrawal. |
In short: Use atomic classes for simple, single-variable atomicity. Use locks for everything else.
Test your understanding!
You are designing a service that manages inventory for an e-commerce platform. One requirement is to track the number of times an item's product page has been viewed. This is a high-volume operation. Another requirement is to process an order, which involves checking if stock is available and, if so, decrementing the stock count.
Which concurrency mechanism would you choose for each requirement, and why?
Show answer
-
For the Page View Counter: I would use an
AtomicInteger. This is a perfect use case for it. The operation is a simple, single-variable increment. Using anAtomicIntegeris much more performant than using a lock because it's non-blocking (uses CAS), minimizing contention and maximizing throughput for this very high-frequency operation. -
For Processing an Order: I would use a lock, such as
synchronizedorReentrantLock. This is a classic "check-then-act" compound action. You must ensure that the check for stock availability and the decrement of the stock count happen as a single, atomic unit. If you used an atomic for the stock count, two threads could both see "1 item in stock," and both attempt to sell it, leading to one customer order that cannot be fulfilled. A lock ensures that once a thread starts the process of checking and decrementing stock, no other thread can interfere until it's complete.
Conclusion
Today, we took a crucial step in understanding concurrent programming. We've seen how easily data can be corrupted when multiple threads access shared state and, more importantly, learned the tools to prevent it.
Key Takeaways:
- Race Condition: A concurrency bug where the outcome depends on the unpredictable timing of threads accessing shared mutable data. The "read-modify-write" pattern is a common cause.
- Critical Section: The part of your code that accesses shared data and must be executed atomically.
- Atomic Classes: Use
AtomicIntegerand its siblings for high-performance, non-blocking, atomic operations on a single variable. They are ideal for counters and flags. - Locks: Use
synchronizedorReentrantLockto protect larger, compound actions (critical sections) that must be executed as a single unit. They are more general-purpose but introduce blocking.
Next Up
We've seen that locks are essential for protecting complex operations. However, like any powerful tool, they introduce their own risks if used incorrectly. What happens if Thread A holds a lock and is waiting for a resource held by Thread B, while Thread B holds its own lock and is waiting for the resource held by Thread A? This is a deadlock, and it can bring your application to a grinding halt.
In our next lesson, we will learn to analyze a thread dump to identify a deadlock and explain deadlock prevention strategies like lock ordering.
Can't find a good explanation? Sign up and we'll make it for you
Sign up