Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 19 of 41

Lesson 19 · The JVM and Concurrency

Concurrency Fundamentals: Threads, Races and Synchronisation

Concurrency taught from the problems: lost updates, visibility and deadlock. Threads and the Java Memory Model, race conditions and atomicity, synchronized, volatile, atomic classes and locks, with runnable demonstrations and honest advice on what each tool does and does not guarantee.

On this page

Key takeaways

  • Threads share the heap but have separate stacks. When several threads read and write the same mutable data, results depend on timing, which is a race condition.
  • Three separate problems exist: atomicity (a compound action such as count++ is several steps), visibility (one thread may not see another thread's write) and ordering. Each tool addresses some of them.
  • synchronized provides mutual exclusion and visibility. volatile provides visibility and ordering for a single variable but not atomic compound actions. Atomic classes provide lock-free atomic updates. Locks add features such as timeouts.
  • Deadlock occurs when threads wait for each other's locks in a cycle. Acquire locks in a consistent order, hold them briefly, and prefer higher-level utilities.
  • The safest concurrent code shares as little mutable state as possible: use immutable objects, confine data to one thread, and use the java.util.concurrent tools covered in the next article.

Consider a website counting page views. One thread handles each visitor. The simplest possible code increments a shared counter. On a quiet day, the number is exactly right. On a busy day, the count comes out lower than the number of visitors, by a different amount each time. No exception is thrown and no line of code looks wrong.

This is the signature of a concurrency bug: correct-looking code, wrong results that depend on timing, and failures that are hard to reproduce. This article teaches the model and the tools to avoid them. We start from the problems, because tools make sense only when you can see what they solve.

What you will learn

#
  • What a thread is, and how it differs from a process.
  • Why a race condition happens, using a real demonstration.
  • The three separate problems: atomicity, visibility and ordering.
  • How synchronized, volatile, atomic classes and locks address them.
  • What deadlock is, and how to prevent it.
  • How to design code that avoids shared mutable state.

Processes, threads and why bother

#

A process is a running program with its own memory space. A thread is a path of execution inside a process. Threads in the same process share the heap, and therefore the same objects, but each has its own stack (article 18). That sharing is what makes threads cheap to communicate with, and also what makes them dangerous.

We use threads for two reasons:

  • Responsiveness: keep a user interface or a server responsive while slow work (a network call, a file read) is in progress.
  • Throughput: use several CPU cores at once, or keep the CPU busy while some threads wait for input and output.

Do not confuse two terms: concurrency means several tasks making progress over overlapping periods (possibly interleaved on one core), and parallelism means tasks actually running at the same instant on different cores. Concurrent code must be correct either way.

The price is complexity. The operating system decides when each thread runs, so the order of operations between threads is not deterministic. Your program must be correct for every possible interleaving.

Creating threads

#
Java
// File: Main.javapublic class Main {    public static void main(String[] args) throws InterruptedException {        Runnable job = () -> System.out.println("running on: " + Thread.currentThread().getName());        Thread direct = new Thread(job, "worker-1");        direct.run();                          // a plain method call: runs on the main thread!        Thread started = new Thread(job, "worker-2");        started.start();                       // starts a new thread that runs the job        started.join();                        // wait for it to finish        System.out.println("done on: " + Thread.currentThread().getName());    }}
Code
running on: mainrunning on: worker-2done on: main

The classic mistake is calling run() instead of start(): it executes the code on the current thread, with no concurrency at all. start() asks the JVM to create a new thread, and join() makes the caller wait until that thread has finished. Note that join also matters for correctness, not only for waiting: the Java Memory Model guarantees that everything a thread did is visible to a thread that returns from join() on it. In real programs you will rarely create threads by hand. Article 20 shows executors, which manage threads for you. We use raw threads here because they make the problems visible.

Problem 1: the race condition (atomicity)

#

Here is the page-view counter. Four threads each increment a shared counter half a million times. The correct total is exactly 2,000,000.

Java
// File: Main.javapublic class Main {    static class UnsafeCounter {        private int count = 0;        void increment() {            count++;                       // looks like one step. It is three.        }        int get() {            return count;        }    }    public static void main(String[] args) throws InterruptedException {        UnsafeCounter counter = new UnsafeCounter();        int threads = 4;        int perThread = 500_000;        Thread[] workers = new Thread[threads];        for (int i = 0; i < threads; i++) {            workers[i] = new Thread(() -> {                for (int j = 0; j < perThread; j++) {                    counter.increment();                }            });            workers[i].start();        }        for (Thread worker : workers) {            worker.join();        }        int expected = threads * perThread;        System.out.println("expected: " + expected);        System.out.println("updates were lost: " + (counter.get() < expected));    }}
Code
expected: 2000000updates were lost: true

The program reports that updates were lost. (The exact total differs on every run, so we print only the comparison. The loss is overwhelmingly likely with this much work, but it is a matter of timing and not a guarantee, which is itself part of the lesson.) What went wrong?

count++ is three separate operations: read the current value, add one, write the result back. Suppose the counter holds 5 and two threads execute count++ at the same moment:

Code
Thread A: reads 5Thread B: reads 5Thread A: writes 6Thread B: writes 6          (should have been 7: one increment is lost)

This is a race condition: the result depends on the relative timing of threads. The specific kind here is a read-modify-write race, a violation of atomicity: an operation that must happen as one indivisible step was interleaved with another. The shared mutable state (the count field) is the root cause. With no sharing or no mutation, there is no race.

Fix 1: synchronized

#

Every Java object has an intrinsic lock (a monitor). A synchronized method or block must acquire the lock before running, and releases it on exit. Only one thread can hold a given lock at a time, so synchronized code on the same lock cannot interleave. Oracle's tutorial puts it this way: it is not possible for two invocations of synchronized methods on the same object to interleave, and when a synchronized method exits it establishes a happens-before relationship with subsequent invocations, so the state changes are visible to all threads.

Fix 2: atomic classes

#

For simple counters, java.util.concurrent.atomic provides classes such as AtomicInteger and AtomicLong whose operations (incrementAndGet, addAndGet, compareAndSet) are atomic without taking a lock, using special CPU instructions. LongAdder is even better under heavy contention when you only need a total.

Fix 3: explicit locks

#

java.util.concurrent.locks.ReentrantLock gives the same exclusion as synchronized with extra abilities (timed and interruptible lock attempts, fairness, multiple conditions). You must release it in a finally block.

Java
// File: Main.javaimport java.util.concurrent.atomic.AtomicInteger;import java.util.concurrent.atomic.LongAdder;import java.util.concurrent.locks.ReentrantLock;public class Main {    interface Counter {        void increment();        long get();    }    static class SynchronizedCounter implements Counter {        private long count;        public synchronized void increment() {            count++;        }        public synchronized long get() {            return count;        }    }    static class AtomicCounter implements Counter {        private final AtomicInteger count = new AtomicInteger();        public void increment() {            count.incrementAndGet();        }        public long get() {            return count.get();        }    }    static class LockCounter implements Counter {        private final ReentrantLock lock = new ReentrantLock();        private long count;        public void increment() {            lock.lock();            try {                count++;            } finally {                lock.unlock();                 // always release in finally            }        }        public long get() {            lock.lock();            try {                return count;            } finally {                lock.unlock();            }        }    }    static class AdderCounter implements Counter {        private final LongAdder count = new LongAdder();        public void increment() {            count.increment();        }        public long get() {            return count.sum();        }    }    static long run(Counter counter) throws InterruptedException {        Thread[] workers = new Thread[4];        for (int i = 0; i < workers.length; i++) {            workers[i] = new Thread(() -> {                for (int j = 0; j < 500_000; j++) {                    counter.increment();                }            });            workers[i].start();        }        for (Thread worker : workers) {            worker.join();        }        return counter.get();    }    public static void main(String[] args) throws InterruptedException {        System.out.println("synchronized: " + run(new SynchronizedCounter()));        System.out.println("atomic:       " + run(new AtomicCounter()));        System.out.println("lock:         " + run(new LockCounter()));        System.out.println("adder:        " + run(new AdderCounter()));    }}
Code
synchronized: 2000000atomic:       2000000lock:         2000000adder:        2000000

All four are exactly right, every time. When to choose:

Tool

Best for

Notes

synchronized

General mutual exclusion over a block of code or several fields

Simple, built in, automatically released, even on exceptions

AtomicInteger / AtomicLong / AtomicReference

A single variable updated atomically

Lock-free; use compareAndSet for custom updates

LongAdder

A hot counter or sum updated by many threads

Faster under contention; slightly more memory; sum() is not a single instant snapshot

ReentrantLock

When you need tryLock, timeouts, interruptible locking or several conditions

You must unlock() in finally

A guideline for synchronized: lock on a private object or on this deliberately, keep the critical section small, and never call unknown code (callbacks, overridable methods) while holding a lock. Holding a lock while calling code you do not control is a common cause of deadlock.

Problem 2: visibility

#

There is a second, more subtle problem. Modern CPUs keep recently used data in caches and registers, and compilers reorder instructions for speed. Without synchronisation, one thread's write to a variable may not be visible to another thread, perhaps never, and writes can appear to happen in a different order. The Java Memory Model (JMM), defined in Chapter 17 of the Java Language Specification, says that these semantics do not prescribe how a multithreaded program should be executed but describe the behaviours it is allowed to exhibit. The rule that gives you safety is the happens-before relationship. When one action happens-before another, the first is guaranteed to be visible to, and ordered before, the second. The specification lists several ways to establish it:

  • Monitors: an unlock of a monitor happens-before every later lock of the same monitor (this is why synchronized gives visibility).
  • Volatile variables: a write to a volatile field happens-before every subsequent read of that field.
  • Thread lifecycle: start() happens-before everything in the started thread, and everything in a thread happens-before another thread's successful return from join() on it.
  • Final fields: a thread that sees a reference only after an object is fully constructed sees its final fields correctly initialised, which is part of why immutable objects are safe to share.

If two threads access the same variable, at least one of them writes, and there is no happens-before relationship between the accesses, you have a data race, and the behaviour is unpredictable. The specification gives an important reassurance: if a program is correctly synchronised, all executions appear to be sequentially consistent, and you do not have to reason about reordering.

volatile

#

A field declared volatile guarantees that writes are visible to other threads and that reads see the latest write, and it prevents certain reorderings around it. The classic use is a stop flag:

Java
// File: Main.javapublic class Main {    static class Worker implements Runnable {        private volatile boolean running = true;     // without volatile, the loop might never see the change        private long iterations;        @Override        public void run() {            while (running) {                iterations++;            }        }        void stop() {            running = false;        }    }    public static void main(String[] args) throws InterruptedException {        Worker worker = new Worker();        Thread thread = new Thread(worker);        thread.start();        Thread.sleep(100);        worker.stop();        thread.join();        System.out.println("worker stopped cleanly");    }}
Code
worker stopped cleanly

With the field declared volatile, the worker is guaranteed to see running = false and stop. Without volatile, the compiler is allowed to assume that no other thread changes running and to read it once outside the loop, so the thread may loop forever. I have not run the broken version, because the failure is not reliable (it depends on the JIT compiler and the hardware), and a demonstration that sometimes works would teach the wrong lesson. This is exactly why visibility bugs are so nasty: a program can appear to work in testing and fail in production.

volatile is not atomicity

#

volatile makes each individual read and write visible, but it does not make count++ atomic. A volatile int counter incremented by four threads loses updates just like the plain one. Use volatile for flags and for publishing a reference to an immutable object, and use atomics or locks for compound updates. A summary of what each tool gives you:

Tool

Mutual exclusion (atomic compound actions)

Visibility

Ordering

Plain field

No

No

No

volatile

No

Yes

Yes (for that variable)

synchronized

Yes

Yes

Yes

Atomic* classes

Yes, for their own operations

Yes

Yes

ReentrantLock

Yes

Yes

Yes

(One more specification detail: a non-volatile long or double may be written as two separate 32-bit halves, so on some platforms another thread could see half of an old value and half of a new one. Writes to references are always atomic.)

Problem 3: deadlock

#

Locks solve races and create a new risk. A deadlock happens when threads wait for each other forever: thread A holds lock 1 and wants lock 2, while thread B holds lock 2 and wants lock 1.

Java
// File: Main.javaimport java.lang.management.ManagementFactory;import java.lang.management.ThreadMXBean;import java.util.concurrent.CountDownLatch;public class Main {    static final Object lockA = new Object();    static final Object lockB = new Object();    static Thread worker(String name, Object first, Object second, CountDownLatch bothHoldFirst) {        Thread t = new Thread(() -> {            synchronized (first) {                bothHoldFirst.countDown();                try {                    bothHoldFirst.await();               // wait until the other thread holds its first lock                } catch (InterruptedException e) {                    return;                }                synchronized (second) {                  // each thread now waits for the other's lock                    System.out.println(name + " finished");                }            }        }, name);        t.setDaemon(true);                               // so this demonstration can exit        t.start();        return t;    }    public static void main(String[] args) throws InterruptedException {        CountDownLatch gate = new CountDownLatch(2);        worker("thread-1", lockA, lockB, gate);          // takes A then B        worker("thread-2", lockB, lockA, gate);          // takes B then A: the opposite order        Thread.sleep(300);        ThreadMXBean bean = ManagementFactory.getThreadMXBean();        long[] deadlocked = bean.findDeadlockedThreads();        System.out.println("deadlocked threads: " + (deadlocked == null ? 0 : deadlocked.length));    }}
Code
deadlocked threads: 2

The latch makes sure that both threads hold their first lock before either asks for the second, which makes the deadlock certain, so that the demonstration is repeatable. In real programs the timing is accidental, and a deadlock may occur once a month. The JVM can detect deadlocks (as findDeadlockedThreads does, and as a thread dump shows), but it cannot fix them: the threads simply stay stuck.

Preventing deadlock:

  • Acquire locks in a consistent global order. If both threads above took lockA first and then lockB, no cycle could form.
  • Hold locks briefly and do not call out to other code while holding one.
  • Use timeouts: ReentrantLock.tryLock(timeout, unit) lets a thread back off instead of waiting forever.
  • Prefer fewer locks, or none, by avoiding shared mutable state.
  • Prefer higher-level tools that handle coordination for you, as in the next article.

Two related problems are worth knowing by name: livelock (threads keep reacting to each other and make no progress) and starvation (a thread never gets a turn).

Stopping a thread: interruption

#

You cannot safely force a thread to stop. Java's mechanism is cooperative cancellation: you call thread.interrupt() to set the thread's interrupted flag, and the thread should notice it (blocking methods such as sleep, wait and join throw InterruptedException when interrupted) and finish cleanly. When you catch InterruptedException, either let it propagate or restore the flag with Thread.currentThread().interrupt(), and do not swallow it silently.

Java
// File: Main.javapublic class Main {    public static void main(String[] args) throws InterruptedException {        Thread sleeper = new Thread(() -> {            try {                Thread.sleep(10_000);                System.out.println("woke up normally");            } catch (InterruptedException e) {                System.out.println("interrupted: cleaning up and exiting");                Thread.currentThread().interrupt();          // restore the flag            }        });        sleeper.start();        Thread.sleep(100);        sleeper.interrupt();        sleeper.join();        System.out.println("flag after exit: " + sleeper.isInterrupted());    }}
Code
interrupted: cleaning up and exitingflag after exit: true

Designing code that avoids these problems

#

The safest technique is not sharing mutable state:

  1. Immutability. An immutable object can be shared freely, as article 7 discussed. The specification's guarantee for final fields helps here.
  2. Thread confinement. Keep data within a single thread. Local variables are confined automatically, since every thread has its own stack. ThreadLocal gives each thread its own copy of a variable (use it with care in pooled threads, where values can leak between tasks).
  3. Stateless services. An object with no fields that change (such as a Spring service that only uses its injected dependencies) is thread-safe by construction. This is how web applications scale.
  4. Use the right abstraction. Let a concurrent collection, an executor or a message queue manage the sharing (article 20).
  5. When you must share mutable state, guard all access to it with the same lock, document which lock guards what, and keep the shared state small.

Legacy mechanisms you will meet: wait() and notify() (the low-level way for threads to signal each other, which is error-prone and replaced by java.util.concurrent tools), the Hashtable and Vector classes (synchronised on every method, and still not enough for compound operations), and Collections.synchronizedList.

Common mistakes

#
  • Calling run() instead of start().
  • Assuming count++ is atomic, or that volatile makes it atomic.
  • Synchronising on different objects for the same data, which provides no protection.
  • Synchronising some accesses but not others. All reads and writes of shared state need the same lock.
  • Locking on a public object or a string literal, which other code can lock too.
  • Forgetting unlock() in finally.
  • Acquiring locks in inconsistent orders.
  • Swallowing InterruptedException.
  • Using Thread.sleep() to "wait for the other thread" instead of join or proper coordination.
  • Testing concurrent code by running it once and seeing no failure.

Exercises

#

Exercise 1

Explain the race

#

Two threads each run balance = balance + 100 on a shared int balance that starts at 0. Give an interleaving that ends with 100 instead of 200.

Exercise 2: Pick the tool

#

Choose volatile, AtomicInteger, synchronized or an immutable object for each: (a) a flag telling a worker thread to stop, (b) a counter of processed requests, (c) a method that moves money between two fields so that the total is always consistent, (d) a configuration object that is rebuilt and replaced as a whole.

Exercise 3: Find the bug

#

public class Cache { private Map<String, String> data = new HashMap<>(); public void put(String k, String v) { synchronized (this) { data.put(k, v); } } public String get(String k) { return data.get(k); } }. What is wrong?

Exercise 4: Prevent the deadlock

#

Two methods transfer money between accounts: transfer(a, b) locks a then b, and a call transfer(b, a) happens concurrently. Explain the deadlock risk and fix it with a consistent lock order.

Exercise 5: Design

#

You need a thread-safe class that counts requests per user. Describe two designs, one with locking and one with a concurrent or atomic structure, and say which you prefer.

Show solutionHide solution

Both threads read balance (0) before either writes. Thread A computes 100 and writes 100. Thread B, which read 0, computes 100 and writes 100. The final value is 100, and one deposit is lost. The cause is that read, add and write are separate steps, so another thread can read in between: an atomicity violation.

Exercise 2

Show solutionHide solution

(a) volatile boolean: a single flag needing visibility, with no compound action. (b) AtomicInteger (or LongAdder): an atomic increment of one variable. (c) synchronized (or a lock): two fields must change together as one atomic action, which a single atomic variable cannot provide. (d) An immutable configuration object whose reference is held in a volatile field (or an AtomicReference): replace the whole object atomically, and readers always see a consistent snapshot.

Exercise 3

Show solutionHide solution

put is synchronised but get is not. The unsynchronised read can run while another thread is modifying the HashMap, so it can see stale data or an inconsistent internal state (a HashMap is not safe for concurrent modification). All accesses to shared mutable state must use the same lock, so get must also synchronise on this. A simpler fix is ConcurrentHashMap, which is designed for this (article 20).

Exercise 4

Show solutionHide solution

Thread 1 locks a and waits for b, while thread 2 locks b and waits for a: a cycle, so both wait forever. Fix it by always locking in the same order, for example by account id: Account first = a.id() < b.id() ? a : b; Account second = first == a ? b : a; synchronized (first) { synchronized (second) { ... } }. Whichever order the arguments come in, both threads lock the lower id first, so no cycle can form (handle the case where both ids are equal).

Exercise 5

Show solutionHide solution

Locking design: a HashMap<String, Integer> guarded by synchronized methods, simple but all threads contend on one lock. Concurrent design: a ConcurrentHashMap<String, LongAdder> or ConcurrentHashMap<String, AtomicLong>, using computeIfAbsent(user, k -> new LongAdder()).increment(). The second is usually preferable: it needs no explicit locking, scales better with many users, and is shorter. The first is acceptable at low volume or when other invariants require one lock.

Where to go next

#

Sources & further reading

  1. docs.oracle.com
  2. docs.oracle.com
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java