Java Multithreading Basics

Modern computers have multiple CPU cores. Multithreading lets your Java programs use them, running tasks simultaneously instead of one after another. A web server handles thousands of requests at once. A video editor renders frames while you continue editing. A game updates physics while rendering graphics.

This tutorial covers the fundamentals: creating threads, synchronizing access to shared data, and avoiding common pitfalls. We’ll stick to core Java threading concepts that form the foundation for more advanced concurrency tools.

What is a Thread?

A thread is an independent path of execution within a program. Every Java program starts with one thread, the main thread, which runs your main() method. You can create additional threads to perform tasks concurrently.

Threads within the same program share memory. They can access the same objects and variables. This makes communication easy but introduces risks when multiple threads modify shared data.

public class MainThreadExample {
    public static void main(String[] args) {
        // This runs in the "main" thread
        System.out.println("Current thread: " + Thread.currentThread().getName());
        // Output: Current thread: main
    }
}

Creating Threads

Java provides two ways to create threads: extending the Thread class or implementing the Runnable interface.

Extending Thread

Create a subclass of Thread and override the run() method:

public class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(getName() + " - Count: " + i);
            try {
                Thread.sleep(500);  // Pause for 500 milliseconds
            } catch (InterruptedException e) {
                System.out.println("Thread interrupted");
            }
        }
    }
}

public class ThreadDemo {
    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        MyThread thread2 = new MyThread();
        
        thread1.setName("Thread-A");
        thread2.setName("Thread-B");
        
        thread1.start();  // Don't call run() directly
        thread2.start();
        
        System.out.println("Main thread continues...");
    }
}

Output varies between runs because threads execute concurrently:

Main thread continues...
Thread-A - Count: 1
Thread-B - Count: 1
Thread-A - Count: 2
Thread-B - Count: 2
...

Call start(), not run(). Calling run() directly executes the code in the current thread. Calling start() creates a new thread that then calls run().

Implementing Runnable

The preferred approach is implementing Runnable. It’s more flexible because your class can extend something else.

public class MyRunnable implements Runnable {
    private String name;
    
    public MyRunnable(String name) {
        this.name = name;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(name + " - Count: " + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                System.out.println("Thread interrupted");
            }
        }
    }
}

public class RunnableDemo {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new MyRunnable("Task-A"));
        Thread thread2 = new Thread(new MyRunnable("Task-B"));
        
        thread1.start();
        thread2.start();
    }
}

Using Lambda Expressions

Since Runnable is a functional interface with one abstract method, you can use lambdas:

public class LambdaThreadDemo {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                System.out.println("Lambda thread: " + i);
            }
        });
        
        thread1.start();
        
        // Even shorter for simple tasks
        new Thread(() -> System.out.println("Quick task")).start();
    }
}

Thread Lifecycle

A thread moves through several states during its lifetime:

NEW: Created but not yet started. The Thread object exists, but start() hasn’t been called.

RUNNABLE: Ready to run or currently running. The thread scheduler decides when it actually executes.

BLOCKED: Waiting to acquire a lock held by another thread.

WAITING: Waiting indefinitely for another thread to perform an action (like calling notify()).

TIMED_WAITING: Waiting for a specified time (like during Thread.sleep()).

TERMINATED: Finished execution. The run() method completed or an uncaught exception occurred.

Thread thread = new Thread(() -> {
    System.out.println("Running");
});

System.out.println(thread.getState());  // NEW

thread.start();
System.out.println(thread.getState());  // RUNNABLE (probably)

thread.join();  // Wait for thread to finish
System.out.println(thread.getState());  // TERMINATED

Thread Methods

sleep()

Pauses the current thread for a specified time. Other threads continue running.

try {
    System.out.println("Starting sleep...");
    Thread.sleep(2000);  // Sleep for 2 seconds
    System.out.println("Woke up!");
} catch (InterruptedException e) {
    System.out.println("Sleep was interrupted");
}

Sleep throws InterruptedException if another thread interrupts this one while sleeping. Always handle or declare this exception.

join()

Makes the current thread wait for another thread to finish.

