Java

Multithreading in Java

March 10, 2024

Multithreading is one of those topics that separates decent Java developers from great ones. I spent a lot of time debugging race conditions and deadlocks before I truly understood how the JVM handles threads. This article is everything I wish I had known earlier.

What Is a Thread?

A thread is the smallest unit of execution within a process. Java runs every program with at least one thread — the main thread. You can spin up additional threads to do work concurrently.

Thread t = new Thread(() -> {
    System.out.println("Running in: " + Thread.currentThread().getName());
});
t.start();

The JVM maps Java threads to OS-level threads (platform threads pre-Java 21). Each thread gets its own stack but shares heap memory with other threads in the same process. That shared heap is both the power and the danger of multithreading.

Creating Threads: Three Ways

1. Extending Thread:

class MyThread extends Thread {
    public void run() {
        System.out.println("Thread running");
    }
}
new MyThread().start();

2. Implementing Runnable:

Runnable task = () -> System.out.println("Runnable running");
new Thread(task).start();

3. Using ExecutorService (preferred):

ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> System.out.println("Executor task"));
executor.shutdown();

Always prefer ExecutorService in production. Raw thread creation is expensive and unmanaged threads are hard to monitor.

The Visibility Problem

Here is a classic bug:

boolean running = true;
 
// Thread 1
while (running) { /* do work */ }
 
// Thread 2
running = false; // Thread 1 may NEVER see this

Without synchronization, the JVM can cache running in a CPU register or L1 cache, and Thread 1 might loop forever. The fix is volatile:

volatile boolean running = true;

volatile guarantees that writes are immediately visible to all threads. But it does NOT make compound operations atomic.

Synchronization

Use synchronized to protect critical sections:

class Counter {
    private int count = 0;
 
    public synchronized void increment() {
        count++;
    }
 
    public synchronized int get() {
        return count;
    }
}

synchronized on an instance method locks on this. On a static method it locks on the class object. You can also use a synchronized block for finer control:

private final Object lock = new Object();
 
public void increment() {
    synchronized (lock) {
        count++;
    }
}

The Java Memory Model (JMM)

The JMM defines when changes made by one thread become visible to others. Key rules:

  • Happens-before: If action A happens-before action B, then A's effects are visible to B.
  • synchronized establishes happens-before between a monitor unlock and the next lock.
  • volatile establishes happens-before between a write and subsequent reads.
  • Thread start and join also establish happens-before.

Without a happens-before relationship, the compiler and CPU are free to reorder your code in ways that break expectations.

java.util.concurrent

The java.util.concurrent package is your best friend. Stop writing raw synchronized blocks when these exist:

ReentrantLock — explicit locking with tryLock, fairness options:

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // critical section
} finally {
    lock.unlock(); // always unlock in finally
}

AtomicInteger — lock-free atomic operations:

AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // atomic, no lock needed

CountDownLatch — wait for N threads to complete:

CountDownLatch latch = new CountDownLatch(3);
// Each worker calls latch.countDown()
latch.await(); // blocks until count reaches 0

CyclicBarrier — synchronize N threads at a checkpoint:

CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("All threads reached barrier"));
barrier.await(); // each thread calls this

Semaphore — limit concurrent access to a resource:

Semaphore semaphore = new Semaphore(5); // max 5 concurrent
semaphore.acquire();
try {
    // access limited resource
} finally {
    semaphore.release();
}

Thread Pools

Creating a new thread for every task is wasteful. Thread pools reuse threads:

// Fixed pool — good for CPU-bound tasks
ExecutorService fixed = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
 
// Cached pool — good for short-lived IO-bound tasks
ExecutorService cached = Executors.newCachedThreadPool();
 
// Scheduled pool — for recurring tasks
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);
scheduled.scheduleAtFixedRate(() -> System.out.println("tick"), 0, 1, TimeUnit.SECONDS);

For production use, always create a ThreadPoolExecutor explicitly so you control queue size, rejection policy, and thread naming:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    4,                          // core threads
    8,                          // max threads
    60L, TimeUnit.SECONDS,      // keepAlive
    new LinkedBlockingQueue<>(100),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

Deadlocks

A deadlock occurs when two threads each hold a lock the other needs:

// Thread 1: locks A then tries to lock B
// Thread 2: locks B then tries to lock A
// Both block forever

Prevention strategies:

  1. Lock ordering — always acquire locks in the same order
  2. tryLock with timeout — give up if you can't acquire
  3. Lock-free data structures — avoid locks altogether

ForkJoinPool and Parallel Streams

For divide-and-conquer workloads, ForkJoinPool is optimal:

ForkJoinPool pool = new ForkJoinPool();
int sum = pool.invoke(new RecursiveSumTask(array, 0, array.length));

Parallel streams use the common ForkJoinPool internally:

long count = list.parallelStream()
    .filter(x -> x > 100)
    .count();

Be careful with parallel streams — for small datasets or IO-bound work, the overhead of splitting and merging outweighs the gains.

Virtual Threads (Java 21+)

Java 21 introduced virtual threads (Project Loom), which are lightweight threads managed by the JVM rather than the OS:

Thread.ofVirtual().start(() -> {
    // blocking IO doesn't pin an OS thread
    String result = callExternalApi();
});

Virtual threads make blocking IO cheap — you can have millions of them. This changes the game for high-concurrency servers. If you're on Java 21+, use virtual threads for IO-bound work and platform threads only for CPU-bound tasks.

Key Takeaways

  • Use volatile for visibility, synchronized or Lock for atomicity of compound operations
  • Prefer java.util.concurrent utilities over raw synchronization
  • Always name your threads and use thread pools in production
  • Deadlocks come from inconsistent lock ordering — establish a global order
  • Java 21 virtual threads remove most reasons to avoid blocking IO

Multithreading is hard to get right, but understanding the JMM, visibility guarantees, and the concurrency utilities makes it tractable. The key is writing code that makes synchronization explicit and auditable.

VA
Vishal
Aggarwal

Full Stack Developer

Ask about Vishal ✦