Polyglot
Languages Java concurrency
Java § concurrency

Concurrency

Java has been a multi-threaded language since version 1.0. The principal concurrency primitives are threads (Thread, Runnable), synchronisation (synchronized, volatile), and the Java Memory Model (the rules that govern inter-thread visibility). Beyond these, the java.util.concurrent package (introduced in Java 5) provides a substantial collection of higher-level concurrency utilities: locks, atomic variables, executors, futures, and concurrent collections. Java 21 introduced virtual threads (Project Loom) — lightweight threads that admit blocking-style code at scale — and structured concurrency (preview) for managing the lifetime of related concurrent operations. The combination is one of the most elaborate concurrency surfaces in mainstream languages.

This page covers the principal mechanisms — threads, synchronisation, executors, futures, atomics, virtual threads — and the conventions for using each.

Threads

A thread is a unit of independent execution. The Thread class represents a thread; Runnable (a functional interface) represents the work the thread performs:

Runnable task = () -> {
    System.out.println("running on thread " + Thread.currentThread().getName());
};

Thread t = new Thread(task, "worker-1");
t.start();
t.join();      // wait for completion

The principal operations:

  • t.start() — begins execution; calls the runnable’s run() method on a new thread.
  • t.join() — waits for the thread to terminate.
  • t.join(millis) — waits with a timeout.
  • t.interrupt() — requests the thread terminate (cooperative; the thread must check).
  • t.isAlive() — whether the thread is still running.
  • t.getName(), t.getId(), t.getState() — diagnostic.
  • Thread.currentThread() — the thread executing the call.
  • Thread.sleep(millis) — pause the current thread.

Thread creation is relatively expensive; the conventional alternative is an executor with a thread pool, treated below.

Daemon threads

A daemon thread is a thread the JVM does not wait for at shutdown:

Thread t = new Thread(task);
t.setDaemon(true);
t.start();

The conventional uses are background services, garbage-collection threads, and timers — anything that should not prevent the program from exiting.

Interruption

Java’s interrupt mechanism is cooperative: t.interrupt() sets a flag on the thread, and the thread is expected to check the flag periodically:

public void run() {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            doWork();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();    // restore the flag
            return;
        }
    }
}

Many blocking operations (sleep, wait, join) throw InterruptedException when the thread is interrupted. The conventional discipline is to catch the exception, restore the interrupt flag (Thread.currentThread().interrupt()), and either propagate the cancellation or terminate.

synchronized and intrinsic locks

Every Java object has an intrinsic lock (also called a monitor). The synchronized keyword acquires the lock for the duration of the synchronised block:

public class Counter {
    private int count = 0;

    public synchronized void increment() {        // acquires this object's lock
        count++;
    }

    public int get() {
        synchronized (this) {                       // explicit form
            return count;
        }
    }
}

Synchronised blocks may use any object as the lock:

private final Object lock = new Object();

public void operation() {
    synchronized (lock) {
        // critical section
    }
}

The conventional discipline:

  • Lock on a private object (not on this or on getClass()); locking on a publicly-visible object admits external interference.
  • Hold locks briefly; never call user code (or any code that may itself lock) while holding a lock.
  • Acquire multiple locks in a fixed order to prevent deadlock.

Static synchronised methods acquire the class’s monitor:

public class Cache {
    private static final Map<String, Object> entries = new HashMap<>();

    public static synchronized void put(String key, Object value) {
        entries.put(key, value);
    }
}

volatile

The volatile keyword marks a field as observable across threads — reads see the most recent write, and the compiler/JVM does not optimise across the access:

public class Flag {
    private volatile boolean stop = false;

    public void run() {
        while (!stop) {
            doWork();
        }
    }

    public void stop() {
        stop = true;
    }
}

volatile admits inter-thread visibility but not compound atomicity: count++ on a volatile int is still a race because the read-modify-write is not atomic. For atomicity, use synchronized or AtomicInteger.

The conventional uses of volatile:

  • Boolean flags read by one thread and written by another.
  • Singleton-with-double-checked-locking initialisation.
  • Read-mostly reference fields where atomicity is implicit.

The Java Memory Model

The Java Memory Model (JMM) specifies the rules for inter-thread memory visibility. The principal points:

  • Happens-before is the fundamental relation. If action A happens-before B, then B sees the effects of A.
  • A volatile write happens-before any subsequent volatile read of the same variable.
  • A synchronized exit happens-before a subsequent synchronized entry on the same monitor.
  • A Thread.start() call happens-before any action in the started thread.
  • A Thread.join() returning happens-before any action after the join.
  • The end of a static initialiser happens-before any subsequent access to the class’s members.

Without happens-before, a thread may see stale or partially-written values, even on architectures with strict cache coherence. The JMM is the substrate on which all the higher-level concurrency utilities are built.

java.util.concurrent

The java.util.concurrent package (introduced in Java 5) provides higher-level concurrency utilities. The principal sub-packages:

