Java Multithreading Cheat Sheet
Covers creating threads, ExecutorService thread pools, synchronization with locks and atomics, and composing async work with CompletableFuture.
Creating Threads
Two ways to define a task and run it on a new thread.
// Extending Threadclass MyThread extends Thread { @Override public void run() { System.out.println("Running in " + getName()); }}new MyThread().start(); // never call run() directly - start() spawns a new thread// Implementing Runnable (preferred - allows extending other classes)Runnable task = () -> System.out.println("Task running");Thread t = new Thread(task);t.start();t.join(); // wait for thread to finish
ExecutorService & Thread Pools
Manage a reusable pool of worker threads instead of raw Thread objects.
ExecutorService pool = Executors.newFixedThreadPool(4);Future<Integer> future = pool.submit(() -> { Thread.sleep(100); return 42;});try { Integer result = future.get(); // blocks until the result is ready} catch (InterruptedException | ExecutionException e) { e.printStackTrace();}pool.shutdown(); // stop accepting new tasks, let running ones finish
Synchronization & Locks
Coordinate access to shared mutable state safely.
class Counter { private int count = 0; public synchronized void increment() { // intrinsic lock on 'this' count++; }}// Explicit lock for more controlprivate final ReentrantLock lock = new ReentrantLock();public void safeUpdate() { lock.lock(); try { // critical section } finally { lock.unlock(); // always unlock in finally }}// Atomic classes avoid locking entirely for simple countersprivate final AtomicInteger atomicCount = new AtomicInteger(0);atomicCount.incrementAndGet();
Key Concurrency Building Blocks
The core classes and keywords for writing safe concurrent code.
- Thread vs Runnable- Prefer implementing Runnable over extending Thread to keep classes free to extend something else
- synchronized keyword- Applied to methods or blocks to enforce mutual exclusion via an intrinsic monitor lock
- volatile- Guarantees visibility of a variable's latest value across threads, but not atomicity of compound operations
- ExecutorService- Manages a pool of reusable worker threads instead of creating raw Thread objects
- CompletableFuture- Composable async pipeline: supplyAsync().thenApply().thenAccept()
- ConcurrentHashMap- Thread-safe map with fine-grained locking, safe for concurrent reads/writes
- CountDownLatch / CyclicBarrier- Coordination primitives for waiting on multiple threads to reach a point
- Deadlock- Occurs when two or more threads wait on each other's locks forever; avoid by always acquiring locks in a consistent order
CompletableFuture Pipelines
Chain async transformations without blocking, and handle errors inline.
CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> fetchUser(1)) // runs on ForkJoinPool.commonPool() by default .thenApply(user -> user.getName()) .thenApply(String::toUpperCase) .exceptionally(ex -> "UNKNOWN"); // fallback on errorfuture.thenAccept(System.out::println); // consume the final resultCompletableFuture<Void> all = CompletableFuture.allOf(future1, future2); // wait for all
Virtual Threads (Java 21+)
Lightweight JVM-managed threads that make thread-per-task servers cheap at massive scale.
// One virtual thread per task - can create millions without exhausting OS threadstry (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = IntStream.range(0, 100_000) .mapToObj(i -> executor.submit(() -> { Thread.sleep(Duration.ofMillis(10)); // blocking calls are cheap - carrier thread is freed return "task-" + i; })) .toList(); for (Future<String> f : futures) { f.get(); }} // executor auto-closes and awaits termination// Virtual threads are daemon by default and NOT pooled - create freely, don't reuse
Structured Concurrency (Preview, Java 21+)
Treat a group of related subtasks as a single unit of work with unified cancellation and error propagation.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Future<String> user = scope.fork(() -> fetchUser(userId)); Future<List<Order>> orders = scope.fork(() -> fetchOrders(userId)); scope.join(); // wait for both, or until first failure scope.throwIfFailed(); // propagate any subtask exception return new Profile(user.resultNow(), orders.resultNow());} // scope.close() ensures no forked thread outlives this block
ReadWriteLock & StampedLock
Allow concurrent readers while still serializing writers for read-heavy shared state.
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();private final Map<String, String> cache = new HashMap<>();public String read(String key) { rwLock.readLock().lock(); // multiple readers allowed concurrently try { return cache.get(key); } finally { rwLock.readLock().unlock(); }}public void write(String key, String value) { rwLock.writeLock().lock(); // exclusive - blocks readers and writers try { cache.put(key, value); } finally { rwLock.writeLock().unlock(); }}// StampedLock adds an optimistic read mode - faster when writes are rareprivate final StampedLock stamped = new StampedLock();public double distanceFromOrigin(double x, double y) { long stamp = stamped.tryOptimisticRead(); double curX = x, curY = y; if (!stamped.validate(stamp)) { // a write happened concurrently, fall back to a real lock stamp = stamped.readLock(); try { curX = x; curY = y; } finally { stamped.unlockRead(stamp); } } return Math.sqrt(curX * curX + curY * curY);}
Choosing & Tuning Thread Pools
Match the pool type to the workload; wrong sizing causes queue buildup or resource exhaustion.
// CPU-bound work: size pool close to available coresExecutorService cpuPool = Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors());// I/O-bound work (blocking calls): larger pool, or better, use virtual threadsExecutorService ioPool = new ThreadPoolExecutor( 10, 50, // core, max pool size 60L, TimeUnit.SECONDS, // idle thread keep-alive new LinkedBlockingQueue<>(200), // bounded queue - avoid unbounded (OOM risk) new ThreadPoolExecutor.CallerRunsPolicy() // backpressure: caller runs the task itself);// Avoid Executors.newCachedThreadPool() in production without limits -// it grows unbounded under load and can exhaust system resources
Combining Independent CompletableFutures
Run independent async calls concurrently and merge their results once both complete.
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> fetchOrders(id));CompletableFuture<Profile> profileFuture = userFuture .thenCombine(ordersFuture, (user, orders) -> new Profile(user, orders));// anyOf races several futures, resolving with whichever finishes firstCompletableFuture<Object> fastest = CompletableFuture.anyOf(mirrorA, mirrorB, mirrorC);// Custom executor avoids starving the shared ForkJoinPool.commonPool()ExecutorService dedicated = Executors.newFixedThreadPool(8);CompletableFuture.supplyAsync(() -> fetchUser(id), dedicated) .thenApplyAsync(User::getName, dedicated);
Advanced Concurrency Pitfalls
Failure modes that only show up under real concurrent load, not in single-threaded tests.
- Double-checked locking needs volatile- A lazily-initialized singleton without a volatile field can publish a partially-constructed object to other threads due to instruction reordering
- ThreadLocal leaks in pooled threads- Values set via ThreadLocal on an ExecutorService thread persist across tasks (threads are reused); always call remove() when done
- Compound check-then-act races- if (!map.containsKey(k)) map.put(k, v) is not atomic even on ConcurrentHashMap; use putIfAbsent() or compute() instead
- Livelock vs deadlock- Threads that keep responding to each other (e.g. both stepping aside repeatedly) stay active but make no progress, unlike a deadlock where they block forever
- wait()/notify() vs java.util.concurrent- Raw Object.wait()/notify() require careful spurious-wakeup handling in a while loop; prefer higher-level constructs like Condition, Semaphore, or BlockingQueue
- Fork/Join work-stealing- ForkJoinPool lets idle worker threads steal tasks from busy threads' queues, which is why deeply recursive divide-and-conquer algorithms (RecursiveTask) scale well on it
- Async stack traces- Exceptions thrown inside CompletableFuture async stages have a stack trace rooted at the async call site, not the original thread - harder to debug without proper logging context
volatile guarantees visibility but not atomicity - a volatile int count; count++; is still a race condition because increment is read-modify-write; use AtomicInteger or synchronized for compound operations on shared state.