Skip to content

Concurrency & threading

Senior

Platform threads vs virtual threads and why Project Loom changes the cost model, plus the concurrency contracts — synchronized, volatile, double-checked locking, and common races.

Created 16 September 2026 · Updated 23 October 2026

3 concepts · detail hidden

  1. 01Glossary — terms used in this page
    • Platform thread — Java’s traditional Thread, a thin wrapper around one OS thread.
    • Virtual thread — a lightweight, JVM-scheduled thread multiplexed onto a small pool of platform threads.
    • Carrier thread — the platform thread a virtual thread is currently mounted on and running.
    • Project Loom / JEP (JDK Enhancement Proposal) — the OpenJDK project that delivered virtual threads; a JEP is a numbered proposal for a JDK change (virtual threads are JEP 444).
    • Monitor — the lock every Java object carries, acquired and released by synchronized.
    • JMM (Java Memory Model) — the specification of when one thread’s writes become visible to another.
    • Happens-before — the JMM ordering guarantee that makes a write visible to a later read.
    • Safe publication — making an object visible to other threads in a fully constructed state.
    • volatile — field modifier that makes every read see the latest write and forbids reordering around it; it does not make compound operations atomic.
    • Spurious wakeup — a thread returning from wait() without having been notified.
    #c-glossary-terms-used-in-this-page
  2. 02Virtual threads — what they are, and why they're superior to platform threads for I/O-bound work

    Virtual threads (a stable JDK feature since Java 21, delivered by Project Loom, JEP 444) are lightweight threads scheduled by the JVM itself, rather than mapped 1:1 onto a dedicated OS thread. A platform thread — Java’s traditional Thread — is a thin wrapper around a genuine OS thread: expensive to create (typically a megabyte-scale stack reserved per thread by default) and practically limited to at most a few thousand concurrently before OS scheduling overhead and memory pressure dominate. A virtual thread instead multiplexes huge numbers of these JVM-managed threads — potentially millions — onto a much smaller pool of underlying platform (“carrier”) threads: when a virtual thread performs a blocking operation on a Loom-aware API (blocking I/O, Thread.sleep, the java.util.concurrent utilities), the JVM automatically unmounts it from its carrier thread, freeing that OS thread to run a different virtual thread, and later re-mounts it — possibly on a different carrier thread — once the blocking call completes. From the application code’s own perspective this is completely transparent: you write the exact same simple, sequential, blocking-style code you’d always write (socket.read(), a blocking JDBC call), with none of the callback/reactor-style inversion of control that reactive frameworks (Netty, reactive streams) traditionally require to reach comparable scalability. This is precisely why virtual threads are described as superior specifically for I/O-bound, high-concurrency workloads — a typical web server handling many concurrent, mostly-waiting requests gets thread-per-request-style code that genuinely scales to huge numbers of concurrently blocked requests, without either the OS-thread ceiling of platform threads or the code complexity of async/reactive programming. They deliberately don’t help CPU-bound work at all: a virtual thread actually running CPU-bound code still occupies a real carrier thread for the entire duration, so CPU-bound throughput stays bounded by the number of actual cores exactly as with platform threads — the win is specifically about how little OS-thread resource is consumed while waiting, not about doing more computation in parallel.

    Conceptually, this is the JVM’s own version of what asyncio’s cooperative scheduling gives Python — many logical tasks multiplexed onto a small number of real OS threads — but implemented transparently by the JVM scheduler itself, rather than requiring async/await and a differently-“colored” function signature for every call that might block.

    #c-virtual-threads-what-they-are-and-why-theyre-superior-to-pla
  3. 03Concurrency contracts — visibility, synchronized, and double-checked locking

    synchronized needs the same lock

    class Counter {
        private int n;
        synchronized void inc() { n++; }                  // locks `this`
        void bad() { synchronized (new Object()) { n++; } }   // different lock every time: no exclusion
    }
    

    A lock only protects data if every access (reads and writes) uses the same monitor. synchronized also establishes happens-before: unlock then lock on the same monitor makes writes visible.

    Singleton with double-checked locking

    class Config {
        private static volatile Config instance;            // volatile is REQUIRED
        static Config get() {
            Config local = instance;
            if (local == null) {
                synchronized (Config.class) {
                    local = instance;
                    if (local == null) instance = local = new Config();
                }
            }
            return local;
        }
    }
    

    Without volatile, the JIT/CPU may reorder “write the reference” before “run the constructor”, so another thread sees a non-null instance pointing at a half-initialised object — a failure of safe publication. The volatile write/read pair restores happens-before between the constructor finishing and another thread reading the reference. Simpler and safe alternatives: the initialization-on-demand holder idiom, or an enum singleton.

    class Config {
        private Config() {}
        private static class Holder { static final Config INSTANCE = new Config(); }
        static Config get() { return Holder.INSTANCE; }     // class init is thread-safe, lazy
    }
    

    Other traps

    • i++ on a volatile int is not atomic (read, add, write); use AtomicInteger.
    • Check-then-act on a ConcurrentHashMap (if (!map.containsKey(k)) map.put(k, v)) races; use putIfAbsent or computeIfAbsent.
    • long and double writes on 32-bit JVMs without volatile could tear (rare today, still spec-legal).
    • wait() must be called in a loop that rechecks its condition (spurious wakeups) and while holding the monitor.
    • Thread.run() runs on the current thread; only start() creates a new one.
    • SimpleDateFormat and other non-thread-safe classes shared across threads; prefer java.time.
    #c-concurrency-contracts-visibility-synchronized-and-double-che