Module 9 · Modern Java: Functional Style & Concurrency ⏱ 19 min

Synchronization, Atomicity, Concurrency Hazards

By the end of this lesson you will be able to:
  • Identify a race condition in code that shares mutable state
  • Explain how synchronized protects a critical section
  • Recognise a deadlock scenario and know how to avoid it

Two threads reading the same variable is harmless. Two threads writing the same variable, or one reading while another writes, is where concurrency turns dangerous. The problem is not the operation itself — it is the gap between reading a value and writing it back. Thread A reads count as 5, Thread B reads count as 5 in the same instant, both add one, and both write back 6. The correct result should be 7, but one increment was lost. This is a race condition: the outcome depends on the invisible timing of the scheduler, so it passes tests one day and fails in production the next.

Race conditions are vicious because they are rare. A counter might lose only one update in a million, which makes the bug nearly impossible to reproduce locally and devastating when it finally surfaces under load.

flowchart TD
  subgraph "Thread A"
    A1["read count = 5"] --> A2["add 1 -> 6"] --> A3["write 6"]
  end
  subgraph "Thread B"
    B1["read count = 5"] --> B2["add 1 -> 6"] --> B3["write 6"]
  end
  C["count = 5"] --> A1
  C --> B1
  A3 --> R["count = 6<br/>LOST UPDATE"]
  B3 --> R
  style R fill:#b45309,color:#fff
A race condition: both threads read 5 before either writes, so one increment is lost.

The synchronized keyword

Java's simplest tool for preventing races is synchronized. When a thread enters a synchronized method or block, it acquires the intrinsic lock (also called the monitor) on the specified object. No other thread can enter any synchronized region on that same object until the first thread releases the lock by exiting. This turns the critical section — the code that touches shared state — into a sequential bottleneck, which is exactly what you want when correctness matters more than raw speed.

Every object in Java has an intrinsic lock. A synchronized instance method uses this as its lock; a synchronized static method uses the class object. You can also write synchronized(someObject) { ... } to lock on an arbitrary object, which is useful when multiple independent variables need separate guards.

Java locks are reentrant: if a thread already holds a lock, it can enter another synchronized block on the same object without deadlocking itself. This is essential when one synchronized method calls another on the same object — without reentrancy, every such call would freeze the program.

Main.java — a counter with a synchronized increment method. Real, compiling Java.
public class Main {
    static class Counter {
        private int count = 0;

        synchronized void increment() {
            count = count + 1;
        }

        int getCount() {
            return count;
        }
    }

    public static void main(String[] args) {
        Counter c = new Counter();
        c.increment();
        c.increment();
        System.out.println(c.getCount());
    }
}

Deadlock: when locks fight back

Synchronization prevents races but introduces a new hazard. A deadlock occurs when Thread A holds Lock 1 and waits for Lock 2, while Thread B holds Lock 2 and waits for Lock 1. Both wait forever. The program does not crash — it simply stops making progress. Deadlocks are harder to spot than races because the system looks idle rather than wrong.

The classic cure is lock ordering: always acquire multiple locks in the same global order. If both threads always try to lock Account A before Account B, one thread gets A first, the other waits, and the second lock is always free when the first is released. Another strategy is to avoid holding multiple locks at once — finish with one resource before reaching for another.

flowchart TD
  A["Thread A"] -->|"holds"| L1["Lock 1"]
  A -->|"waits for"| L2["Lock 2"]
  B["Thread B"] -->|"holds"| L2
  B -->|"waits for"| L1
  L1 -.->|"circular wait"| B
  L2 -.->|"circular wait"| A
  style A fill:#b45309,color:#fff
  style B fill:#b45309,color:#fff
Deadlock: Thread A holds Lock 1 and waits for Lock 2; Thread B holds Lock 2 and waits for Lock 1.
Main.java — explicit synchronized blocks protect a transfer between two accounts. Real, compiling Java.
public class Main {
    static class Account {
        private int balance;