public class JoinDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            System.out.println("Worker starting...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Worker finished!");
        });
        
        worker.start();
        
        System.out.println("Main thread waiting for worker...");
        worker.join();  // Block until worker finishes
        
        System.out.println("Main thread continues after worker");
    }
}

Output:

Worker starting...
Main thread waiting for worker...
Worker finished!
Main thread continues after worker

You can also specify a timeout: worker.join(1000) waits at most 1 second.

interrupt()

Signals a thread that it should stop what it’s doing. It doesn’t force the thread to stop. The thread must check for interruption and respond appropriately.

public class InterruptDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("Working...");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    System.out.println("Interrupted during sleep!");
                    break;  // Exit the loop
                }
            }
            System.out.println("Worker stopped");
        });
        
        worker.start();
        
        Thread.sleep(2000);  // Let it run for 2 seconds
        worker.interrupt();  // Signal it to stop
    }
}

Synchronization

When multiple threads access shared data, problems arise. Consider this counter:

public class UnsafeCounter {
    private int count = 0;
    
    public void increment() {
        count++;  // Not atomic: read, add, write
    }
    
    public int getCount() {
        return count;
    }
}

public class RaceConditionDemo {
    public static void main(String[] args) throws InterruptedException {
        UnsafeCounter counter = new UnsafeCounter();
        
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        });
        
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        
        System.out.println("Final count: " + counter.getCount());
        // Expected: 20000
        // Actual: Something less, like 18543 (varies each run)
    }
}

The count is wrong because count++ isn’t atomic. It’s three operations: read the value, add one, write the result. Two threads can read the same value, both add one, and both write back, losing an increment.

This is called a race condition. The result depends on the timing of thread execution.

The synchronized Keyword

The synchronized keyword ensures only one thread executes a block of code at a time.

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

Now the increment method is thread-safe. When a thread enters increment(), it acquires a lock on the object. Other threads trying to call any synchronized method on the same object must wait.

You can also synchronize a specific block instead of the entire method:

public class SafeCounter {
    private int count = 0;
    private final Object lock = new Object();
    
    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
    
    public int getCount() {
        synchronized (lock) {
            return count;
        }
    }
}

Block synchronization offers finer control. You can use different locks for different resources, allowing more concurrency.

The volatile Keyword

The volatile keyword ensures that reads and writes to a variable go directly to main memory, not thread-local caches. It provides visibility but not atomicity.

public class VolatileDemo {
    private volatile boolean running = true;
    
    public void stop() {
        running = false;
    }
    
    public void run() {
        while (running) {
            // Do work
        }
        System.out.println("Stopped");
    }
}

Without volatile, the running thread might cache the value of running and never see the update from another thread. Volatile ensures the change is visible.

Volatile is not enough for operations like increment. Use synchronized or atomic classes for those.

Deadlock

Deadlock occurs when two or more threads are blocked forever, each waiting for a lock held by the other.

public class DeadlockDemo {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();
    
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1: Holding lock1");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                
                System.out.println("Thread 1: Waiting for lock2");
                synchronized (lock2) {
                    System.out.println("Thread 1: Holding lock1 and lock2");
                }
            }
        });
        
        Thread t2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2: Holding lock2");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                
                System.out.println("Thread 2: Waiting for lock1");
                synchronized (lock1) {
                    System.out.println("Thread 2: Holding lock2 and lock1");
                }
            }
        });
        
        t1.start();
        t2.start();
    }
}

Thread 1 holds lock1 and waits for lock2. Thread 2 holds lock2 and waits for lock1. Neither can proceed. The program hangs.

Avoid deadlock by:

  • Always acquiring locks in the same order
  • Using timeout-based lock attempts
  • Avoiding nested locks when possible
  • Using higher-level concurrency utilities instead of manual locking

Thread-Safe Collections

Standard collections like ArrayList and HashMap aren’t thread-safe. Multiple threads modifying them concurrently causes corruption.

Java provides several solutions:

Synchronized Wrappers

List<String> syncList = Collections.synchronizedList(new ArrayList<>());
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());

// Must synchronize during iteration
synchronized (syncList) {
    for (String item : syncList) {
        System.out.println(item);
    }
}

Concurrent Collections

