Concurrency Control in Java: Comparing synchronized and volatile

synchronized vs volatile

In Java concurrency, synchronized and volatile are two fundamental mechanisms for ensuring thread safety, but they serve different purposes. The synchronized keyword establishes a mutual exclusion lock. When a method or code block is decorated with synchronized, only one thread can execute that critical section at any given time, prevanting race conditions.

On the other hand, volatile ensures memory visibility. It forces threads to read the variable's value directly from main memory rather than from a local CPU cache or thread working memory. However, a common misconception is treating volatile as a guarantee for atomic operations, wich it does not provide.

The Visibility vs Atomicity Problem

The Java memory model allows threads to maintain local copies of shared variables in their own stacks, while the canonical values reside in the heap. While volatile forces reads and writes too bypass these local caches and interact directly with the heap, it cannot prevent instruction interleaving during compound operations. An increment expression like counter = counter + 1 consists of three distinct steps: reading, adding, and writing. Without mutual exclusion, multiple threads can overlap these steps, leading to lost updates and incorrect final values.

The following example demonstrates how volatile fails to ensure atomicity during a compound operation:

public class VolatileVisibilityDemo extends Thread {
    public static volatile int counter = 0;

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            try {
                counter = counter + 1; // Not an atomic operation
                Thread.sleep(2); 
            } catch (InterruptedException ignored) {}
        }
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileVisibilityDemo[] workers = new VolatileVisibilityDemo[50];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = new VolatileVisibilityDemo();
            workers[i].start();
        }
        for (VolatileVisibilityDemo worker : workers) {
            worker.join();
        }
        System.out.println("Final counter value: " + counter); // Output will consistently be less than 1000
    }
}

When running the above code, the final result will almost always be less than the expected 1000, proving that volatile alone is insufficient for compound actions.

To resolve this race condition, we must enforce atomicity using the synchronized keyword. By locking the increment method, we ensure that the read-modify-write cycle completes uninterrupted by other threads:

public class SynchronizedAtomicDemo extends Thread {
    public static int counter = 0;

    public static synchronized void increment() {
        counter++;
    }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            try {
                increment(); // Atomic operation protected by lock
                Thread.sleep(2);
            } catch (InterruptedException ignored) {}
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedAtomicDemo[] workers = new SynchronizedAtomicDemo[50];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = new SynchronizedAtomicDemo();
            workers[i].start();
        }
        for (SynchronizedAtomicDemo worker : workers) {
            worker.join();
        }
        System.out.println("Final counter value: " + counter); // Output will reliably be 1000
    }
}

By applying synchronized to the increment logic, mutual exclusion is guaranteed, and the program consistently yields the correct result of 1000.

Tags: java Concurrency Synchronized volatile thread-safety

Posted on Wed, 08 Jul 2026 17:16:06 +0000 by Averice