Engineering Notes
Home/Languages/Java/Concurrency Basics
Languages · Java

Concurrency Basics

A Thread is an independent path of execution within a process, sharing the same heap memory as every other thread in that JVM. Two ways to define what a thread runs:

💻 Languages● Advanced⏱ 7 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Threads: the Unit of Concurrent Execution
  • Race Conditions: Why Shared Mutable State Is the Problem
  • synchronized: Mutual Exclusion
  • Visibility: Why volatile Exists
  • ExecutorService: Thread Pools Instead of Manual Thread Management

1Threads: the Unit of Concurrent Execution

A Thread is an independent path of execution within a process, sharing the same heap memory as every other thread in that JVM. Two ways to define what a thread runs:

java
// implementing Runnable — preferred, doesn't burn your one shot at extending a class
Runnable task = () -> System.out.println("running on: " + Thread.currentThread().getName());
Thread t = new Thread(task);
t.start();      // schedules the thread to run — NOT the same as calling task.run() directly
t.join();       // blocks the CALLING thread until t finishes

// extending Thread directly — works, but couples your class to being a Thread
class Worker extends Thread {
    public void run() { System.out.println("worker running"); }
}
new Worker().start();

Calling .run() directly executes the code synchronously on the current thread — no new thread is created. Only .start() actually schedules a new OS-backed thread.

2Race Conditions: Why Shared Mutable State Is the Problem

A race condition happens when multiple threads read-modify-write shared state without coordination, and the outcome depends on unpredictable timing.

java
class Counter {
    private int count = 0;
    void increment() { count++; } // NOT atomic — read, add 1, write back
    int get() { return count; }
}

count++ is really three separate steps (read count, compute count + 1, write it back). If two threads interleave those steps, one increment can be lost entirely — thread A reads 5, thread B reads 5 before A writes back, both compute 6, both write 6, and one increment vanishes. Running this with enough threads and iterations reliably produces a final count less than the true number of increments performed.

3synchronized: Mutual Exclusion

synchronized ensures only one thread at a time can execute a given block/method for a given lock object, fixing the race above:

java
class Counter {
    private int count = 0;
    synchronized void increment() { count++; } // lock is `this` — only one thread inside at a time
    synchronized int get() { return count; }
}
java
class Counter {
    private final Object lock = new Object();
    private int count = 0;
    void increment() {
        synchronized (lock) { count++; } // synchronized block — lock on an explicit object
    }
}

A synchronized instance method locks on this; a synchronized static method locks on the Class object; a synchronized (obj) { ... } block locks on whatever object you name. Only one thread can hold a given lock at a time — any other thread calling a method synchronized on the same lock blocks until the lock is released.

4Visibility: Why volatile Exists

Mutual exclusion (synchronized) solves atomicity. A separate problem is visibility: without synchronization, one thread's write to a shared variable is not guaranteed to ever become visible to another thread reading it, because each thread/CPU core may cache the variable rather than re-reading main memory every time.

java
class Flag {
    private boolean running = true; // no volatile: another thread's write to `running`
                                      // might never become visible to a thread spinning here
    void stop() { running = false; }
    void loopUntilStopped() {
        while (running) { /* busy loop */ } // can spin FOREVER on a stale cached `true`
    }
}
java
class Flag {
    private volatile boolean running = true; // volatile: every read sees the latest write,
                                               // every write is immediately visible to other threads
    void stop() { running = false; }
    void loopUntilStopped() {
        while (running) { /* now guaranteed to observe stop() eventually */ }
    }
}

volatile guarantees visibility and ordering for reads/writes of that single variable, but not atomicity for compound operations — volatile int count; count++; is still a race, because ++ is still three separate steps even though each individual read and write is now visible. synchronized (or java.util.concurrent.atomic classes) is still required for compound read-modify-write operations.

5ExecutorService: Thread Pools Instead of Manual Thread Management

Creating a raw Thread per task doesn't scale — OS threads are expensive to create and there's no bound on how many pile up. ExecutorService manages a reusable pool of worker threads that tasks are submitted to:

java
java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(4);

pool.submit(() -> System.out.println("task on a pooled thread"));

pool.shutdown(); // stop accepting new tasks; lets already-submitted tasks finish

Submitted tasks are queued and run by whichever pooled thread becomes free — you never manage Thread objects directly, and the pool size bounds how much concurrent work actually happens regardless of how many tasks are submitted.

6Future: Getting a Result Back From an Async Task

.submit() on an ExecutorService returns a Future<T> representing a result that isn't ready yet:

java
java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(2);

java.util.concurrent.Future<Integer> future = pool.submit(() -> {
    Thread.sleep(100);
    return 42;
});

System.out.println("submitted, doing other work...");
int result = future.get(); // BLOCKS the calling thread until the task finishes, then returns 42
pool.shutdown();

.get() blocks the caller until the task completes (or throws, wrapped as ExecutionException, if the task itself threw); an overload .get(timeout, unit) bounds how long the caller will wait before giving up with a TimeoutException.

Under the HoodHow it works internally▾