The java.util.concurrent package provides collections designed for concurrent access:

// Thread-safe map with better performance than synchronized wrapper
ConcurrentHashMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("key", 1);
concurrentMap.computeIfAbsent("newKey", k -> computeValue());

// Thread-safe list optimized for read-heavy workloads
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("item");

// Thread-safe queue
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
queue.put("item");       // Blocks if full
String item = queue.take();  // Blocks if empty

ConcurrentHashMap allows concurrent reads and writes without blocking the entire map. CopyOnWriteArrayList creates a new copy on each write, making it ideal when reads vastly outnumber writes.

Atomic Classes

The java.util.concurrent.atomic package provides classes for lock-free, thread-safe operations on single variables:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);
    
    public void increment() {
        count.incrementAndGet();  // Atomic operation
    }
    
    public int getCount() {
        return count.get();
    }
}

public class AtomicDemo {
    public static void main(String[] args) throws InterruptedException {
        AtomicCounter counter = new AtomicCounter();
        
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        });
        
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        
        System.out.println("Final count: " + counter.getCount());
        // Always prints: 20000
    }
}

Other useful atomic classes: AtomicLong, AtomicBoolean, AtomicReference.

ExecutorService

Creating threads manually works for simple cases, but production code uses thread pools. The ExecutorService interface manages a pool of reusable threads.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ExecutorDemo {
    public static void main(String[] args) {
        // Create a pool with 4 threads
        ExecutorService executor = Executors.newFixedThreadPool(4);
        
        // Submit 10 tasks
        for (int i = 1; i <= 10; i++) {
            int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " running on " + 
                    Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
        
        // Shut down the executor
        executor.shutdown();
        try {
            executor.awaitTermination(60, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
        
        System.out.println("All tasks completed");
    }
}

Common executor types:

  • newFixedThreadPool(n) – Pool with exactly n threads
  • newCachedThreadPool() – Pool that creates threads as needed, reuses idle ones
  • newSingleThreadExecutor() – Single thread executing tasks sequentially
  • newScheduledThreadPool(n) – Pool for scheduling delayed or periodic tasks

Callable and Future

Runnable can’t return a value. Callable can. When you submit a Callable, you get a Future representing the pending result.

import java.util.concurrent.*;

public class CallableDemo {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        
        // Callable returns a result
        Callable<Integer> task = () -> {
            Thread.sleep(1000);
            return 42;
        };
        
        Future<Integer> future = executor.submit(task);
        
        System.out.println("Task submitted, doing other work...");
        
        // get() blocks until result is available
        Integer result = future.get();
        System.out.println("Result: " + result);
        
        // Check status without blocking
        Future<String> anotherFuture = executor.submit(() -> {
            Thread.sleep(2000);
            return "Done";
        });
        
        while (!anotherFuture.isDone()) {
            System.out.println("Still waiting...");
            Thread.sleep(500);
        }
        
        System.out.println("Result: " + anotherFuture.get());
        
        executor.shutdown();
    }
}

Best Practices

Minimize shared mutable state. The less data threads share, the fewer synchronization problems you’ll have. Prefer immutable objects and thread-local data.

Use higher-level concurrency utilities. ExecutorService, ConcurrentHashMap, and atomic classes are safer and easier than raw threads and synchronized blocks.

Keep synchronized blocks short. Hold locks for the minimum time necessary. Long-held locks hurt performance and increase deadlock risk.

Don’t call Thread.stop(). It’s deprecated because it can leave objects in inconsistent states. Use interruption and cooperative cancellation instead.

Test thoroughly. Concurrency bugs are timing-dependent and hard to reproduce. Race conditions might only appear under heavy load or on certain hardware.


Previous: Java Streams API

Related: Java Lambda Expressions | Java Collections Framework Overview | Java Collections Interview Questions

Sources

  • Oracle. “Concurrency.” docs.oracle.com/javase/tutorial/essential/concurrency
  • Oracle. “java.util.concurrent Package.” docs.oracle.com/javase/21/docs/api/java.base/java/util/concurrent/package-summary.html
  • Goetz, Brian. “Java Concurrency in Practice.” 2006
Scroll to Top