Java synchronized and volatile Explained with Examples

Introduction When several threads share data, two kinds of things go wrong. Threads can interleave in the middle of an operation and corrupt a value - a race condition. And one thread may simply not see a change another thread made - a visibility problem. Java's two most basic tools against these are the synchronized and volatile keywords. This tutorial explains synchronized and volatile in Java with examples. We will start by creating a race condition and seeing it lose updates. We will then fix it with synchronized methods and synchronized blocks, and understand the locks behind them. Next we will see a visibility problem, and fix it with volatile. Lastly, we will compare the two, look at atomic classes as an alternative, and go through common mistakes.

(Note - Creating and starting threads, and the states a thread goes through, are covered in Java Multithreading BasicsClick to read tutorial on creating and running threads and Thread Life Cycle and Thread StatesClick to read tutorial on thread states.) A race condition - lost updates count++ looks like one step, but it is really three: read the value, add one, write it back. If two threads read the same value before either writes, both write the same result, and one increment is lost. Four threads each incrementing a shared counter 100,000 times should give 400,000 -
Four threads incrementing an unsynchronized counter
//RaceCondition.java
public class RaceCondition {
  static int count = 0;

  public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[4];
    for (int t = 0; t < threads.length; t++) {
      threads[t] = new Thread(() -> {
        for (int i = 0; i < 100_000; i++) {
          count++;                          // read, add, write - not atomic
        }
      });
      threads[t].start();
    }
    for (Thread t : threads) {
      t.join();                             // wait for every thread to finish
    }
    System.out.println("Expected 400000, got " + count);
  }
}
 OUTPUT of the above code (the result differs on every run)
Expected 400000, got 109381
More than half of the updates were lost in this run, and the result changes from run to run. Race conditions are dangerous precisely because they are intermittent: a program can pass every test and still fail under real load. synchronized methods Declaring a method synchronized makes it a critical section: only one thread at a time can run it on the same object. Every object has a built-in lock, the monitor. A thread entering a synchronized instance method acquires the lock of this, and releases it when the method returns - even by an exception. Other threads trying to enter wait until the lock is free. A static synchronized method locks the Class object instead.
Fixing the race with synchronized methods
//SynchronizedCounter.java
public class SynchronizedCounter {
  private int count = 0;

  public synchronized void increment() {   // one thread at a time per object
    count++;
  }

  public synchronized int get() {          // reads synchronized too, so they see the latest value
    return count;
  }

  public static void main(String[] args) throws InterruptedException {
    SynchronizedCounter counter = new SynchronizedCounter();
    Thread[] threads = new Thread[4];
    for (int t = 0; t < threads.length; t++) {
      threads[t] = new Thread(() -> {
        for (int i = 0; i < 100_000; i++) counter.increment();
      });
      threads[t].start();
    }
    for (Thread t : threads) t.join();
    System.out.println("Expected 400000, got " + counter.get());
  }
}
 OUTPUT of the above code
Expected 400000, got 400000
Every increment counted. synchronized gives two guarantees: mutual exclusion - only one thread in the critical section - and visibility - when a thread releases a lock, its changes become visible to the next thread that acquires the same lock. That is why get() is synchronized as well.
synchronized blocks A synchronized block locks only part of a method, on an object you choose. Keeping critical sections short means threads wait less. Locking on a dedicated private object, rather than on this, also stops outside code from interfering with your lock.
A synchronized block on a private lock
//BankTransfers.java
import java.util.ArrayList;
import java.util.List;

public class BankTransfers {
  private final Object lock = new Object();         // a private lock object
  private double balance = 1000;
  private final List<String> audit = new ArrayList<>();

  void deposit(String who, double amount) {
    String entry = who + " +" + amount;              // preparing the entry needs no lock
    synchronized (lock) {                            // only the shared state is locked
      balance += amount;
      audit.add(entry);
    }
  }

  public static void main(String[] args) throws InterruptedException {
    BankTransfers account = new BankTransfers();
    List<Thread> threads = new ArrayList<>();
    for (String who : new String[] {"Tom", "Harry", "Nancy"}) {
      Thread t = new Thread(() -> {
        for (int i = 0; i < 1000; i++) account.deposit(who, 1);
      });
      threads.add(t);
      t.start();
    }
    for (Thread t : threads) t.join();
    synchronized (account.lock) {
      System.out.println("Balance: " + account.balance + ", audit entries: " + account.audit.size());
    }
  }
}
 OUTPUT of the above code
Balance: 4000.0, audit entries: 3000
The balance and the audit list are updated together inside one block, so no thread can ever see one updated without the other. ArrayList is not thread-safe on its own; here every access to it is guarded by the same lock. A visibility problem - and volatile For speed, each CPU core caches values, and the JIT compiler may keep a field in a register or move a read out of a loop. Without synchronization, a thread may never see a value another thread wrote. Here, a worker loops until a running flag becomes false, and the main thread clears the flag after 100 ms.
A stop flag without volatile
//VisibilityProblem.java
public class VisibilityProblem {
  static boolean running = true;                    // NOT volatile

