Picture this: You’ve built a REST API to process customer orders. It works great in testing. You deploy to production, and… it can only handle one request at a time. While user A’s order is processing, users B, C, and D are waiting. Your boss is not happy. Your users are not happy. And you’re wondering: “Why is this so slow?”
Welcome to the world of concurrency problems.
I remember the first time I encountered this. I had built what I thought was a perfectly good application, only to discover it was essentially a single-lane highway when it needed to be a multi-lane superhighway. The solution? Understanding how Java handles multiple tasks simultaneously—multithreading and concurrency.
In this post, I’ll walk you through everything you need to know about Java concurrency, from the absolute basics to the common pitfalls that will bite you in production. By the end, you’ll understand not just how to write multithreaded code, but why certain approaches work and others lead to 2 AM debugging sessions.
What Are Threads? (And Why Should You Care)
Before we dive into code, let’s understand what threads actually are and why they matter.
The Single-Threaded Problem
Imagine you’re running a restaurant with only one waiter. The waiter:
- Takes order from table 1
- Goes to kitchen
- Waits for food
- Brings food back
- Then (and only then) moves to table 2
Tables 2, 3, 4, and 5 are waiting while your single waiter stands in the kitchen watching food cook. This is single-threaded execution.
Here’s what it looks like in code:
public class SingleThreadedServer {
public static void main(String[] args) {
while (true) {
Request request = waitForRequest(); // Wait for request
Response response = processRequest(request); // Process it
sendResponse(response); // Send response
// Only NOW can we handle the next request!
}
}
static Response processRequest(Request request) {
// This takes 5 seconds
callDatabase(); // 2 seconds
callExternalAPI(); // 3 seconds
return buildResponse();
}
}Problem: If processing takes 5 seconds, you can handle maximum 12 requests per minute. The 13th request? Waiting.
Enter Threads: Multiple Workers
Now imagine your restaurant hires 10 waiters. Each can:
- Take orders independently
- Wait for different tables’ food
- Serve multiple customers simultaneously
This is multithreading. Each waiter is a thread, working concurrently.
public class MultiThreadedServer {
public static void main(String[] args) {
while (true) {
Request request = waitForRequest();
// Spawn a new thread for each request
new Thread(() -> {
Response response = processRequest(request);
sendResponse(response);
}).start();
// Immediately ready for next request!
}
}
}Now you can handle multiple requests simultaneously. Problem solved, right?
Not quite. This is where it gets interesting (and complex).
Program vs Process vs Thread
Let’s clarify some terminology:
- Program: Code sitting on your disk (e.g.,
myapp.jar) - Process: A running instance of a program (when you execute
java myapp.jar) - Thread: A unit of execution within a process
One process can have multiple threads. They all share:
- The same memory space
- The same heap
- The same code
- The same open files
But each thread has its own:
- Execution path (can be at different lines of code)
- Call stack
- Local variables
Think of a process as a company, and threads as employees working in different departments but sharing the same office space and resources.
Creating Your First Thread
Let’s get our hands dirty with code. There are two main ways to create threads in Java.
Method 1: Extending Thread Class
public class MyThread extends Thread {
private String taskName;
public MyThread(String taskName) {
this.taskName = taskName;
}
@Override
public void run() {
System.out.println(taskName + " started on " + Thread.currentThread().getName());
// Simulate some work
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(taskName + " completed");
}
public static void main(String[] args) {
MyThread thread1 = new MyThread("Task 1");
MyThread thread2 = new MyThread("Task 2");
MyThread thread3 = new MyThread("Task 3");
thread1.start(); // NOT thread1.run()!
thread2.start();
thread3.start();
System.out.println("All threads started on " + Thread.currentThread().getName());
}
}Output:
All threads started on main
Task 1 started on Thread-0
Task 2 started on Thread-1
Task 3 started on Thread-2
Task 1 completed
Task 2 completed
Task 3 completedImportant: Use start(), not run()!
start(): Creates a new thread and runs code in that threadrun(): Runs code in the current thread (defeats the purpose!)
Method 2: Implementing Runnable (Preferred)
public class MyTask implements Runnable {
private String taskName;
public MyTask(String taskName) {
this.taskName = taskName;
}
@Override
public void run() {
System.out.println(taskName + " started on " + Thread.currentThread().getName());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(taskName + " completed");
}
public static void main(String[] args) {
Thread thread1 = new Thread(new MyTask("Task 1"));
Thread thread2 = new Thread(new MyTask("Task 2"));
Thread thread3 = new Thread(new MyTask("Task 3"));
thread1.start();
thread2.start();
thread3.start();
}
}Why prefer Runnable?
- Java doesn’t support multiple inheritance; if you extend
Thread, you can’t extend anything else - Separates the task (what to do) from the thread (how to execute)
- More flexible and composable
Modern Way: Lambda Expressions
With Java 8+, you can use lambdas for simple tasks:
public class LambdaThreads {
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
System.out.println("Task 1 running on " + Thread.currentThread().getName());
});
Thread thread2 = new Thread(() -> {
System.out.println("Task 2 running on " + Thread.currentThread().getName());
});
thread1.start();
thread2.start();
}
}Clean and concise!
Thread Lifecycle States
A thread goes through several states during its life:
NEW → RUNNABLE → RUNNING → TERMINATED
↓ ↑
↓ ↑
↓ TIMED_WAITING
↓ ↑
↓ ↑
BLOCKED ← WAITINGStates explained:
- NEW: Thread created but not started
- RUNNABLE: Thread started and ready to run (waiting for CPU)
- RUNNING: Thread is executing
- BLOCKED: Waiting for a lock/monitor
- WAITING: Waiting indefinitely for another thread
- TIMED_WAITING: Waiting for a specified time (e.g.,
Thread.sleep()) - TERMINATED: Thread finished execution
The Dark Side: Common Concurrency Problems
Now comes the fun part—where things can go horribly wrong. Multithreading introduces problems that simply don’t exist in single-threaded code.
Problem 1: Race Conditions
A race condition occurs when multiple threads access shared data and try to change it simultaneously.
Here’s a classic example—a bank account:
public class BankAccount {
private int balance = 1000;
public void withdraw(int amount) {
// Check if sufficient balance
if (balance >= amount) {
// Simulate some processing time
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Withdraw the amount
balance -= amount;
System.out.println(Thread.currentThread().getName() +
" withdrew " + amount + ", balance: " + balance);
} else {
System.out.println(Thread.currentThread().getName() +
" insufficient funds");
}
}
public int getBalance() {
return balance;
}
}
public class RaceConditionDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
// Two threads trying to withdraw simultaneously
Thread wife = new Thread(() -> account.withdraw(800), "Wife");
Thread husband = new Thread(() -> account.withdraw(800), "Husband");
wife.start();
husband.start();
wife.join();
husband.join();
System.out.println("Final balance: " + account.getBalance());
}
}Output:
Wife withdrew 800, balance: 200
Husband withdrew 800, balance: -600
Final balance: -600Wait, what?! We just allowed the account to go negative! Both threads checked the balance (1000), both saw it was sufficient, and both withdrew 800. We lost 600 that doesn’t exist!
This is a race condition. The threads “raced” to access shared data, and the outcome depends on timing—unpredictable and wrong.
Problem 2: Visibility Issues
Changes made by one thread might not be visible to other threads immediately:
public class VisibilityDemo {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread writer = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("Flag set to true");
});
Thread reader = new Thread(() -> {
while (!flag) {
// Waiting for flag to become true
// This might loop forever!
}
System.out.println("Flag is now true!");
});
reader.start();
writer.start();
}
}The reader thread might never see the flag change due to CPU caching and compiler optimizations. It could loop forever!
Solution: Use the volatile keyword:
private static volatile boolean flag = false;volatile ensures:
- Writes are immediately visible to all threads
- Reads always get the latest value
Problem 3: Deadlock
A deadlock occurs when two or more threads are waiting for each other to release resources, creating a circular dependency.
Classic example:
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock 2...");
synchronized (lock2) {
System.out.println("Thread 1: Holding lock 1 & 2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock 1...");
synchronized (lock1) {
System.out.println("Thread 2: Holding lock 1 & 2");
}
}
});
thread1.start();
thread2.start();
}
}Output:
Thread 1: Holding lock 1...
Thread 2: Holding lock 2...
Thread 1: Waiting for lock 2...
Thread 2: Waiting for lock 1...
[Program hangs forever]What happened?
- Thread 1 holds lock1, wants lock2
- Thread 2 holds lock2, wants lock1
- Both wait forever
This is like two people trying to pass through a doorway, each waiting for the other to go first. Nobody moves.
Thread Synchronization: Making Things Safe
So how do we fix these problems? Enter synchronization.
The synchronized Keyword
The synchronized keyword ensures that only one thread can execute a block of code at a time:
public class BankAccountFixed {
private int balance = 1000;
// Method-level synchronization
public synchronized void withdraw(int amount) {
if (balance >= amount) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= amount;
System.out.println(Thread.currentThread().getName() +
" withdrew " + amount + ", balance: " + balance);
} else {
System.out.println(Thread.currentThread().getName() +
" insufficient funds");
}
}
public synchronized int getBalance() {
return balance;
}
}Now when we run the same test:
Wife withdrew 800, balance: 200
Husband insufficient funds
Final balance: 200Perfect! The synchronized keyword ensures only one thread can execute withdraw() at a time.
How synchronized Works
When you use synchronized, Java uses a monitor (also called an intrinsic lock):
// Synchronized method
public synchronized void method() {
// Only one thread at a time
}
// Is equivalent to:
public void method() {
synchronized(this) {
// Only one thread at a time
}
}Key points:
- Every object in Java has a monitor
- Only one thread can hold a monitor at a time
- Other threads wait until the monitor is released
Block-Level Synchronization
You don’t always need to synchronize the entire method:
public class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
// Non-critical code (doesn't need sync)
System.out.println("About to increment");
// Critical section (needs sync)
synchronized (lock) {
count++;
}
// More non-critical code
System.out.println("Incremented");
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}Benefits:
- Faster execution (less time spent synchronized)
- More granular control
- Better concurrency (threads don’t block unnecessarily)
The Cost of Synchronization
Synchronization isn’t free:
- Performance overhead: Acquiring and releasing locks takes time
- Reduced concurrency: Threads wait, reducing parallelism
- Potential deadlocks: More locks = more deadlock risk
Rule of thumb: Synchronize only what needs synchronization, and keep critical sections as small as possible.
Explicit Locks: More Control
Sometimes synchronized isn’t enough. You need more control. Enter java.util.concurrent.locks.
ReentrantLock: A Better Lock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankAccountWithLock {
private int balance = 1000;
private final Lock lock = new ReentrantLock();
public void withdraw(int amount) {
lock.lock(); // Acquire lock
try {
if (balance >= amount) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= amount;
System.out.println(Thread.currentThread().getName() +
" withdrew " + amount + ", balance: " + balance);
} else {
System.out.println(Thread.currentThread().getName() +
" insufficient funds");
}
} finally {
lock.unlock(); // Always unlock in finally!
}
}
}Why use ReentrantLock over synchronized?
1. Try-Lock: Avoid Blocking Forever
public boolean tryWithdraw(int amount) {
if (lock.tryLock()) { // Try to acquire, don't block
try {
if (balance >= amount) {
balance -= amount;
return true;
}
return false;
} finally {
lock.unlock();
}
} else {
System.out.println("Could not acquire lock, try again later");
return false;
}
}2. Timeout: Wait for a Limited Time
public boolean tryWithdrawWithTimeout(int amount) throws InterruptedException {
if (lock.tryLock(1, TimeUnit.SECONDS)) { // Wait max 1 second
try {
if (balance >= amount) {
balance -= amount;
return true;
}
return false;
} finally {
lock.unlock();
}
} else {
System.out.println("Timeout waiting for lock");
return false;
}
}3. Interruptible Lock Acquisition
public void withdrawInterruptibly(int amount) throws InterruptedException {
lock.lockInterruptibly(); // Can be interrupted while waiting
try {
if (balance >= amount) {
balance -= amount;
}
} finally {
lock.unlock();
}
}4. Fair Locks: First Come, First Served
// Fair lock - threads acquire in order they requested
private final Lock fairLock = new ReentrantLock(true);
// Unfair lock (default) - faster but no guarantee of order
private final Lock unfairLock = new ReentrantLock(false);Fair locks are slower but prevent thread starvation.
When to Use Which?
Use synchronized when:
- Simple locking needs
- Don’t need timeout or try-lock
- Want cleaner, more readable code
Use ReentrantLock when:
- Need try-lock or timeout functionality
- Need fair lock behavior
- Need to interrupt lock acquisition
- Need advanced features like conditions
Thread Pools: Managing Threads Efficiently
Creating threads is expensive. Creating thousands of threads? Terrible idea.
The Problem with Unlimited Threads
// DON'T DO THIS!
public class BadServer {
public static void main(String[] args) {
while (true) {
Request request = waitForRequest();
// Create a new thread for every request!
new Thread(() -> processRequest(request)).start();
}
}
}Problems:
- Thread creation is expensive (memory, time)
- Each thread consumes ~1 MB of memory
- Context switching overhead increases
- Can exhaust system resources
- Eventually:
OutOfMemoryError
Enter Thread Pools
A thread pool maintains a pool of worker threads that can be reused:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class GoodServer {
private static final ExecutorService executor = Executors.newFixedThreadPool(10);
public static void main(String[] args) {
while (true) {
Request request = waitForRequest();
// Submit task to thread pool
executor.submit(() -> processRequest(request));
}
}
}Benefits:
- Threads are created once and reused
- Controlled number of concurrent threads
- Automatic task queuing when all threads are busy
- Better resource management
Types of Thread Pools
1. Fixed Thread Pool
ExecutorService executor = Executors.newFixedThreadPool(10);
// Always has exactly 10 threads
// Tasks queue up if all threads are busyUse when: You know the optimal thread count (usually number of CPU cores for CPU-bound tasks).
2. Cached Thread Pool
ExecutorService executor = Executors.newCachedThreadPool();
// Creates new threads as needed
// Reuses idle threads
// Terminates threads idle for 60 secondsUse when: You have many short-lived tasks and unpredictable load.
3. Single Thread Executor
ExecutorService executor = Executors.newSingleThreadExecutor();
// Only one thread
// Tasks execute sequentiallyUse when: You need guaranteed sequential execution (e.g., logging).
4. Scheduled Thread Pool
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
// Schedule task to run after delay
executor.schedule(() -> {
System.out.println("Runs after 5 seconds");
}, 5, TimeUnit.SECONDS);
// Schedule task to run periodically
executor.scheduleAtFixedRate(() -> {
System.out.println("Runs every 10 seconds");
}, 0, 10, TimeUnit.SECONDS);Use when: You need scheduled or periodic tasks.
Complete Example: Processing Tasks
import java.util.concurrent.*;
import java.util.List;
import java.util.ArrayList;
public class TaskProcessorDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newFixedThreadPool(5);
// Submit tasks and collect Futures
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
final int taskId = i;
Future<Integer> future = executor.submit(() -> {
System.out.println("Processing task " + taskId +
" on " + Thread.currentThread().getName());
Thread.sleep(1000); // Simulate work
return taskId * 2; // Return result
});
futures.add(future);
}
// Get results
System.out.println("\nResults:");
for (Future<Integer> future : futures) {
System.out.println("Result: " + future.get());
}
// Shutdown executor
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("All tasks completed");
}
}Proper Shutdown
Always shutdown executors properly:
// Graceful shutdown
executor.shutdown(); // No new tasks accepted
executor.awaitTermination(60, TimeUnit.SECONDS); // Wait for completion
// Forceful shutdown if needed
if (!executor.isTerminated()) {
List<Runnable> droppedTasks = executor.shutdownNow(); // Interrupt running tasks
System.out.println("Dropped " + droppedTasks.size() + " tasks");
}Concurrent Collections: Thread-Safe Data Structures
Regular collections aren’t thread-safe. Using them from multiple threads leads to corruption.
The Problem
// NOT THREAD-SAFE!
Map<String, Integer> map = new HashMap<>();
// Multiple threads doing this = disaster
map.put("key", value);Result: Data corruption, lost updates, crashes.
Solution 1: Synchronized Collections (Old Way)
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
// Now thread-safe, but...
// - Every operation is synchronized (slow)
// - Iteration still requires external synchronization
synchronized (map) {
for (String key : map.keySet()) {
// Must synchronize iteration!
}
}Problems: Poor performance, awkward iteration.
Solution 2: Concurrent Collections (Better)
ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Thread-safe operations
map.put("key", 1);
map.putIfAbsent("key", 2); // Atomic: only put if absent
// Atomic updates
map.compute("counter", (key, oldValue) -> {
return (oldValue == null) ? 1 : oldValue + 1;
});
// Atomic increment
map.computeIfPresent("counter", (key, oldValue) -> oldValue + 1);
// No external synchronization needed for iteration!
for (String key : map.keySet()) {
System.out.println(key + ": " + map.get(key));
}Benefits:
- Much faster than
Collections.synchronizedMap() - Lock-free reads
- Fine-grained locking for writes
- Safe iteration without external synchronization
CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArrayList;
List<String> list = new CopyOnWriteArrayList<>();
// Thread-safe add/remove
list.add("item1");
list.add("item2");
// Safe iteration (even if list is modified during iteration)
for (String item : list) {
System.out.println(item);
// Other threads can modify list safely
}Use when:
- Many reads, few writes
- Iteration is more common than modification
- You can afford the copy cost on writes
BlockingQueue: Producer-Consumer Pattern
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ArrayBlockingQueue;
public class ProducerConsumerDemo {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
// Producer thread
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
queue.put(i); // Blocks if queue is full!
System.out.println("Produced: " + i);
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// Consumer thread
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
Integer item = queue.take(); // Blocks if queue is empty!
System.out.println("Consumed: " + item);
Thread.sleep(300);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
producer.start();
consumer.start();
}
}Key methods:
put(): Add item, block if fulltake(): Remove item, block if emptyoffer(): Add item, return false if full (non-blocking)poll(): Remove item, return null if empty (non-blocking)
The Future of Async: CompletableFuture
Sometimes you need to wait for a result from a thread. The old Future interface was limited. CompletableFuture is much more powerful.
Basic Usage
import java.util.concurrent.CompletableFuture;
public class CompletableFutureDemo {
public static void main(String[] args) {
// Run async task
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Fetching data on " + Thread.currentThread().getName());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Data from API";
});
// Do other work while waiting
System.out.println("Doing other work...");
// Get result (blocks until complete)
try {
String result = future.get();
System.out.println("Result: " + result);
} catch (Exception e) {
e.printStackTrace();
}
}
}Chaining Operations
CompletableFuture.supplyAsync(() -> {
// Step 1: Fetch user ID
return fetchUserIdFromDatabase();
})
.thenApply(userId -> {
// Step 2: Fetch user details
return fetchUserDetails(userId);
})
.thenApply(userDetails -> {
// Step 3: Transform data
return userDetails.toUpperCase();
})
.thenAccept(result -> {
// Step 4: Use result
System.out.println("Final result: " + result);
})
.exceptionally(ex -> {
// Handle any errors
System.err.println("Error: " + ex.getMessage());
return null;
});Combining Multiple Futures
public class CombiningFutures {
public static void main(String[] args) throws Exception {
// Start multiple async operations
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
sleep(1000);
return "Result from API 1";
});
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
sleep(2000);
return "Result from API 2";
});
CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> {
sleep(1500);
return "Result from API 3";
});
// Wait for all to complete
CompletableFuture<Void> allOf = CompletableFuture.allOf(future1, future2, future3);
allOf.thenRun(() -> {
try {
System.out.println(future1.get());
System.out.println(future2.get());
System.out.println(future3.get());
} catch (Exception e) {
e.printStackTrace();
}
}).join();
System.out.println("All tasks completed");
}
static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}Why It Gets Complex
While powerful, CompletableFuture can lead to complex chains:
CompletableFuture.supplyAsync(() -> fetchUser())
.thenCompose(user -> fetchOrders(user.getId()))
.thenCompose(orders -> CompletableFuture.allOf(
orders.stream()
.map(order -> processOrder(order))
.toArray(CompletableFuture[]::new)
))
.thenApply(v -> aggregateResults())
.exceptionally(ex -> handleError(ex))
.thenAccept(result -> sendResponse(result));This is where it gets messy. Debugging async chains is hard. Error handling is tricky. Stack traces become useless.
There had to be a better way… (hint: Java 21 virtual threads solve this—more on that in another post!)
Common Patterns and Best Practices
Pattern 1: Producer-Consumer
Already covered with BlockingQueue. Perfect for decoupling producers and consumers.
Pattern 2: Thread-Local Storage
Sometimes each thread needs its own copy of a variable:
public class ThreadLocalDemo {
// Each thread gets its own copy
private static ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public static String formatDate(Date date) {
// Each thread uses its own SimpleDateFormat instance
// (SimpleDateFormat is not thread-safe!)
return dateFormat.get().format(date);
}
}Use when:
- You have a non-thread-safe object (like
SimpleDateFormat) - Creating the object is expensive
- You don’t want to synchronize access
Warning: Be careful with thread pools—ThreadLocal persists across task executions!
Pattern 3: Immutability for Thread Safety
The easiest way to make something thread-safe? Make it immutable.
// Immutable class - inherently thread-safe
public final class ImmutableUser {
private final String name;
private final int age;
private final List<String> roles;
public ImmutableUser(String name, int age, List<String> roles) {
this.name = name;
this.age = age;
// Defensive copy
this.roles = Collections.unmodifiableList(new ArrayList<>(roles));
}
public String getName() { return name; }
public int getAge() { return age; }
public List<String> getRoles() { return roles; }
// No setters - immutable!
}Benefits:
- No synchronization needed
- Can be shared freely between threads
- Simpler to reason about
Pattern 4: Avoid Shared Mutable State
The best concurrency bug is the one you don’t have:
// BAD - shared mutable state
public class Counter {
private int count = 0; // Shared!
public void increment() {
count++; // Multiple threads = problems
}
}
// GOOD - no shared state
public class BetterCounter {
public int increment(int current) {
return current + 1; // Pure function, no state
}
}
// ALSO GOOD - use atomic variables
public class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Thread-safe!
}
}Performance Considerations
Multithreading isn’t always faster. Sometimes it’s slower!
Context Switching Overhead
When the CPU switches between threads, it must:
- Save the current thread’s state
- Load the next thread’s state
- Clear CPU caches
This takes time! Too many threads = more time context switching than doing actual work.
Memory Per Thread
Each thread consumes memory:
- Stack space: ~1 MB (default on most systems)
- Thread-local variables
- Bookkeeping data structures
1,000 threads ≈ 1 GB of memory just for stacks!
CPU Cores vs Thread Count
Rule of thumb:
- CPU-bound tasks: Number of threads ≈ number of CPU cores
- More threads = more context switching, no benefit
- I/O-bound tasks: Number of threads > number of CPU cores
- Threads spend time waiting on I/O
- More threads = better CPU utilization
// For CPU-bound work
int cpuCores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuBoundExecutor = Executors.newFixedThreadPool(cpuCores);
// For I/O-bound work
ExecutorService ioBoundExecutor = Executors.newFixedThreadPool(cpuCores * 2);
// Or even more, depending on I/O wait timeCommon Pitfalls
- Creating too many threads: Use thread pools!
- Not shutting down executors: Memory leaks
- Forgetting to handle InterruptedException: Swallowing interrupts
- Overusing synchronization: Kills performance
- Underusing synchronization: Data corruption
- Holding locks too long: Reduces concurrency
Real-World Example: Concurrent Web Scraper
Let’s build a complete example: scraping multiple web pages concurrently.
import java.io.*;
import java.net.*;
import java.util.*;
import java.util.concurrent.*;
public class WebScraper {
private final ExecutorService executor;
private final int threadPoolSize;
public WebScraper(int threadPoolSize) {
this.threadPoolSize = threadPoolSize;
this.executor = Executors.newFixedThreadPool(threadPoolSize);
}
public Map<String, String> scrapeUrls(List<String> urls) throws InterruptedException, ExecutionException {
Map<String, String> results = new ConcurrentHashMap<>();
List<Future<Void>> futures = new ArrayList<>();
long startTime = System.currentTimeMillis();
// Submit all scraping tasks
for (String url : urls) {
Future<Void> future = executor.submit(() -> {
try {
String content = fetchUrl(url);
results.put(url, content);
System.out.println("Scraped: " + url + " on " +
Thread.currentThread().getName());
} catch (IOException e) {
System.err.println("Error scraping " + url + ": " + e.getMessage());
results.put(url, "ERROR: " + e.getMessage());
}
return null;
});
futures.add(future);
}
// Wait for all to complete
for (Future<Void> future : futures) {
future.get();
}
long endTime = System.currentTimeMillis();
System.out.println("\nScraped " + urls.size() + " URLs in " +
(endTime - startTime) + " ms using " + threadPoolSize + " threads");
return results;
}
private String fetchUrl(String urlString) throws IOException {
URL url = new URL(urlString);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream()))) {
StringBuilder content = new StringBuilder();
String line;
int lines = 0;
while ((line = reader.readLine()) != null && lines < 10) {
content.append(line).append("\n");
lines++;
}
return content.toString();
}
}
public void shutdown() {
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
public static void main(String[] args) throws Exception {
List<String> urls = Arrays.asList(
"https://www.example.com",
"https://www.github.com",
"https://www.stackoverflow.com",
"https://www.reddit.com",
"https://www.wikipedia.org"
);
// Test with single thread
System.out.println("=== Single Thread ===");
WebScraper singleThread = new WebScraper(1);
singleThread.scrapeUrls(urls);
singleThread.shutdown();
// Test with multiple threads
System.out.println("\n=== 5 Threads ===");
WebScraper multiThread = new WebScraper(5);
multiThread.scrapeUrls(urls);
multiThread.shutdown();
}
}Expected output:
=== Single Thread ===
Scraped: https://www.example.com on pool-1-thread-1
Scraped: https://www.github.com on pool-1-thread-1
Scraped: https://www.stackoverflow.com on pool-1-thread-1
Scraped: https://www.reddit.com on pool-1-thread-1
Scraped: https://www.wikipedia.org on pool-1-thread-1
Scraped 5 URLs in 3250 ms using 1 threads
=== 5 Threads ===
Scraped: https://www.example.com on pool-2-thread-1
Scraped: https://www.github.com on pool-2-thread-2
Scraped: https://www.stackoverflow.com on pool-2-thread-3
Scraped: https://www.reddit.com on pool-2-thread-4
Scraped: https://www.wikipedia.org on pool-2-thread-5
Scraped 5 URLs in 750 ms using 5 threads4x faster with 5 threads! For I/O-bound tasks like web scraping, concurrency provides significant speedups.
The Limitations (And What Comes Next)
We’ve covered a lot, but there are fundamental limitations with Java’s traditional threading model:
Problem 1: Platform Threads Are Expensive
- ~1 MB of memory per thread
- Expensive to create and destroy
- Limited by OS resources
- Typically can’t have more than a few thousand
Problem 2: Thread Pool Tuning Is Hard
How many threads should you use? It depends on:
- Number of CPU cores
- Type of work (CPU-bound vs I/O-bound)
- Average task duration
- Memory constraints
- Traffic patterns
There’s no magic number. You tune, load test, retune, and hope for the best.
Problem 3: Complexity
Look at the CompletableFuture chains we wrote. They work, but they’re:
- Hard to read
- Hard to debug
- Error-prone
- Stack traces are useless
Reactive programming (Project Reactor, RxJava) makes this even more complex with operators like flatMap, zip, switchMap, etc.
Problem 4: Blocking Operations
When a thread blocks on I/O:
- It consumes memory while doing nothing
- Can’t be used for other work
- Limits scalability
The fundamental issue: Platform threads are a scarce resource, but we’re forced to use them for tasks that mostly wait.
What If There Was a Better Way?
Imagine if:
- Threads were so cheap you could create millions
- No need to tune thread pools
- Write simple, blocking code that scales massively
- No callback hell or reactive complexity
This isn’t fantasy. It’s Java 21 with Virtual Threads.
Java 21 introduces a revolutionary concurrency model that solves all these problems. Virtual threads are lightweight, JVM-managed threads that allow you to write simple, synchronous code while achieving massive scalability.
But that’s a story for another post. Want to learn about virtual threads and how they change everything? Check out my deep dive: Java 21 Concurrency Revolution: Why Your Multithreaded Code Just Got 10x Simpler.
Conclusion
Concurrency in Java is powerful but complex. Let’s recap the key takeaways:
Core Concepts:
- Threads allow multiple tasks to run simultaneously
- Each thread has its own execution path but shares memory
- Creating threads is expensive; use thread pools
Common Problems:
- Race conditions: Multiple threads accessing shared data
- Deadlocks: Circular dependencies on locks
- Visibility issues: Changes not visible across threads
Solutions:
- Use
synchronizedfor simple locking - Use
ReentrantLockfor advanced control - Use concurrent collections (
ConcurrentHashMap, etc.) - Use thread pools (
ExecutorService) instead of raw threads - Prefer immutability and avoid shared mutable state
Best Practices:
- Synchronize only what needs synchronization
- Keep critical sections small
- Use appropriate thread pool sizes
- Always shutdown executors
- Handle
InterruptedExceptionproperly - Test concurrent code thoroughly
Remember: The easiest concurrency bug to fix is the one you don’t create. Prefer simple, single-threaded solutions when possible. Add concurrency only when needed, and add it carefully.
What’s Next?
Now that you understand the fundamentals, here are your next steps:
- Practice: Write concurrent programs. Make mistakes. Learn from them.
- Read: Study the
java.util.concurrentpackage documentation - Explore: Look into more advanced topics:
- Fork/Join framework
- Parallel streams
- Atomic variables (
AtomicInteger, etc.) - Phasers and barriers
- Advance: Learn about Java 21’s virtual threads (seriously, they’re game-changing)
Got questions about Java concurrency? Run into threading issues? I’d love to hear about your experiences. Feel free to reach out on LinkedIn!
And don’t forget—when you’re ready to learn how Java 21 makes all of this 10x simpler with virtual threads, check out my next post!
References
- Java Concurrency in Practice - Brian Goetz (the definitive book)
- Oracle Java Tutorials: Concurrency
- java.util.concurrent package documentation
- JEP 444: Virtual Threads - Preview of what’s next!