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).
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,
ConcurrentHashMapor 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.
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());
}
}