  public static void main(String[] args) throws InterruptedException {
    Thread worker = new Thread(() -> {
      long spins = 0;
      while (running) {
        spins++;
      }
      System.out.println("Worker stopped after " + spins + " spins");
    });
    worker.start();
    Thread.sleep(100);
    running = false;
    System.out.println("main set running = false");
    worker.join(2000);                               // wait up to 2 seconds
    System.out.println("Worker still running after 2 seconds? " + worker.isAlive());
    System.exit(0);                                  // end the program even if the worker never stops
  }
}
 OUTPUT of the above code (this behaviour is not guaranteed either way)
main set running = false
Worker still running after 2 seconds? true
The worker never noticed the change: once its loop was compiled, the JIT read running once and kept using that value. Nothing in the Java Memory Model required it to read the field again. Declaring the field volatile fixes this -
The same stop flag with volatile
//VolatileFlag.java
public class VolatileFlag {
  static volatile boolean running = true;           // every read sees the latest write

  public static void main(String[] args) throws InterruptedException {
    Thread worker = new Thread(() -> {
      long spins = 0;
      while (running) {
        spins++;
      }
      System.out.println("Worker stopped after " + (spins > 0 ? "many" : "no") + " spins");
    });
    worker.start();
    Thread.sleep(100);
    running = false;
    System.out.println("main set running = false");
    worker.join(2000);
    System.out.println("Worker still running after 2 seconds? " + worker.isAlive());
  }
}
 OUTPUT of the above code
main set running = false
Worker stopped after many spins
Worker still running after 2 seconds? false
A write to a volatile field is immediately visible to every thread that reads it afterwards, and reads of it cannot be cached or moved out of a loop.
volatile does not make count++ safe volatile guarantees visibility, but not atomicity. count++ on a volatile int is still three steps, and still loses updates. For counters and similar read-modify-write operations, use synchronized, or the atomic classes in java.util.concurrent.atomic, which perform such operations atomically without locks.
volatile vs AtomicInteger for a counter
//VolatileVsAtomic.java
import java.util.concurrent.atomic.AtomicInteger;

public class VolatileVsAtomic {
  static volatile int volatileCount = 0;
  static AtomicInteger atomicCount = new AtomicInteger();

  public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[4];
    for (int t = 0; t < threads.length; t++) {
      threads[t] = new Thread(() -> {
        for (int i = 0; i < 100_000; i++) {
          volatileCount++;                        // visible, but not atomic
          atomicCount.incrementAndGet();          // atomic
        }
      });
      threads[t].start();
    }
    for (Thread t : threads) t.join();
    System.out.println("volatile int  : " + volatileCount);
    System.out.println("AtomicInteger : " + atomicCount.get());
  }
}
 OUTPUT of the above code (the volatile result differs on every run)
volatile int  : 228399
AtomicInteger : 400000
synchronized vs volatile
synchronizedvolatile
Applies toMethods and blocksFields only
VisibilityYesYes
Atomicity of compound actionsYes - the whole block runs aloneNo - only single reads and writes
BlockingThreads wait for the lockNever blocks
Typical useUpdating shared state, keeping several values consistentFlags and status values written by one thread and read by others
Common mistakes
  • Synchronizing on different locks: two synchronized methods on different objects do not exclude each other. All code touching the same data must use the same lock.
  • Unsynchronized reads: synchronizing only the writes is not enough - readers may see stale values.
  • Locking too much: doing slow work - I/O, network calls - inside a synchronized block makes every other thread wait. Keep critical sections short.
  • Deadlock: if one thread holds lock A and waits for B, while another holds B and waits for A, both wait forever. Always acquire multiple locks in the same order.
  • Reinventing tools: for most needs, the java.util.concurrent package - atomic classes, ConcurrentHashMap, locks, and ExecutorServiceClick to read tutorial: Java ExecutorService and Thread Pools: An Introduction - is safer than hand-written synchronization.
Note - Since Java 24, synchronized also works well with virtual threads - see Virtual Threads and synchronized without Pinning in Java 24Click to read tutorial on synchronized without pinning in Java 24. Summary In this tutorial we first created a race condition, in which four threads lost a large share of their increments, because count++ is not atomic. We then fixed it with synchronized methods, and used a synchronized block on a private lock to keep two values consistent, learning that synchronized gives both mutual exclusion and visibility. Next we saw a thread that never noticed a stop flag change, and fixed it with volatile. We then saw that volatile does not make count++ safe, while AtomicInteger does. Lastly, we compared the two keywords and went through common mistakes. In the next tutorial, we will stop creating threads by hand and use ExecutorService and thread poolsClick to read tutorial: Java ExecutorService and Thread Pools: An Introduction.

Java synchronized and volatile Explained with Examples - JavaBrahman