Sub-packageContents
java.util.concurrentExecutors, futures, locks, queues, collections
java.util.concurrent.atomicAtomic variables (AtomicInteger, AtomicReference)
java.util.concurrent.locksLock interfaces and implementations

The conventional contemporary discipline is to use the java.util.concurrent utilities rather than raw threads and synchronized blocks; the higher-level utilities are easier to use correctly and produce better diagnostics.

Executors

An Executor accepts Runnable tasks and runs them on its own threads:

import java.util.concurrent.*;

ExecutorService executor = Executors.newFixedThreadPool(4);

executor.submit(() -> System.out.println("running"));
Future<Integer> result = executor.submit(() -> compute());
int n = result.get();      // blocks for the result

executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);

The principal factory methods:

FactoryPool
Executors.newFixedThreadPool(n)Fixed-size pool of n threads
Executors.newCachedThreadPool()Unbounded; threads are reused for 60 seconds
Executors.newSingleThreadExecutor()One thread; tasks are serialised
Executors.newScheduledThreadPool(n)Pool admitting scheduled and periodic tasks
Executors.newWorkStealingPool()Work-stealing pool (Java 8+); the ForkJoinPool
Executors.newVirtualThreadPerTaskExecutor()Virtual threads (Java 21+)

For finer control, ThreadPoolExecutor admits the full constructor:

ExecutorService executor = new ThreadPoolExecutor(
    coreSize: 4,
    maxSize: 16,
    keepAliveTime: 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

The conventional discipline:

  • Use Executors.newFixedThreadPool for known workloads.
  • Use newWorkStealingPool (the ForkJoinPool) for parallelisable, recursive workloads.
  • Use newVirtualThreadPerTaskExecutor for I/O-heavy, blocking code (Java 21+).
  • Avoid newCachedThreadPool for unbounded workloads (it can spawn unbounded threads).

Callable<V> and Future<V>

Callable<V> is like Runnable but returns a value (and may throw checked exceptions):

Callable<String> task = () -> "result";
Future<String> future = executor.submit(task);
String result = future.get();        // blocks

Future<V> represents a pending result:

  • get() — blocks until completion; returns the value or throws.
  • get(timeout, unit) — blocks with a timeout.
  • isDone() — whether the future has completed.
  • cancel(boolean mayInterrupt) — attempt cancellation.

CompletableFuture<V>

Java 8 introduced CompletableFuture<V> — a future with composition primitives:

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> fetch("https://example.com"))
    .thenApply(this::parse)
    .thenCompose(this::enrichAsync)
    .exceptionally(e -> "default")
    .thenAccept(result -> log.info("got: {}", result));

The principal operations:

OperationEffect
supplyAsync(supplier)Run a supplier on the common pool
thenApply(fn)Transform the result
thenAccept(consumer)Side-effect on the result
thenCompose(fn)Sequence with another async operation
thenCombine(other, fn)Combine two futures
allOf(futures...)Wait for all
anyOf(futures...)Wait for the first
exceptionally(fn)Recover from a failure
handle(fn)Handle both success and failure

The mechanism admits expressing async pipelines without explicit thread management. It is the conventional Java idiom for non-blocking, composable async operations.

Synchronisation primitives

The java.util.concurrent.locks package provides several lock types:

TypePurpose
ReentrantLockLike synchronized but with explicit lock/unlock and timed acquisition
ReentrantReadWriteLockMultiple readers or one writer
StampedLockOptimistic-read variant of read-write
import java.util.concurrent.locks.*;

private final ReentrantLock lock = new ReentrantLock();

public void process() {
    lock.lock();
    try {
        // critical section
    } finally {
        lock.unlock();
    }
}

ReentrantLock admits tryLock(timeout, unit) for timed acquisition, lockInterruptibly() for interruptible acquisition, and Condition objects for waiting and signalling.

Concurrent collections

Several thread-safe collections:

TypePurpose
ConcurrentHashMap<K, V>Thread-safe hash map; the conventional choice
CopyOnWriteArrayList<T>Read-mostly thread-safe list
ConcurrentLinkedQueue<T>Thread-safe FIFO
ConcurrentLinkedDeque<T>Thread-safe deque
BlockingQueue<T> and implementationsProducer-consumer queue
ArrayBlockingQueue<T> / LinkedBlockingQueue<T>Bounded / unbounded blocking queue
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.compute("key", (k, v) -> v == null ? 1 : v + 1);
counts.merge("key", 1, Integer::sum);

ConcurrentHashMap admits substantial concurrent access without locking the whole map; it is the conventional thread-safe map.

Atomic variables

The java.util.concurrent.atomic package provides atomic variables:

import java.util.concurrent.atomic.*;

AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();                    // atomic increment
counter.compareAndSet(expected: 5, update: 10); // atomic CAS
int n = counter.get();

The principal types:

  • AtomicInteger, AtomicLong, AtomicBoolean — primitives.
  • AtomicReference<T> — references.
  • AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray<T> — arrays.
  • LongAdder, LongAccumulator — high-throughput counters (Java 8+).

