Menu
CoddyTech

Multithreading in Java Interview Questions

Threads, synchronization, volatile, executors, CompletableFuture and virtual threads.

10 questions
  1. What are the ways to create a thread in Java?

    Extend Thread, pass a Runnable to a Thread, or submit a Runnable or Callable to an ExecutorService. Prefer the executor: it reuses threads, and a Callable can return a value. ExecutorService is AutoCloseable since Java 19.

    Java
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Future;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            Thread t = new Thread(() -> System.out.println("runnable in a thread"));
            t.start();
            t.join();   // wait for it to finish
    
            try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
                Future<Integer> f = pool.submit(() -> 6 * 7);   // a Callable
                System.out.println("callable returned " + f.get());
            }
        }
    }

    Follow-up: what does calling run() instead of start() do? It runs the code on the current thread.

  2. What are the states of a thread in Java (the thread lifecycle)?

    Six Thread.State values: NEW, RUNNABLE (running or ready), BLOCKED (waiting for a monitor), WAITING (wait(), join() or park() with no timeout), TIMED_WAITING (with a timeout, or sleep) and TERMINATED.

    Java
    import java.util.concurrent.CountDownLatch;
    
    public class Main {
        public static void main(String[] args) throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            Thread t = new Thread(() -> {
                try { latch.await(); } catch (InterruptedException e) { }
            });
            System.out.println(t.getState());
            t.start();
            while (t.getState() != Thread.State.WAITING) Thread.onSpinWait();
            System.out.println(t.getState());
            latch.countDown();
            t.join();
            System.out.println(t.getState());
        }
    }

    It prints NEW, WAITING and TERMINATED. A thread waiting on a ReentrantLock is WAITING, because BLOCKED is only for monitors.

  3. What is a race condition and how does synchronized or AtomicInteger fix it?

    A race condition is a result that depends on thread timing: count++ is read, add, write, and two threads can interleave the steps and lose updates. synchronized admits one thread at a time and makes its writes visible to the next lock holder; AtomicInteger does it for one counter with lock-free CAS.

    Java
    import java.util.concurrent.atomic.AtomicInteger;
    
    public class Main {
        private static int syncCount = 0;
        private static final AtomicInteger atomicCount = new AtomicInteger();
    
        private static synchronized void increment() { syncCount++; }
    
        public static void main(String[] args) throws InterruptedException {
            Runnable work = () -> {
                for (int i = 0; i < 10_000; i++) {
                    increment();
                    atomicCount.incrementAndGet();
                }
            };
            Thread t1 = new Thread(work), t2 = new Thread(work);
            t1.start(); t2.start();
            t1.join(); t2.join();
            System.out.println(syncCount + " " + atomicCount.get());
        }
    }

    It always prints 20000 20000; with a plain unsynchronized int it usually prints less.

  4. What does volatile do in Java? Does it make count++ thread safe?

    volatile guarantees visibility and ordering: every read sees the latest write, and accesses are not reordered across it. It does not make count++ atomic.

    Use it for a stop flag one thread sets and another polls (without it the JIT may hoist the read and loop forever), and on the instance field in double-checked locking. Counters need AtomicInteger.

  5. What is the difference between wait() and sleep()?

    sleep() pauses the thread and keeps its locks. wait() must be called while holding the object's monitor, releases it, and resumes on notify(), a timeout or an interrupt.

    Call wait() in a while loop that rechecks the condition, because spurious wakeups happen. BlockingQueue or CountDownLatch are easier in new code.

  6. What is a deadlock and how do you prevent it?

    A deadlock is threads each holding a lock another needs, so all wait forever: thread 1 holds A and wants B while thread 2 holds B and wants A.

    Prevent it by taking locks in one global order (by account id in a transfer), or with tryLock(timeout) and back-off. jstack <pid> reports "Found one Java-level deadlock".

  7. What is CompletableFuture and how is it different from Future?

    Future only has a blocking get(). CompletableFuture (Java 8) chains async steps without blocking: thenApply, thenCompose, thenCombine, exceptionally, handle.

    Java
    import java.util.concurrent.CompletableFuture;
    
    public class Main {
        public static void main(String[] args) {
            CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 120);
            CompletableFuture<Integer> tax = CompletableFuture.supplyAsync(() -> 18);
    
            int total = price.thenCombine(tax, Integer::sum)   // combine two
                             .thenApply(t -> t * 2)            // transform
                             .exceptionally(e -> -1)           // fallback on error
                             .join();
            System.out.println("total " + total);
    
            String failed = CompletableFuture.supplyAsync(() -> {
                        if (true) throw new IllegalStateException("down");
                        return "ok";
                    })
                    .handle((v, e) -> e == null ? v : "recovered: " + e.getCause().getMessage())
                    .join();
            System.out.println(failed);
        }
    }

    It prints total 276 and recovered: down; handle receives a CompletionException, hence getCause(). Async steps run on the common ForkJoinPool (a new thread per task if its parallelism is below two) unless you pass an executor, which you should for blocking I/O.

  8. What are virtual threads in Java 21?

    Virtual threads (final in Java 21) are JVM-managed threads that unmount from their carrier platform thread while blocked on I/O, so millions can run plain thread-per-request code.

    Java
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Future;
    import java.util.ArrayList;
    import java.util.List;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
                List<Future<Integer>> results = new ArrayList<>();
                for (int i = 1; i <= 10_000; i++) {
                    int n = i;
                    results.add(ex.submit(() -> {
                        Thread.sleep(10);     // blocking is cheap here
                        return n;
                    }));
                }
                long sum = 0;
                for (Future<Integer> f : results) sum += f.get();
                System.out.println("sum " + sum);
            }
        }
    }

    It prints sum 50005000, and 10,000 sleeping tasks finish quickly. They help I/O-bound work, not CPU-bound work, and should not be pooled. In Java 21 blocking inside synchronized pins the carrier; JDK 24 removed most of that pinning with JEP 491.

  9. How do you solve the producer-consumer problem in Java?

    Use a BlockingQueue: put() waits while the queue is full and take() waits while it is empty, so the queue does the locking and signalling that hand-written wait() and notify() code often gets wrong.

    Java
    import java.util.concurrent.ArrayBlockingQueue;
    import java.util.concurrent.BlockingQueue;
    
    public class Main {
        public static void main(String[] args) throws InterruptedException {
            BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);   // holds 2 items
    
            Thread producer = new Thread(() -> {
                try {
                    for (int i = 1; i <= 5; i++) queue.put(i);   // waits while full
                    queue.put(-1);                                // poison pill: done
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
    
            Thread consumer = new Thread(() -> {
                try {
                    int sum = 0;
                    for (int item; (item = queue.take()) != -1; ) sum += item;   // waits while empty
                    System.out.println("consumed sum " + sum);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
    
            producer.start();
            consumer.start();
            producer.join();
            consumer.join();
        }
    }

    It prints consumed sum 15. The -1 is a poison pill that tells the consumer to stop, and the bounded capacity stops a fast producer from filling memory. Follow-up: with wait(), test the condition in a while loop, not an if, because of spurious wakeups.

  10. What is ThreadLocal, and when should you use it?

    ThreadLocal gives each thread its own copy of a variable, so nothing is shared and no lock is needed. Typical uses are per-request context (user, transaction, trace id) and objects that are not thread safe, such as SimpleDateFormat.

    Java
    import java.util.Map;
    import java.util.TreeMap;
    import java.util.concurrent.ConcurrentHashMap;
    
    public class Main {
        private static final ThreadLocal<StringBuilder> buffer =
                ThreadLocal.withInitial(StringBuilder::new);
        private static final Map<String, String> seen = new ConcurrentHashMap<>();
    
        static void log(String msg) { buffer.get().append(msg).append(' '); }
    
        public static void main(String[] args) throws InterruptedException {
            Runnable job = () -> {
                String name = Thread.currentThread().getName();
                log(name + "-a");
                log(name + "-b");
                seen.put(name, buffer.get().toString().trim());
                buffer.remove();   // clean up, above all on pooled threads
            };
            Thread t1 = new Thread(job, "T1"), t2 = new Thread(job, "T2");
            t1.start(); t2.start();
            t1.join(); t2.join();
            System.out.println(new TreeMap<>(seen));
        }
    }

    It prints {T1=T1-a T1-b, T2=T2-a T2-b}: each thread saw only its own buffer. Call remove() when the work ends. Pool threads live as long as the application, so a forgotten value leaks memory and can hand one request's data to the next.

Coddy programming languages illustration

Learn to code with Coddy

Get started