Welcome back. In the previous lesson, you practiced making dependencies explicit in dynamic programming: define the state, identify which earlier states it relies on, and fill in an order that respects those dependencies. Java concurrency requires the same discipline, except the dependency graph now spans threads.
A backend service commonly handles many requests concurrently. If a Spring singleton, cache wrapper, or in-memory rate limiter exposes mutable state to those request threads, correct-looking Java code can still fail because operations interleave or because one thread has no guarantee of seeing another’s update. This lesson gives you a reliable way to inspect an execution trace, draw the relevant happens-before relationships, and decide whether it contains a race. Study time: about 40 minutes.
1. The question that source order cannot answer
Within one thread, Java follows program order: if a statement appears before another statement in that thread, the first action happens-before the second.
Across threads, source order is not enough. Consider:
class Counter {
private int count = 0;
void increment() {
count++;
}
}
Two request-handling threads may call increment() at nearly the same time. The source code has only one line, but the operation conceptually includes several actions:
- Read the shared value of
count. - Compute a new local value, old value plus one.
- Write the computed value back to
count.
Java does not make that entire read-modify-write operation atomic merely because int reads and writes themselves are simple operations.
The Java Memory Model uses happens-before, abbreviated , to specify when one action is guaranteed to be visible to and ordered before another action. It is not a claim that actions occur at a particular wall-clock time. It is a correctness guarantee for reasoning about shared memory.
Two ideas must remain separate:
| Concept | Meaning |
|---|---|
| Mutual exclusion | Only one thread may execute a critical section at a time. |
| Visibility and ordering | A thread is guaranteed to observe another thread’s earlier actions. |
| Happens-before | The formal relation that provides ordering and visibility guarantees across threads. |
A lock provides both mutual exclusion and a happens-before edge when threads use the same monitor correctly. A volatile field provides visibility and ordering for reads and writes of that field, but does not provide mutual exclusion.
Before continuing, read the core JLS rules. This is specification-level material, but focus on the operational rules you can apply in an interview trace rather than every formal detail.
Read the relevant parts of the Java Language Specification, Chapter 17. This is the authoritative source for monitors, synchronization order, happens-before, and the definition of a data race.
In Section 17.1, “Synchronization,” read the monitor explanation beginning with “Each object in Java is associated with a monitor” through monitor release. Notice that a synchronized block locks one specific object. Then read Section 17.4.4, “Synchronization Order.” Start at “A synchronization order is a total order over all of the synchronization actions of an execution.” Read the monitor and volatile rules, including the bullets for monitor unlock and lock, volatile write and read, start(), and join(). Finally, in Section 17.4.5, “Happens-before Order,” read the listed rules and the paragraph defining a data race. End with the synchronization guarantee. Focus on program order, synchronizes-with, and transitivity.
The happens-before rules worth memorizing
For this course, these are the highest-value rules:
-
Program order: Earlier actions in one thread happen-before later actions in that same thread.
-
Monitor lock rule: An unlock of monitor happens-before a later lock of that same monitor .
-
Volatile rule: A write to a
volatilefield happens-before a later read of that same field. -
Thread start rule: Actions before
thread.start()happen-before actions in the started thread. -
Thread join rule: All actions in a thread happen-before another thread successfully returns from
join()on it. -
Transitivity: If and , then .
The first and last rules let small synchronization facts create a larger guarantee. For example, a normal write before releasing a lock becomes visible to another thread that later acquires that lock, because program order and the monitor-lock rule compose transitively.
2. From a lost update to a data race
A data race, in the Java Memory Model sense, exists when two threads perform conflicting accesses to the same shared variable, at least one access is a write, and the accesses are not ordered by happens-before.
A race condition is broader: the program’s correctness depends on an undesirable timing or interleaving of threads. Data races commonly create race conditions, but even individually thread-safe operations can form a higher-level check-then-act race if the entire business operation is not atomic.
First, focus on the classic read-modify-write failure. Suppose count starts at .
| Trace step | Thread 1 | Thread 2 | Shared count |
|---|---|---|---|
| Initial state | 0 | ||
| 1 | reads count, gets 0 | 0 | |
| 2 | reads count, gets 0 | 0 | |
| 3 | computes local result 1 | 0 | |
| 4 | writes 1 | 1 | |
| 5 | computes local result 1 | 1 | |
| 6 | writes 1 | 1 |
Both calls completed, but the final result is , not . Thread 2 overwrote Thread 1’s update because both calculations used the same stale starting value.
The local calculations are not themselves shared-memory conflicts. The important conflicting actions are:
- Thread 1’s read of
countand Thread 2’s write ofcount. - Thread 2’s read of
countand Thread 1’s write ofcount. - The two writes to
count.
There is program order within each thread, but there is no synchronization action connecting the threads. Therefore, there is no happens-before ordering that protects the conflicting shared accesses.
This is not repaired by observing that a particular test run happened to produce . A safe-looking schedule can occur by chance. The defect is that the harmful trace is permitted and there is no correctness guarantee ruling it out.
Watch this concise walkthrough for a visual trace of the lost-update pattern and the idea of a critical section.
Race Conditions in Java Multithreading
Watch “Race Conditions in Java Multithreading” by Jakob Jenkov. It demonstrates why a one-line increment is a read-modify-write operation and how synchronization changes the trace.
Begin with the definition to distinguish read-modify-write and check-then-act races. Then watch the counter trace; pause at the interleaving where both threads read zero. Finish with the synchronized fix, focusing on why the whole critical section, rather than only its final write, must be protected.
A trace-analysis procedure
When asked to diagnose concurrent code, do not start by guessing what the CPU does. Use this sequence:
-
List shared state.
Local variables and method parameters are generally thread-confined; fields, array elements, map entries, and referenced mutable objects may be shared. -
Expand compound operations.
Treatcount++,balance += amount,if (x) { ... }, andcontainsKeyfollowed byputas multiple logical steps. -
Mark conflicting accesses.
Look for accesses to the same location with at least one write. -
Draw program-order edges within each thread.
These are automatic. -
Find synchronization bridges across threads.
Look for the same monitor, avolatilewrite and later read of the same field,start(),join(), or a higher-level concurrent primitive with documented guarantees. -
Apply transitivity.
A write inside a critical section may happen-before a later read inside a later critical section, even though the read and write are not synchronization actions themselves. -
State the failure precisely.
Identify the unordered conflicting accesses and the violated invariant, such as “two increments must increase the counter by two.”
This gives an interview-quality explanation:
“
count++is a read-modify-write critical section. Both threads can read the same old value and subsequently write the same new value. The conflicting accesses have no happens-before relationship because no common lock, volatile publication, or other synchronization connects the threads. Therefore a lost update is possible.”
3. Using synchronized: one monitor, one critical section
A synchronized block locks the monitor associated with its object expression:
synchronized (monitor) {
// critical section
}
Only one thread can hold that monitor at a time. Leaving the block releases it, including when the block exits because of an exception.
final class Counter {
private final Object monitor = new Object();
private int count;
void increment() {
synchronized (monitor) {
count++;
}
}
int get() {
synchronized (monitor) {
return count;
}
}
}
Here, count++ is entirely inside the critical section. Suppose Thread 1 increments first and exits the block; Thread 2 later enters it.
- Thread 1’s read and write of
countoccur before Thread 1 unlocksmonitor, by program order. - Thread 1’s unlock happens-before Thread 2’s later lock of that same
monitor. - Thread 2’s lock happens-before Thread 2’s later read of
count, by program order. - By transitivity, Thread 1’s update happens-before Thread 2 reads
count.
The visibility guarantee matters just as much as exclusion. Thread 2 cannot enter halfway through Thread 1’s increment, and once it enters, it is guaranteed to observe the update that Thread 1 made before releasing the monitor.

