Thread Safety Explained: Why count++ Fails in Multi-threaded Java
This article introduces thread safety by explaining threads, shared memory, and thread switching, then demonstrates a race condition where a Java counter loses increments due to non-atomic count++, and shows how synchronized fixes it by serializing access.
When learning a new concept, it often helps to understand prerequisite ideas first. Thread safety is one such concept: the author recommends grasping three fundamentals before diving in.
Three Concepts to Understand First
Thread. A thread is like a clue that carries code through execution step by step. A program can run multiple threads simultaneously; the main thread starts at main, and additional threads are created with new Thread().
Shared memory. In a single process, all threads share the same memory space. If multiple threads can access the same object's member variable, that variable is shared.
Thread switching. A CPU core executes only one thread at a time. Threads are switched in and out by the scheduler; the program cannot control when a switch occurs or which instruction runs next.
Together these three create the backdrop for thread-safety issues: several threads run concurrently, operate on the same variable, and the order of their operations is uncontrolled.
A Piece of Code That Calculates Incorrectly
Consider a simple click counter:
private int count = 0;
public void click() {
count++;
}In a single thread, calling click() any number of times yields the correct count. But with two threads each calling click() 50,000 times (100,000 total), the result is wrong.
Full test code (run on Windows 11 with Java 17):
public class CounterBroken {
// Counter state shared by two threads
private int count = 0;
// Three steps: 1.read count 2.add 1 3.write back count
public void click() {
count++;
}
public static void main(String[] args) throws Exception {
// Two threads operate on the same counter instance
CounterBroken counter = new CounterBroken();
// Each thread calls click 50,000 times; total should be 100,000
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.click();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.click();
}
});
t1.start();
t2.start();
// Wait for both threads to finish, then print result
t1.join();
t2.join();
System.out.println("expected=100000, actual=" + counter.count);
}
}Three runs produced:
expected=100000, actual=53887
expected=100000, actual=67931
expected=100000, actual=66587The actual counts are only 53k–67k, and each run differs even though the code is unchanged. Tens of thousands of increments are lost.
The Reason: count++ Is Three Operations
The seemingly simple count++ decomposes into three distinct steps:
Read: fetch the current value of count.
Compute: add 1 to the fetched value.
Write: store the result back to count.
In a single thread these steps run sequentially without interference. In a multi-threaded environment, two threads on different cores (or time-sliced on one core) can interleave their steps arbitrarily. A typical interleaving:
Thread A reads count → 0.
Thread B reads count → 0 (A hasn't written yet).
Thread A computes 0+1=1, writes back → count becomes 1.
Thread B computes 0+1=1, writes back → count stays 1.
Two increments produced only one net increase; B's increment was overwritten. Over 100,000 operations, thousands of such collisions cause the observed shortfall.
Race Condition Enters the Stage
This error has a name: race condition . The program's outcome depends on the relative timing of thread execution, not on the program's logic. The thread scheduler decides the order; the code cannot control or predict it. Execution is no longer a single line but multiple lines weaving unpredictably.
Race conditions also appear in check-then-act patterns (e.g., if (x > 0) x--), where the condition may become stale before the action runs.
Beyond Sharing, There Is Mutability
The variable count is mutable — its value changes repeatedly. Access occurs without any coordination mechanism (no synchronization). When three conditions coincide — shared , mutable , no synchronization — incorrect behavior is inevitable, even if it lies dormant for weeks or months. A change in load, hardware, or timing can suddenly expose the bug.
Making the Code Synchronized
Adding synchronized to the click method serializes access:
public class CounterFixed {
// Counter state shared by two threads
private int count = 0;
// synchronized ensures only one thread executes click at a time
// One thread completes read-compute-write before the next enters
public synchronized void click() {
count++;
}
public static void main(String[] args) throws Exception {
CounterFixed counter = new CounterFixed();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.click();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.click();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("expected=100000, actual=" + counter.count);
}
}Three runs now produce:
expected=100000, actual=100000
expected=100000, actual=100000
expected=100000, actual=100000 synchronizeddoes not change the logic of count++; it simply guarantees that only one thread can be inside click() at any moment. The three steps become atomic with respect to other threads, turning concurrent access into queued access.
Summary
To judge whether your code is thread-safe, ask: Is there mutable state shared by multiple threads without protection? If yes, choose one of three remedies: don't share (thread-local or immutable copies), make it immutable (final fields, no setters), or add protection (synchronization, locks, atomic classes). If no shared mutable state exists, the code is inherently safe.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
samdeepthink
Knowledge Planet: Old Dock's Tech Chronicles Zhihu: SamDeepThinking A technical manager who still codes heavily on the front line. From junior developer to tech lead, then tech manager, now leading the whole front‑ and back‑end development team—leveling up along the way. I have some insights on programming, career development, and tech management.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
