Skip to content
elephantoo

Synchronisation & thread safety

Lesson 36 of 43 18 min read

Race conditions, synchronized, volatile, atomics, locks, concurrent collections and deadlocks.


In the last lesson, threads ran happily in parallel because they didn't share anything. The moment several threads read and write the same data, things can go wrong in ways that are hard to reproduce and debug. This lesson explains why, and shows the tools Java provides to write thread-safe code.

A race condition#

Two threads each increment a shared counter 100,000 times. We expect 200,000:

Race.java
public class Race {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) count++;
        };
        Thread a = new Thread(work);
        Thread b = new Thread(work);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println("Expected 200000, got " + count);
    }
}

A typical run (the number changes each time):

Output
Expected 200000, got 137452

count++ looks like one operation, but it's three: read count, add one, write it back. If both threads read 41 at the same moment, both write 42, and one increment is lost:

Output
Thread A: read 41            add → 42   write 42
Thread B:      read 41  add → 42             write 42      ← an update is lost

A race condition is when correctness depends on the timing of threads. Code that behaves correctly under concurrent use is thread-safe.

Two problems: atomicity and visibility#

  1. Atomicity: a compound action (read-modify-write, check-then-act) must happen as one indivisible step.
  2. Visibility: CPUs cache values and compilers reorder instructions, so a write by one thread may not be seen by another thread, possibly ever, unless there is a happens-before relationship (created by locks, volatile, thread start/join, and so on).

synchronized: mutual exclusion#

Every Java object has an intrinsic lock (monitor). A synchronized block or method lets only one thread at a time hold that lock and run the protected code. It also guarantees visibility: everything written before releasing the lock is visible to the next thread that acquires it.

SyncCounter.java
public class SyncCounter {
    private int count = 0;

    public synchronized void increment() {     // locks on 'this'
        count++;
    }

    public synchronized int get() {            // reads need the lock too, for visibility
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        SyncCounter counter = new SyncCounter();
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) counter.increment();
        };
        Thread a = new Thread(work), b = new Thread(work);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(counter.get());
    }
}
Output
200000

Synchronized blocks let you lock on a specific object and keep the critical section small:

Java
private final Object lock = new Object();
private final List<String> log = new ArrayList<>();

public void record(String msg) {
    String line = LocalTime.now() + " " + msg;   // no lock needed for local work
    synchronized (lock) {                        // only the shared mutation is locked
        log.add(line);
    }
}
  • synchronized instance methods lock on this; static synchronized methods lock on the Class object.
  • Locks are reentrant: a thread already holding a lock can enter other blocks guarded by the same lock.
  • Every access to shared mutable state, reads included, must use the same lock.
  • Keep critical sections short. Never do slow I/O while holding a lock if you can avoid it.

Check-then-act needs locking too#

BankTransfer.java
public class BankTransfer {
    private int balance = 100;

    // Without synchronized, two threads could both pass the check and overdraw
    public synchronized boolean withdraw(int amount) {
        if (balance >= amount) {          // check
            balance -= amount;            // act
            return true;
        }
        return false;
    }

    public synchronized int balance() { return balance; }

    public static void main(String[] args) throws InterruptedException {
        BankTransfer acc = new BankTransfer();
        Thread[] ts = new Thread[10];
        for (int i = 0; i < ts.length; i++) {
            ts[i] = new Thread(() -> acc.withdraw(30));
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        System.out.println("Balance: " + acc.balance());   // never negative
    }
}
Output
Balance: 10

Three withdrawals of 30 succeed; the other seven are refused.

volatile: visibility for simple flags#

A volatile field is always read from and written to main memory, so all threads see the latest value. It's ideal for a stop flag written by one thread and read by another:

VolatileFlag.java
public class VolatileFlag {
    private static volatile boolean running = true;   // try removing volatile...

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long loops = 0;
            while (running) loops++;
            System.out.println("worker saw the flag and stopped");
        });
        worker.start();
        Thread.sleep(100);
        running = false;
        worker.join();
        System.out.println("done");
    }
}
Output
worker saw the flag and stopped
done

Without volatile, the JIT compiler may hoist the read out of the loop, and the worker can spin forever. But volatile does not make count++ atomic. Use it only when each write is independent of the current value.

Atomic variables#

java.util.concurrent.atomic provides lock-free, thread-safe single variables using CPU compare-and-swap instructions:

Atomics.java
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

public class Atomics {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger hits = new AtomicInteger();
        AtomicLong maxSeen = new AtomicLong();
        LongAdder requests = new LongAdder();          // best under heavy contention

        Runnable work = () -> {
            for (int i = 1; i <= 50_000; i++) {
                hits.incrementAndGet();
                requests.increment();
                long value = i;
                maxSeen.accumulateAndGet(value, Math::max);
            }
        };
        Thread a = new Thread(work), b = new Thread(work);
        a.start(); b.start();
        a.join(); b.join();

        System.out.println(hits.get() + " " + requests.sum() + " " + maxSeen.get());
        System.out.println(hits.compareAndSet(100_000, 0) + " " + hits.get());
    }
}
Output
100000 100000 50000
true 0

