AdvancedJava · Lesson 3 of 9

Virtual Threads & Thread Safety

Race conditions, atomics, concurrent collections, locks, and massive concurrency with virtual threads.

When threads share mutable data, a race condition can corrupt it: count++ is really read-add-write, and two threads can interleave and lose updates. The fixes, from simplest: avoid shared mutable state; use atomic classes (AtomicInteger, LongAdder); use concurrent collections (ConcurrentHashMap); or guard critical sections with synchronized or a ReentrantLock.

Virtual threads (Java 21) are lightweight threads managed by the JVM. You can run hundreds of thousands at once, so blocking code — waiting on HTTP calls or databases — scales without async callbacks. Create one per task with Executors.newVirtualThreadPerTaskExecutor().

Virtual threads help I/O-bound work, not CPU-bound work: for heavy computation, use a fixed pool sized to your CPU cores (or parallel streams).

ThreadSafety.javaJava
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;

public class ThreadSafety {
    static int unsafeCount = 0;

    public static void main(String[] args) {
        AtomicInteger safeCount = new AtomicInteger();

        try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
            for (int t = 0; t < 8; t++) {
                pool.submit(() -> {
                    for (int i = 0; i < 100_000; i++) {
                        unsafeCount++;                 // race condition
                        safeCount.incrementAndGet();   // atomic
                    }
                });
            }
        }
        System.out.println("unsafe: " + unsafeCount + " (expected 800000)");
        System.out.println("atomic: " + safeCount.get());

        ConcurrentHashMap<String, Integer> votes = new ConcurrentHashMap<>();
        IntStream.range(0, 10_000).parallel()
            .forEach(i -> votes.merge(i % 3 == 0 ? "debate" : "science", 1, Integer::sum));
        System.out.println(votes);

        long start = System.nanoTime();
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));   // e.g. waiting for an SMS gateway
                return i;
            }));
        }
        System.out.printf("10,000 blocking tasks on virtual threads took %.1f s%n",
            (System.nanoTime() - start) / 1e9);
    }
}

Key points

  • Shared mutable state + threads = race conditions; prefer immutability.
  • Use atomics, ConcurrentHashMap or locks when sharing is unavoidable.
  • Virtual threads make blocking I/O scale; use fixed pools for CPU-heavy work.

Exercise

Write a BankAccount with deposit and withdraw used by 50 threads at once. Show that a plain long balance goes wrong, then fix it with synchronized and again with a ReentrantLock, and compare the timings.

Show solution

Try the exercise yourself first — then compare your approach with this one.

Fifty threads each make 1,000 deposits and 1,000 withdrawals of 1, so the final balance must equal the starting balance. The plain version loses updates; synchronized and ReentrantLock both protect the read-modify-write and get it right. The lock version also lets you try tryLock with a timeout.

AccountRace.javaJava
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class AccountRace {
    interface Account { void deposit(long n); void withdraw(long n); long balance(); }

    static class Unsafe implements Account {
        private long balance = 1_000_000;
        public void deposit(long n) { balance += n; }
        public void withdraw(long n) { balance -= n; }
        public long balance() { return balance; }
    }

    static class Synchronized implements Account {
        private long balance = 1_000_000;
        public synchronized void deposit(long n) { balance += n; }
        public synchronized void withdraw(long n) { balance -= n; }
        public synchronized long balance() { return balance; }
    }

    static class Locked implements Account {
        private final ReentrantLock lock = new ReentrantLock();
        private long balance = 1_000_000;
        public void deposit(long n) { lock.lock(); try { balance += n; } finally { lock.unlock(); } }
        public void withdraw(long n) { lock.lock(); try { balance -= n; } finally { lock.unlock(); } }
        public long balance() { lock.lock(); try { return balance; } finally { lock.unlock(); } }
    }

    static void run(String label, Account account) {
        long start = System.nanoTime();
        try (ExecutorService pool = Executors.newFixedThreadPool(50)) {
            for (int t = 0; t < 50; t++) {
                pool.submit(() -> {
                    for (int i = 0; i < 1_000; i++) {
                        account.deposit(1);
                        account.withdraw(1);
                    }
                });
            }
        }
        System.out.printf("%-13s balance %,d (expected 1,000,000) in %d ms%n",
            label, account.balance(), (System.nanoTime() - start) / 1_000_000);
    }

    public static void main(String[] args) {
        run("unsafe", new Unsafe());
        run("synchronized", new Synchronized());
        run("lock", new Locked());
    }
}

Check your understanding

  1. Why is count++ unsafe when several threads run it?

  2. Which class makes a counter thread-safe without locks?

  3. When do virtual threads help most?

  4. Why release a ReentrantLock in a finally block?

Ask AI