Common lock mistakes
Locking different objects does not coordinate access.
synchronized (new Object()) {
count++;
}
This creates a fresh monitor for every call. Each thread can acquire its own object’s monitor, so nothing is mutually exclusive.
Similarly, these do not protect the same state unless they intentionally use the same monitor:
synchronized (this) { ... }
synchronized (otherObject) { ... }
Be especially alert to this in backend code:
- An instance
synchronizedmethod locks that particularthis. - A
static synchronizedmethod locks the class object. - If a static field is protected by per-instance locks, separate instances can mutate it concurrently.
The correct rule is not “the code uses synchronized.” It is:
Every access that must coordinate must use the same lock, and the lock must cover the full invariant-preserving operation.
4. Using volatile: publish a state change, not a compound update
volatile is appropriate when one thread publishes a state change and another thread observes it. A typical example is a readiness flag:
final class ResultBox {
private int result;
private volatile boolean ready;
void publish(int computed) {
result = computed;
ready = true;
}
Integer tryRead() {
if (!ready) {
return null;
}
return result;
}
}
Assume a consumer reads ready as true from the publisher’s write. The guarantee is:
- The write to
resultoccurs before the writeready = truein the publisher, by program order. - The volatile write to
readyhappens-before the consumer’s later volatile read ofready. - The read of
readyoccurs before the read ofresultin the consumer, by program order. - Therefore, the
resultwrite happens-before the consumer readsresult.
The publication write must occur after the data writes. The consumer must read the volatile signal before consuming the associated ordinary state.