Common operations: get, set, incrementAndGet, getAndIncrement, addAndGet, compareAndSet(expected, new), updateAndGet(fn). For counters and statistics, atomics are simpler and usually faster than locks.

Explicit locks: ReentrantLock#

java.util.concurrent.locks offers locks with features synchronized lacks: try-with-timeout, interruptible waits, fairness, and separate read/write locks:

Locks.java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class Locks {
    private final ReentrantLock lock = new ReentrantLock();
    private int stock = 5;

    boolean reserve() throws InterruptedException {
        if (lock.tryLock(1, TimeUnit.SECONDS)) {      // give up if busy for >1s
            try {
                if (stock > 0) { stock--; return true; }
                return false;
            } finally {
                lock.unlock();                         // ALWAYS unlock in finally
            }
        }
        return false;
    }

    public static void main(String[] args) throws InterruptedException {
        Locks shop = new Locks();
        int ok = 0;
        for (int i = 0; i < 8; i++) if (shop.reserve()) ok++;
        System.out.println(ok + " reserved, stock left " + shop.stock);
    }
}
Output
5 reserved, stock left 0

The lock() / try / finally { unlock(); } pattern is mandatory. Forget the finally and an exception leaves the lock held forever. ReentrantReadWriteLock allows many concurrent readers but exclusive writers, which is useful for read-heavy caches.

Thread-safe collections#

ArrayList and HashMap are not thread-safe: concurrent modification can corrupt them. Use the concurrent collections instead:

NeedUse
Map shared by many threadsConcurrentHashMap
List read often, rarely changedCopyOnWriteArrayList
Producer/consumer queueArrayBlockingQueue, LinkedBlockingQueue
Sorted concurrent mapConcurrentSkipListMap
ConcurrentCounts.java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentCounts {
    public static void main(String[] args) throws InterruptedException {
        Map<String, Integer> counts = new ConcurrentHashMap<>();
        List<String> words = List.of("java", "lock", "java", "thread", "java", "lock");

        Runnable work = () -> {
            for (int i = 0; i < 1000; i++) {
                for (String w : words) counts.merge(w, 1, Integer::sum);   // atomic per key
            }
        };
        Thread a = new Thread(work), b = new Thread(work);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(new TreeMap<>(counts));
    }
}
Output
{java=6000, lock=4000, thread=2000}

ConcurrentHashMap's merge, compute and putIfAbsent are atomic. But if (!map.containsKey(k)) map.put(k, v) is still a check-then-act race; use putIfAbsent or computeIfAbsent instead.

Producer-consumer with a BlockingQueue

ProducerConsumer.java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ProducerConsumer {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);   // small buffer

        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) queue.put(i);   // blocks when full
                queue.put(-1);                               // "poison pill": no more work
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });

        Thread consumer = new Thread(() -> {
            try {
                int sum = 0;
                while (true) {
                    int item = queue.take();                 // blocks when empty
                    if (item == -1) break;
                    sum += item;
                }
                System.out.println("consumed sum = " + sum);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });

        producer.start(); consumer.start();
        producer.join(); consumer.join();
    }
}
Output
consumed sum = 15

The queue handles all the locking and waiting. This is far simpler and safer than low-level wait()/notify(), which you should rarely need to write yourself.

Deadlock#

A deadlock happens when threads wait for each other's locks forever:

Java
// Thread 1                         // Thread 2
synchronized (accountA) {           synchronized (accountB) {
    synchronized (accountB) {           synchronized (accountA) {   // each waits for the other
        transfer();                         transfer();
    }                                   }
}                                   }

Prevention:

  • Lock ordering: always acquire multiple locks in the same global order (e.g. by account ID).
  • Use tryLock with a timeout and back off.
  • Hold as few locks as possible, for as short a time as possible.
  • Diagnose a hung program with jstack <pid>, which reports detected deadlocks.

Strategies for thread safety (best first)#

  1. Don't share: keep data confined to one thread (local variables are always thread-safe).
  2. Share immutable objects: records, String, List.of(...) need no locking at all.
  3. Use thread-safe library classes: atomics, ConcurrentHashMap, BlockingQueue, executors.
  4. Lock carefully with synchronized or ReentrantLock when you must share mutable state.

Common mistakes#

  • Synchronising writes but not reads.
  • Using different locks to guard the same data.
  • Thinking volatile makes compound operations atomic.
  • Using HashMap/ArrayList from multiple threads.
  • Calling unlock() outside finally.
  • Locking on publicly accessible objects or on String literals, which other code may also lock.

What's next#

Creating and coordinating threads by hand is error-prone. Next, ExecutorService and CompletableFuture let you submit tasks to thread pools, get results back, compose asynchronous pipelines, and use Java 21's virtual threads.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Why is count++ on a shared int not thread-safe?

  2. 2.What does volatile guarantee?

  3. 3.Thread 1 holds lock A and waits for lock B; thread 2 holds lock B and waits for lock A. What is this?

Finished reading?

Mark this lesson complete to track your progress.