        Account(int balance) {
            this.balance = balance; }

        int getBalance() { return balance; }
        void adjust(int delta) { balance += delta; }
    }

    public static void main(String[] args) {
        Account a = new Account(100);
        Account b = new Account(100);

        synchronized (a) {
            synchronized (b) {
                a.adjust(-30);
                b.adjust(30);
            }
        }

        System.out.println(a.getBalance());
        System.out.println(b.getBalance());
    }
}

Atomic variables: synchronization without locking

java.util.concurrent.atomic provides classes like AtomicInteger, AtomicLong, and AtomicReference that offer thread-safe operations without synchronized. Instead of locking the whole object, they use compare-and-swap (CAS) hardware instructions: the CPU checks whether the current value still matches what the thread originally read, and if so, atomically replaces it.

AtomicInteger.incrementAndGet() is faster than a synchronized increment under low contention because no thread is ever blocked. Under high contention, threads spin-retry instead of sleeping, which can waste CPU. For simple counters and flags, atomic classes are the modern default; for complex multi-step updates, synchronized or higher-level constructs like ConcurrentHashMap are still appropriate.

Do not confuse atomic classes with the volatile keyword. volatile guarantees that reads and writes go straight to main memory, preventing one thread from seeing a stale cached value, but it does not make compound operations like i++ atomic. An atomic class gives you both visibility and atomicity; volatile gives you only visibility.

flowchart LR
  R["Read value = 5"] --> C["Compare: still 5?"]
  C -->|"yes"| W["Write 6<br/>success"]
  C -->|"no"| NR["Another thread changed it<br/>re-read and retry"]
  NR --> C
  style W fill:#3776ab,color:#fff
  style NR fill:#c2410c,color:#fff
Compare-and-swap retries until the update succeeds without blocking other threads.

Best practices for safe concurrency

  • Prefer immutability. An immutable object never changes after creation, so many threads can read it safely without any locks. String, Integer, and records are your friends. If you need to change state, build a new object rather than mutating the old one.
  • Favour atomic classes over synchronized for simple counters and flags. They are faster and less error-prone because the JVM handles the hard parts.
  • Keep critical sections tiny. Hold a lock only for the fewest statements possible; never perform I/O, sleep, or call unknown code while holding a lock. The longer you hold a lock, the more you serialise your program and the higher the deadlock risk.
  • Avoid sharing mutable state across threads when possible. Give each thread its own copy, or use thread-local variables. If nothing is shared, nothing can race.
  • Always acquire multiple locks in the same order to prevent deadlock. Document the ordering in comments so future maintainers do not accidentally reverse it.
Exercise

Two unsynchronized threads repeatedly call counter++. What is the likely result?

Exercise

What does the synchronized keyword do when a thread enters a synchronized method?

Exercise

Thread A holds Lock X and waits for Lock Y. Thread B holds Lock Y and waits for Lock X. What is this situation called?

Exercise

Why is AtomicInteger.incrementAndGet() often preferred over a synchronized increment method?

Exercise

Which of the following is the safest approach to sharing data across threads?

Recap

  • A race condition occurs when multiple threads access shared mutable state and at least one writes, with no synchronization.
  • synchronized acquires an object's intrinsic lock, turning a critical section into a sequential bottleneck.
  • Deadlock is circular waiting for locks; prevent it with consistent ordering and short lock duration.
  • Atomic classes use hardware compare-and-swap for lock-free thread safety on simple variables.
  • Immutability is the safest concurrency strategy: if nothing changes, nothing can race.

With these concepts you now understand how Java handles concurrency safely — from raw threads to executors, from locks to lock-free atomics, and from mutable shared state to immutable design.

Checkpoint quiz

What hardware mechanism do AtomicInteger operations use to avoid locks?

What is the best way to prevent deadlock when a thread needs multiple locks?

Go deeper — technical resources