volatile is not a replacement for a lock in the counter example:
private volatile int count;
void increment() {
count++;
}
This still has a lost-update race. Each individual read and write has volatile visibility semantics, but two threads can both read before either performs its write; each can then write . volatile supplies ordering and visibility, not the “only one thread at a time” property needed for a read-modify-write operation.
For independent numeric increments, use a suitable atomic type such as AtomicInteger; for a larger invariant involving several fields or decisions, use a lock or a higher-level concurrency design.
Do not mistake timing for synchronization
Neither of these establishes a happens-before relationship between worker threads:
Thread.sleep(100);
Thread.yield();
Adding a delay may make a race harder to reproduce, but it does not make the code correct.
join() has a narrower, useful role:
worker1.start();
worker2.start();
worker1.join();
worker2.join();
System.out.println(counter.get());
Each successful join() ensures that the joined worker’s actions happen-before the main thread continues. It helps the main thread safely observe completed work. It does not make worker1 and worker2 coordinate with each other while both are performing their increments.
5. Data races versus check-then-act races
A useful final distinction for system-design and backend interviews:
- A data race is an unordered conflicting memory access under the Java Memory Model.
- A check-then-act race occurs when a decision and its dependent action must be atomic but are split apart.
For example, ConcurrentHashMap protects its own internal structure, but this is not one atomic business operation:
if (!users.containsKey(userId)) {
users.put(userId, createUser(userId));
}
Two threads can both observe that the user is absent, both create an object or trigger an external side effect, and then race to insert. The map remains structurally safe, but the application invariant “create this user only once” may be violated.
The fix might be an atomic map operation such as putIfAbsent or computeIfAbsent, or a lock covering the entire decision and action. The key question is always:
What invariant must be true, and what complete sequence of reads, checks, writes, and side effects must occur as one coordinated operation?
Key takeaways
- Happens-before is Java’s formal guarantee of cross-thread ordering and visibility; it is not merely source-code order or elapsed time.
- A data race involves conflicting shared-memory accesses that lack a happens-before relationship.
- Expand compound expressions such as
count++into read, compute, and write steps before analyzing a trace. synchronizedprovides mutual exclusion and visibility only when the same monitor protects the entire critical section.- A volatile write followed by a later volatile read of the same field provides a publication edge, but
volatiledoes not make compound operations atomic. sleep()andyield()are timing tools, not synchronization tools;join()orders a completed worker with the joining thread, not peer workers with each other.- A thread-safe collection can still participate in a higher-level check-then-act race if several individually safe calls do not preserve the business invariant atomically.
Next, you will configure an ExecutorService for bounded concurrency and graceful shutdown. That will turn these low-level correctness rules into a practical server-side policy: limiting how much concurrent work your application accepts and ensuring worker tasks finish predictably during shutdown.
Can't find a good explanation? Sign up and we'll make it for you
Sign up