Fundamentals 11 min read

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.

samdeepthink
samdeepthink
samdeepthink
Thread Safety Explained: Why count++ Fails in Multi-threaded Java

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=66587

The 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
synchronized

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

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

thread safetyshared memoryrace conditionmulti-threadingatomicitysynchronizedJava Concurrency
samdeepthink
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.