Threads are backed by real OS threads, and the JVM memory model is what volatile/synchronized actually control. Each Java Thread maps to a native OS thread (on the HotSpot JVM), each with its own stack but sharing the process's heap. Because modern CPUs have per-core caches and compilers/CPUs are allowed to reorder instructions for performance as long as single-threaded behavior looks unchanged, a plain (non-volatile, non-synchronized) write on one thread has no guaranteed point at which another thread must observe it — the Java Memory Model (JMM) formally defines exactly which operations establish a happens-before relationship between threads. synchronized (both entering and exiting a monitor) and volatile reads/writes are two of the JMM's happens-before-establishing actions: exiting a synchronized block happens-before a subsequent thread entering a block synchronized on the same lock, and a volatile write happens-before any subsequent volatile read of the same variable. Without one of these, the JMM makes literally no visibility guarantee at all — not "probably fine," genuinely undefined, which is why the Flag example without volatile can spin forever on real hardware, not just in theory.

synchronized is implemented via the object's intrinsic monitor, with biased/lightweight/heavyweight locking as a performance ladder. Every Java object has an associated monitor (historically tracked via bits in the object header). Uncontended locking — the extremely common case where one thread repeatedly locks/unlocks an object nobody else is touching — is handled by the JVM with cheap, sometimes lock-free bookkeeping (biased or thin/lightweight locking, implementation-dependent by JVM version); only genuine contention, where a second thread actually tries to acquire a lock another thread holds, triggers an expensive "heavyweight" OS-level mutex/monitor with real thread blocking and a wait queue. This is why synchronized code with little real contention is often much cheaper than people assume, and why contended locks — not the mere presence of synchronized — are the actual performance concern.

One thread's write can sit in its core's cache while another thread's JIT-hoisted read never sees it; volatile and synchronized establish happens-before edges that guarantee visibility
One thread's write can sit in its core's cache while another thread's JIT-hoisted read never sees it; volatile and synchronized establish happens-before edges that guarantee visibility

⚠️ Common Pitfalls

  1. Calling .run() instead of .start() — executes the task synchronously on the current thread; no concurrency happens at all, and bugs from this are easy to miss because the code still "works," just sequentially.
  2. Assuming volatile makes compound operations (count++, if (x) x = false;) atomic — it only guarantees visibility/ordering for single reads/writes; compound operations still need synchronized or an AtomicInteger/AtomicBoolean.
  3. Forgetting pool.shutdown() — an ExecutorService's threads are non-daemon by default, so a program that creates a thread pool and never shuts it down will hang on exit instead of terminating.
  4. Synchronizing on different lock objects for the same shared state — synchronized(new Object()) inside a method creates a fresh lock every call, providing zero actual mutual exclusion; the lock object must be the same instance across every thread/call site protecting that state.
  5. Calling future.get() immediately after submit() with no other work in between — defeats the purpose of submitting asynchronously; if the calling thread has nothing else to do until the result arrives, an async Future bought nothing over just calling the task directly.

📋 Quick Reference

java
new Thread(runnable).start();     // schedule on a new OS-backed thread
t.join();                          // block caller until t finishes

synchronized void m() { }           // lock on `this`
synchronized (obj) { }               // lock on an explicit object
volatile boolean flag;                // visibility + ordering, NOT atomicity, for one variable

ExecutorService pool = Executors.newFixedThreadPool(n);
Future<T> f = pool.submit(callable); // async task -> result-in-the-future handle
T result = f.get();                   // BLOCKS until the task completes
pool.shutdown();                       // stop accepting new tasks

Race condition: unsynchronized read-modify-write on shared state -> lost updates
happens-before: what the JMM actually guarantees; synchronized/volatile establish it

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How do you run tasks concurrently in Java?core▾

Executors (ExecutorService thread pools), CompletableFuture for async composition, and in Java 21 virtual threads (Executors.newVirtualThreadPerTaskExecutor()), cheap threads for massive I/O concurrency.

Follow-ups interviewers ask
  • Why not new Thread() everywhere? Unbounded thread creation and no lifecycle management.
Q2What does synchronized do and what's volatile?core▾

synchronized provides mutual exclusion and memory visibility (happens-before) for a monitor; volatile guarantees visibility and ordering of a single variable's reads/writes but not atomicity of compound actions.

Follow-ups interviewers ask
  • Atomic counter? AtomicInteger/LongAdder.
Q3What is the Java Memory Model's happens-before?senior▾

Rules guaranteeing that one action's effects are visible to another: unlock before subsequent lock, volatile write before read, thread start/join, and executor submission/completion.

Follow-ups interviewers ask
  • Why does it matter? Without it, threads may see stale values due to caching/reordering.
Q4What are virtual threads and their caveats?core▾

Lightweight threads scheduled by the JVM onto carrier threads, letting blocking code scale like async code. Caveats: pinning in synchronized blocks with blocking I/O (improved in newer JDKs), don't pool them, and ThreadLocal-heavy code uses more memory.

Follow-ups interviewers ask
  • Good fit? Thread-per-request servers with blocking I/O.
Q5How do you avoid deadlocks in Java?core▾

Acquire locks in a consistent order, use tryLock with timeouts (ReentrantLock), keep critical sections small, prefer concurrent collections and higher-level utilities, and detect with thread dumps (jstack).

Follow-ups interviewers ask
  • What does a thread dump show? Threads, states and which locks they hold/wait for.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.