The mechanism is the conventional foundation for lock-free data structures.

CountDownLatch, CyclicBarrier, Semaphore

Several coordination primitives:

  • CountDownLatch — a single-use barrier; threads wait until a count reaches zero.
  • CyclicBarrier — a multi-use barrier; threads wait until all reach the barrier, then proceed together.
  • Semaphore — a counting semaphore; admits a fixed number of permits.
CountDownLatch latch = new CountDownLatch(3);

for (int i = 0; i < 3; i++) {
    int taskId = i;
    executor.submit(() -> {
        process(taskId);
        latch.countDown();
    });
}

latch.await();    // blocks until all three count down

ThreadLocal<T>

A ThreadLocal<T> admits per-thread storage:

private static final ThreadLocal<DateFormat> formatter =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

public String format(Date d) {
    return formatter.get().format(d);
}

The mechanism is the conventional way to implement per-thread caches, error states, and storage that should not be shared across threads. The principal trade-off:

  • Memory: each thread that touches the ThreadLocal retains its instance until the thread exits.
  • Cleanup: long-lived threads (in pools) must be explicitly cleared via remove() to avoid leaks.

Virtual threads (Java 21)

Java 21 introduced virtual threads (Project Loom): lightweight threads that the JVM schedules onto OS threads. The mechanism admits blocking-style code at scale — programs that block on I/O can use thousands or millions of virtual threads without exhausting OS resources.

Thread.startVirtualThread(() -> {
    // runs on a virtual thread
    blockingIO();
});

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
List<Future<String>> futures = urls.stream()
    .map(url -> executor.submit(() -> fetch(url)))
    .toList();

The principal differences from platform (OS) threads:

  • Cheap: a virtual thread costs hundreds of bytes; programs may have millions.
  • Scheduled by the JVM: virtual threads run on a small pool of carrier threads (typically one per CPU core).
  • Yields on blocking: when a virtual thread blocks on I/O, synchronized, or Object.wait(), it unmounts from its carrier and another virtual thread runs.
  • Same API: Thread.currentThread(), Thread.sleep, interrupt, etc., all work as expected.

The mechanism is Java’s response to async/await — instead of structured async code with CompletableFuture, the program writes blocking code that the JVM scales transparently.

The conventional discipline:

  • Use virtual threads for I/O-heavy, blocking code (HTTP servers, database access).
  • Use platform threads for CPU-bound parallel computation.
  • Use Executors.newVirtualThreadPerTaskExecutor() for ad-hoc task dispatch.

Pinning

When a virtual thread executes inside a synchronized block or a native call, it is pinned to its carrier thread; if the thread blocks while pinned, the carrier is also blocked. The conventional defence is to use ReentrantLock instead of synchronized when virtual threads will be involved.

Structured concurrency (preview, Java 21+)

Structured concurrency is a preview feature that admits managing the lifetime of related concurrent operations:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> task1 = scope.fork(() -> fetch1());
    Subtask<Integer> task2 = scope.fork(() -> fetch2());

    scope.join();
    scope.throwIfFailed();

    String s = task1.get();
    int n = task2.get();
}

The mechanism guarantees that all subtasks complete or are cancelled before the scope exits. The construction is similar to async/await in other languages but is not yet stabilised in the language spec.

Common patterns and pitfalls

Producer-consumer

BlockingQueue<Item> queue = new LinkedBlockingQueue<>();

// Producer:
executor.submit(() -> {
    for (Item item : items) {
        queue.put(item);    // blocks if full
    }
});

// Consumer:
executor.submit(() -> {
    while (true) {
        Item item = queue.take();    // blocks if empty
        process(item);
    }
});

One-time initialisation

private static final Database INSTANCE;

static {
    INSTANCE = createDatabase();   // class-init is thread-safe
}

Static initialisation is guaranteed thread-safe; the conventional alternative to synchronized for one-time setup.

Single-thread serialisation

ExecutorService serial = Executors.newSingleThreadExecutor();
serial.submit(task);          // ordered serial execution

A single-thread executor ensures tasks run in submission order; the mechanism is the conventional way to serialise access to a non-thread-safe resource.

Don’t invoke async on a service that may be a virtual thread

Several common defects:

  • Holding a lock across an async boundary — the lock may be released on a different thread, producing incorrect lock ownership.
  • Using ThreadLocal with virtual threads — virtual threads work with ThreadLocal but can produce surprising lifetimes.
  • Ignoring InterruptedException — the thread’s interrupt status is cleared; subsequent code does not see the interrupt.
  • Catching Throwable indiscriminately in a worker — swallows failures that a well-designed pool would have surfaced.

The combination of java.util.concurrent, virtual threads, the JMM, and the discipline of marking shared state appropriately is the conventional toolkit for concurrent Java. The discipline of writing concurrency-correct code — minimising shared state, preferring immutable data, using the higher-level utilities, and testing under load — is one of the principal skills of writing reliable Java services.