Lesson 09: Threads & Concurrency
Master multi-threading in Java: understand threads, handle concurrency safely, avoid common pitfalls, and build responsive applications that can do multiple things simultaneously.
Introduction
Imagine trying to cook a complex meal where you need to boil pasta, sauté vegetables, and prepare sauce all at the same time. If you did each task one after another, dinner would take forever and the first dish would be cold before the last was ready. Instead, you multitask - starting the pasta water while chopping vegetables, then stirring the sauce while the pasta cooks. This is exactly what threads allow your Java programs to do: perform multiple tasks simultaneously instead of waiting for each one to finish. Threads are lightweight processes that share the same memory space but can run independently, making your applications faster and more responsive. However, just like coordinating multiple cooking tasks can lead to burnt food or kitchen chaos without proper planning, managing multiple threads requires careful attention to synchronization and thread safety. This lesson will teach you how to create and manage threads, understand the challenges of concurrent programming, and write safe multi-threaded code that performs well without breaking.
Understanding Threads
Definition
A thread is the smallest unit of execution within a process that can run independently while sharing the same memory space with other threads. In Java, every application starts with a main thread that executes your main method, but you can create additional threads to perform tasks concurrently. Threads allow your program to do multiple things at once - like downloading a file while updating the user interface, or processing multiple user requests simultaneously in a web server. Each thread has its own stack for local variables and method calls, but all threads in a process share the same heap memory where objects are stored.
Analogy
Think of threads like workers in a busy restaurant kitchen. The kitchen (your Java program) has multiple chefs (threads) working simultaneously. Each chef has their own workspace and tools (thread stack), but they all share the same ingredients, ovens, and storage areas (shared heap memory). The head chef coordinates everyone to ensure orders are completed efficiently without chefs interfering with each other's work. One chef might be grilling meat while another prepares salads and a third plates desserts. If they don't coordinate properly - like two chefs trying to use the same pan at once - chaos ensues. But when they work together smoothly, the restaurant serves customers much faster than if one chef did everything sequentially.
Examples
Single vs Multi-threaded execution:
// Single-threaded: tasks run one after another
doTask1(); // takes 2 seconds
doTask2(); // takes 3 seconds
doTask3(); // takes 1 second
// Total time: 6 seconds
Multi-threaded: tasks run simultaneously:
Thread t1 = new Thread(() -> doTask1());
Thread t2 = new Thread(() -> doTask2());
Thread t3 = new Thread(() -> doTask3());
// All start at once, total time: 3 seconds (longest task)
Checking if code runs on main thread:
System.out.println("Current thread: " + Thread.currentThread().getName());
// Output: Current thread: main
Getting thread information:
Thread current = Thread.currentThread();
System.out.println("Thread ID: " + current.getId());
System.out.println("Thread priority: " + current.getPriority());
Creating Threads
Definition
Java provides several ways to create and start threads: extending the Thread class, implementing the Runnable interface, or using lambda expressions for simple tasks. The Runnable approach is preferred because it separates the task (what to do) from the thread management (how to do it), and Java only supports single inheritance so implementing Runnable keeps your class free to extend other classes. Once you create a thread, you must call start() to begin execution - calling run() directly executes the code in the current thread instead of creating a new one.
Analogy
Creating threads is like hiring employees for different jobs in your company. You can either hire someone for a specific role (extend Thread class - they can only do that one job) or hire versatile people who can take on various responsibilities (implement Runnable - they can do their job while also handling other duties). Writing a job description (Runnable) separately from hiring the person (Thread) gives you more flexibility. You can reuse the same job description for multiple employees, or assign additional responsibilities to someone later. Once you've hired someone and given them a job description, you need to officially start their employment (call start()) rather than just telling them what their job is (call run()).
Examples
Creating thread with lambda (most common):
Thread worker = new Thread(() -> {
System.out.println("Working on background task...");
});
worker.start(); // Starts new thread
Implementing Runnable interface:
class DataProcessor implements Runnable {
public void run() {
System.out.println("Processing data in background...");
}
}
new Thread(new DataProcessor()).start();
Extending Thread class:
class BackgroundTask extends Thread {
public void run() {
System.out.println("Background task running...");
}
}
new BackgroundTask().start();
Thread with parameters:
Thread calculator = new Thread(() -> {
int result = 10 * 20;
System.out.println("Calculation result: " + result);
});
calculator.start();
Named threads for debugging:
Thread namedThread = new Thread(() -> {
System.out.println("Running in: " + Thread.currentThread().getName());
}, "DataProcessor-1");
namedThread.start();
Thread Lifecycle
Definition
Threads go through several states during their lifetime: NEW (created but not started), RUNNABLE (ready to run or currently running), BLOCKED (waiting for a lock), WAITING (waiting indefinitely for another thread), TIMED_WAITING (waiting for a specific time), and TERMINATED (finished execution). Understanding these states helps you debug threading issues and write better concurrent code. Threads transition between states based on method calls like start(), join(), sleep(), and wait(), or when they acquire/release locks.
Analogy
Think of a thread's lifecycle like a customer's journey through a busy restaurant. When first created (NEW), the customer is waiting outside, not yet seated. Once they enter and are seated (RUNNABLE), they're ready to order and eat, though they might have to wait for the server's attention. Sometimes they're blocked waiting for a table to become available (BLOCKED), or waiting for their food to arrive (WAITING). They might also wait for a specific time, like waiting 15 minutes for their reserved table (TIMED_WAITING). Finally, when they finish their meal and leave (TERMINATED), their restaurant experience is complete. Throughout this journey, the customer transitions between different states based on circumstances and actions.
Examples
Thread states in action:
Thread t = new Thread(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) {}
});
System.out.println("State: " + t.getState()); // NEW
t.start();
System.out.println("State: " + t.getState()); // RUNNABLE
Waiting for thread to complete:
Thread worker = new Thread(() -> {
// Some work here
});
worker.start();
worker.join(); // Wait for worker to finish
System.out.println("Worker completed");
Thread sleeping (TIMED_WAITING):
Thread.sleep(2000); // Sleep for 2 seconds
System.out.println("Woke up after 2 seconds");
Checking if thread is alive:
Thread t = new Thread(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) {}
});
System.out.println("Alive: " + t.isAlive()); // false
t.start();
System.out.println("Alive: " + t.isAlive()); // true
Concurrency Basics
Definition
Concurrency is the ability to run multiple tasks at the same time, either truly in parallel (on multiple CPU cores) or by rapidly switching between tasks (time-slicing). In Java, concurrency allows your application to remain responsive while performing background work, handle multiple user requests simultaneously, or process large datasets faster by dividing the work among threads. However, concurrent programming introduces complexity because threads can interfere with each other when accessing shared resources, leading to unpredictable behavior if not handled properly.
Analogy
Concurrency is like managing a busy coffee shop during morning rush hour. You have multiple baristas (threads) working simultaneously to serve customers faster. One barista takes orders while another prepares drinks, and a third handles the cash register. This concurrent operation serves more customers than having one person do everything sequentially. However, coordination is crucial - if two baristas try to use the same espresso machine at once, or if the cashier and order-taker don't communicate about customizations, confusion and mistakes happen. The coffee shop needs systems (like order tickets and calling out names) to ensure smooth coordination, just like concurrent programs need synchronization mechanisms to prevent threads from interfering with each other.
Examples
Sequential vs concurrent processing:
// Sequential: process items one by one
for (int i = 0; i < 1000; i++) {
processItem(i); // Takes 1000 units of time
}
Concurrent: process items in parallel:
// Split work among 4 threads
for (int t = 0; t < 4; t++) {
final int threadId = t;
new Thread(() -> {
for (int i = threadId * 250; i < (threadId + 1) * 250; i++) {
processItem(i); // Takes ~250 units of time total
}
}).start();
}
Background task while UI stays responsive:
// Start background calculation
new Thread(() -> {
performLongCalculation();
}).start();
// UI remains responsive for user interactions
handleUserInput();
Multiple concurrent downloads:
String[] urls = {"url1", "url2", "url3"};
for (String url : urls) {
new Thread(() -> downloadFile(url)).start();
}
// All downloads happen simultaneously
Race Conditions
Definition
A race condition occurs when multiple threads access shared data simultaneously and the outcome depends on the timing of their execution. This happens because operations that appear atomic (like incrementing a counter) actually consist of multiple steps: read the current value, add one, write the new value. If two threads perform these steps at overlapping times, they might both read the same initial value and write the same result, effectively losing one increment. Race conditions lead to unpredictable behavior, data corruption, and bugs that are difficult to reproduce because they depend on precise timing.
Analogy
Imagine two roommates sharing a joint bank account, each trying to deposit $100 at the same time using different ATMs. Both ATMs read the current balance of $500 simultaneously. Each adds $100 to what they read ($500), and both write back $600 as the new balance. Instead of the correct final balance of $700, the account shows only $600 - one deposit was lost! This happens because the "deposit" operation isn't atomic; it requires reading, calculating, and writing back. In the brief moment between reading and writing, the other person's transaction interfered. Banks prevent this with sophisticated locking mechanisms, and your concurrent programs need similar protection for shared data.
Examples
Race condition example (problematic):
int counter = 0;
// Two threads both do this:
counter++; // Read, increment, write - not atomic!
// Final result is unpredictable
Demonstrating the race condition:
class Counter {
private int count = 0;
public void increment() { count++; }
public int getCount() { return count; }
}
// Result may be less than 2000 due to race condition
Lost update example:
// Thread 1: balance = balance + 100
// Thread 2: balance = balance - 50
// Both read 1000, final result unpredictable
Inconsistent state example:
class BankAccount {
private int balance = 1000;
private int transactions = 0;
public void deposit(int amount) {
balance += amount; // Step 1
transactions++; // Step 2 - another thread might see inconsistent state
}
}
Synchronization
Definition
Synchronization is the mechanism that ensures only one thread can access a shared resource at a time, preventing race conditions and maintaining data consistency. Java provides the synchronized keyword to create critical sections - blocks of code that only one thread can execute simultaneously. When a thread enters a synchronized block or method, it acquires a lock on the specified object. Other threads trying to enter synchronized code on the same object must wait until the lock is released. This serializes access to shared data, trading some performance for correctness and predictability.
Analogy
Synchronization is like having a single key for a shared office supply cabinet in a busy workplace. When someone needs supplies, they must first obtain the key, unlock the cabinet, take what they need, update the inventory list, and then return the key for the next person. Only one person can access the cabinet at a time, preventing chaos like multiple people trying to update the same inventory sheet or taking the last item without others knowing. While this approach is slower than everyone having their own key, it ensures accurate inventory and prevents conflicts. The key represents the lock, and the rule about using the key represents the synchronization mechanism that prevents concurrent access problems.
Examples
Synchronized method:
class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++; // Now thread-safe
}
}
Synchronized block:
private final Object lock = new Object();
public void updateBalance(int amount) {
synchronized(lock) {
balance += amount;
transactions++;
} // Lock released automatically
}
Synchronizing on specific object:
private final List items = new ArrayList<>();
public void addItem(String item) {
synchronized(items) {
items.add(item);
}
}
Class-level synchronization:
public static synchronized void updateGlobalCounter() {
globalCounter++; // Synchronized across all instances
}
Thread Safety
Definition
Thread safety means that a class or method behaves correctly when accessed by multiple threads simultaneously, regardless of the scheduling or interleaving of thread execution. Thread-safe code maintains its invariants and produces correct results even under concurrent access. This can be achieved through synchronization, using atomic operations, designing immutable objects, or using thread-safe data structures provided by Java's concurrent collections. Thread safety is about ensuring that shared mutable state is properly protected, while immutable objects are inherently thread-safe because they cannot be modified.
Analogy
Thread safety is like designing a public library that can handle many visitors simultaneously without chaos. The library has several safety mechanisms: some books are reference-only and can't be changed (immutable objects), checkout counters have specific procedures to prevent double-booking (synchronized methods), the card catalog system uses atomic updates that can't be interrupted mid-process (atomic operations), and some special collections are kept in locked cases with controlled access (concurrent collections). A well-designed library ensures that multiple people can use it at the same time without books getting lost, records becoming corrupted, or people interfering with each other's research. The library's rules and systems make it "thread-safe" for concurrent use.
Examples
Thread-safe with synchronization:
class ThreadSafeBank {
private int balance = 0;
public synchronized void deposit(int amount) {
balance += amount;
}
}
Using atomic variables:
private AtomicInteger atomicCounter = new AtomicInteger(0);
public void increment() {
atomicCounter.incrementAndGet(); // Thread-safe atomic operation
}
Using concurrent collections:
// Thread-safe alternative to HashMap
private ConcurrentHashMap userScores = new ConcurrentHashMap<>();
public void updateScore(String user, int score) {
userScores.put(user, score); // Thread-safe
}
Immutable objects (inherently thread-safe):
public final class ImmutablePoint {
private final int x, y;
public ImmutablePoint(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
}
Deadlocks
Definition
A deadlock occurs when two or more threads are blocked forever, each waiting for the other to release a resource. This typically happens when threads acquire multiple locks in different orders - Thread A holds Lock 1 and waits for Lock 2, while Thread B holds Lock 2 and waits for Lock 1. Since neither can proceed, both threads are stuck indefinitely. Deadlocks can also occur with more than two threads in circular waiting patterns. They represent one of the most serious threading problems because the application appears to freeze without any obvious error message.
Analogy
Imagine two drivers approaching a narrow bridge from opposite directions, each needing to use the entire width. Driver A enters the bridge first but needs the exit area that Driver B is blocking. Driver B also entered from the other side but needs the entrance area that Driver A is blocking. Neither can move forward, backward, or around the other. They're both stuck forever unless one of them backs up, but in a deadlock scenario, that backing up never happens automatically. This is exactly what happens when threads acquire resources (bridges) in conflicting orders and then wait for each other to release what they need. The solution is having traffic rules (ordering protocols) that prevent such situations.
Examples
Classic deadlock scenario:
Object lock1 = new Object();
Object lock2 = new Object();
// Thread 1:
synchronized(lock1) {
synchronized(lock2) { /* work */ }
}
// Thread 2:
synchronized(lock2) {
synchronized(lock1) { /* work */ } // DEADLOCK!
}
Avoiding deadlock with lock ordering:
// Always acquire locks in same order
private void transfer(Account from, Account to, int amount) {
Account firstLock = from.getId() < to.getId() ? from : to;
Account secondLock = from.getId() < to.getId() ? to : from;
synchronized(firstLock) {
synchronized(secondLock) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
Using timeout to avoid deadlock:
ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
// Do work safely
}
} finally { lock1.unlock(); }
}
ExecutorService
Definition
ExecutorService is a higher-level abstraction for managing threads that provides better control over thread lifecycle, task submission, and resource management. Instead of creating threads manually, you submit tasks to an ExecutorService which uses a pool of reusable threads to execute them. This approach is more efficient because creating threads is expensive, and thread pools prevent resource exhaustion by limiting the number of concurrent threads. ExecutorService also provides features like scheduled execution, task cancellation, and clean shutdown procedures.
Analogy
ExecutorService is like having a professional cleaning company instead of hiring individual cleaners for each room in a large building. The company maintains a team of trained cleaners (thread pool) who can be assigned to different tasks as needed. When you need a room cleaned, you don't hire a new person; you submit a cleaning request to the company, and they assign an available cleaner from their team. This is more efficient than hiring and training a new cleaner for each room, and the company can manage workload distribution, ensure quality standards, and handle scheduling. Similarly, ExecutorService manages a pool of threads efficiently, assigns tasks to available threads, and handles the complexity of thread management for you.
Examples
Creating and using thread pool:
ExecutorService executor = Executors.newFixedThreadPool(4);
// Submit tasks to the pool
executor.submit(() -> processData("file1.txt"));
executor.submit(() -> processData("file2.txt"));
executor.shutdown(); // Stop accepting new tasks
Getting results from tasks:
Future result = executor.submit(() -> {
return calculateSum(numbers);
});
int sum = result.get(); // Wait for result
System.out.println("Sum: " + sum);
Processing multiple tasks concurrently:
List> futures = new ArrayList<>();
for (String url : urls) {
Future future = executor.submit(() -> downloadContent(url));
futures.add(future);
}
// Collect all results
for (Future future : futures) {
String content = future.get();
processContent(content);
}
Scheduled execution:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// Run task every 30 seconds
scheduler.scheduleAtFixedRate(() -> {
cleanupOldData();
}, 0, 30, TimeUnit.SECONDS);
Thread Interruption
Definition
Thread interruption is a cooperative mechanism for signaling a thread to stop its current activity. When a thread is interrupted, it receives an interrupt signal that it can check and respond to appropriately. The interrupted thread can choose to stop what it's doing, clean up resources, and terminate gracefully. Interruption is safer than forcibly killing threads because it allows proper cleanup and prevents resource leaks. However, threads must actively check for interruption and handle InterruptedException properly for this mechanism to work effectively.
Analogy
Thread interruption is like a polite tap on the shoulder to get someone's attention during a conversation. Instead of rudely shouting or physically pulling someone away from their task, you gently interrupt them and give them a chance to finish their sentence, save their work, or politely excuse themselves from the conversation. The person being interrupted can choose to acknowledge the signal immediately or finish their current thought before responding. This courteous approach ensures that important tasks can be completed properly and relationships remain intact. Similarly, thread interruption allows running threads to receive a signal, complete critical operations, clean up resources, and shut down gracefully rather than being abruptly terminated.
Examples
Checking for interruption:
while (!Thread.currentThread().isInterrupted()) {
// Do work
processNextItem();
}
System.out.println("Thread was interrupted, stopping gracefully");
Handling InterruptedException:
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restore interrupt status
System.out.println("Sleep was interrupted");
return; // Exit gracefully
}
Interrupting a thread:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
doWork();
}
});
worker.start();
// Later, interrupt the thread
worker.interrupt();
Proper interruption handling pattern:
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
performTask();
Thread.sleep(1000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
cleanup();
}
}
Volatile Keyword
Definition
The volatile keyword ensures that changes to a variable are immediately visible to all threads and prevents compiler optimizations that might cache the variable's value. When a variable is declared volatile, every read gets the latest value from main memory, and every write is immediately flushed to main memory. This guarantees visibility across threads but doesn't provide atomicity for compound operations. Volatile is useful for flags, status indicators, and simple coordination between threads, but synchronization is still needed for complex operations on shared data.
Analogy
Think of volatile like a public announcement board in a busy office building. When someone posts a notice on this special board (writes to a volatile variable), it immediately becomes visible to everyone walking by (all threads can see the change instantly). Without this special board, people might rely on outdated copies of notices they picked up earlier (cached values), not realizing that the original has been updated. However, this announcement board only ensures that everyone sees the same information - it doesn't prevent two people from trying to post conflicting notices at the same time (it doesn't provide atomicity). For complex coordination, you still need meeting rooms with locked doors (synchronization) to ensure only one person can make changes at a time.
Examples
Volatile flag for thread coordination:
class Worker {
private volatile boolean running = true;
public void run() {
while (running) {
doWork();
}
}
public void stop() {
running = false; // Immediately visible to worker thread
}
}
Volatile vs non-volatile behavior:
// Without volatile: other threads might not see changes
private boolean flag = false;
// With volatile: changes immediately visible to all threads
private volatile boolean flag = false;
Volatile for status sharing:
class TaskProcessor {
private volatile String status = "IDLE";
public void updateStatus(String newStatus) {
status = newStatus; // Visible to all threads immediately
}
public String getStatus() {
return status; // Always returns latest value
}
}
Volatile limitations (not atomic):
private volatile int counter = 0;
// This is NOT thread-safe even with volatile!
counter++; // Still needs synchronization for atomicity
// Volatile only good for simple reads/writes
private volatile boolean ready = false;
ready = true; // This is thread-safe
Wait & Notify
Definition
Wait and notify are methods used for thread communication and coordination. When a thread calls wait() on an object, it releases the lock on that object and goes to sleep until another thread calls notify() or notifyAll() on the same object. This mechanism allows threads to efficiently wait for specific conditions to be met rather than continuously checking (busy waiting). Wait and notify must be used within synchronized blocks or methods, and they enable elegant producer-consumer patterns and other coordination scenarios where threads need to signal each other about state changes.
Analogy
Wait and notify work like a restaurant's kitchen communication system. When a server (thread) places an order but the kitchen is busy, instead of standing there asking "Is my order ready?" every few seconds (busy waiting), the server goes back to serving other customers and tells the kitchen staff "Please call me when order #15 is ready" (wait()). When the chef finishes the order, they ring a bell and announce "Order #15 is ready!" (notify()), which signals the waiting server to come back and pick up the food. This system is much more efficient than having servers constantly interrupting the kitchen to check on their orders. The kitchen (synchronized block) ensures only one person can communicate at a time, preventing chaos and miscommunication.
Examples
Basic wait and notify pattern:
class SharedResource {
private boolean dataReady = false;
public synchronized void waitForData() throws InterruptedException {
while (!dataReady) {
wait(); // Release lock and wait
}
// Data is now ready, proceed
}
public synchronized void notifyDataReady() {
dataReady = true;
notify(); // Wake up waiting thread
}
}
Producer-consumer with wait/notify:
class Buffer {
private Queue queue = new LinkedList<>();
private final int MAX_SIZE = 10;
public synchronized void produce(String item) throws InterruptedException {
while (queue.size() == MAX_SIZE) {
wait(); // Wait for space
}
queue.offer(item);
notifyAll(); // Notify consumers
}
public synchronized String consume() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // Wait for items
}
String item = queue.poll();
notifyAll(); // Notify producers
return item;
}
}
Using notifyAll() vs notify():
// notify() - wakes up one waiting thread
notify();
// notifyAll() - wakes up all waiting threads (safer)
notifyAll();
Always use wait() in a loop:
// Correct: check condition in loop
synchronized(obj) {
while (!condition) {
obj.wait();
}
}
// Wrong: checking condition only once
synchronized(obj) {
if (!condition) {
obj.wait(); // Spurious wakeup might occur
}
}
ThreadLocal
Definition
ThreadLocal provides thread-local variables where each thread has its own independent copy of the variable. This allows you to store data that should be accessible throughout a thread's execution but isolated from other threads. ThreadLocal is useful for maintaining context information (like user sessions, database connections, or transaction states) without passing parameters through every method call. Each thread sees only its own value, eliminating the need for synchronization when accessing thread-local data.
Analogy
ThreadLocal is like giving each employee in a company their own personal notebook that only they can read and write in. While everyone might have a notebook with the same title (like "Daily Tasks"), each person's notebook contains completely different information that's relevant only to their work. When an employee writes something in their notebook, it doesn't affect anyone else's notebook, and they don't need to worry about someone else changing their notes. If an employee moves between different departments during the day, they can carry their notebook with them and always have access to their personal context and information. This eliminates the need for complex sharing systems while ensuring each person has the information they need for their specific tasks.
Examples
Basic ThreadLocal usage:
class UserContext {
private static ThreadLocal currentUser = new ThreadLocal<>();
public static void setUser(String username) {
currentUser.set(username);
}
public static String getUser() {
return currentUser.get();
}
public static void clear() {
currentUser.remove(); // Important: prevent memory leaks
}
}
ThreadLocal with initial value:
private static ThreadLocal dateFormatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatDate(Date date) {
return dateFormatter.get().format(date); // Each thread gets its own formatter
}
Database connection per thread:
class DatabaseManager {
private static ThreadLocal connectionHolder = new ThreadLocal<>();
public static Connection getConnection() {
Connection conn = connectionHolder.get();
if (conn == null) {
conn = createNewConnection();
connectionHolder.set(conn);
}
return conn;
}
public static void closeConnection() {
Connection conn = connectionHolder.get();
if (conn != null) {
conn.close();
connectionHolder.remove();
}
}
}
Web request context example:
class RequestContext {
private static ThreadLocal requestId = new ThreadLocal<>();
private static ThreadLocal userRole = new ThreadLocal<>();
public static void setRequestInfo(String id, String role) {
requestId.set(id);
userRole.set(role);
}
public static String getRequestId() {
return requestId.get();
}
public static boolean hasAdminRole() {
return "ADMIN".equals(userRole.get());
}
}
Atomic Operations
Definition
Atomic operations are single, indivisible operations that complete entirely or not at all, without the possibility of interference from other threads. Java provides atomic classes like AtomicInteger, AtomicBoolean, and AtomicReference that use low-level CPU instructions to ensure thread-safe operations without the overhead of synchronization. These operations are much faster than synchronized blocks for simple operations like incrementing counters, updating flags, or swapping references. Atomic operations guarantee both visibility and atomicity, making them perfect for high-performance concurrent scenarios.
Analogy
Atomic operations are like using a high-tech vending machine that can only process one transaction at a time, but does it incredibly fast. When you insert money and select a product, the entire transaction (checking payment, dispensing item, giving change) happens as one complete, uninterruptible action. No one else can interfere halfway through your transaction - either the whole operation succeeds, or it fails completely and rolls back. Unlike a traditional store where you might have to wait in line, talk to a cashier, and handle multiple steps (like synchronized methods), the vending machine handles simple operations instantly and atomically. For complex purchases requiring multiple items or special handling, you'd still need to visit the traditional store (use synchronization), but for simple, common operations, the vending machine (atomic operations) is much faster and more efficient.
Examples
AtomicInteger for thread-safe counting:
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // Thread-safe, no synchronization needed
}
public int getCount() {
return counter.get();
}
AtomicBoolean for flags:
private AtomicBoolean isRunning = new AtomicBoolean(true);
public void stop() {
isRunning.set(false);
}
public void run() {
while (isRunning.get()) {
doWork();
}
}
AtomicReference for object swapping:
private AtomicReference currentStatus = new AtomicReference<>("IDLE");
public boolean updateStatus(String expectedStatus, String newStatus) {
return currentStatus.compareAndSet(expectedStatus, newStatus);
}
// Usage: only update if current status is what we expect
if (updateStatus("IDLE", "PROCESSING")) {
// Successfully changed from IDLE to PROCESSING
}
Compare-and-swap operations:
AtomicInteger value = new AtomicInteger(10);
// Only update if current value is 10
boolean success = value.compareAndSet(10, 20);
// Atomic increment and get previous value
int previousValue = value.getAndIncrement();
// Atomic add operation
int newValue = value.addAndGet(5);
Performance comparison:
// Synchronized approach (slower)
private int counter = 0;
public synchronized void increment() {
counter++;
}
// Atomic approach (faster for simple operations)
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet();
}
Common Pitfalls
Definition
Multi-threaded programming introduces several common mistakes that can lead to bugs, performance problems, or application failures. These include calling run() instead of start(), not properly synchronizing shared data, creating too many threads, forgetting to handle InterruptedException, using non-thread-safe collections in concurrent contexts, and improper exception handling in threads. Understanding these pitfalls helps you write more robust concurrent code and debug threading issues more effectively.
Analogy
Common threading pitfalls are like typical mistakes new drivers make when learning to navigate busy intersections. They might forget to signal (not handling interruptions properly), try to change lanes without checking mirrors (not synchronizing access to shared resources), get confused about right-of-way rules (improper lock ordering leading to deadlocks), or panic when something unexpected happens (poor exception handling). Just as defensive driving courses teach you to recognize and avoid these dangerous situations, understanding common threading mistakes helps you write safer concurrent code. Experienced programmers, like experienced drivers, develop habits and patterns that naturally avoid these hazards.
Examples
Wrong: calling run() instead of start():
Thread t = new Thread(() -> doWork());
t.run(); // Wrong! Executes in current thread
t.start(); // Correct! Creates new thread
Wrong: using non-thread-safe collections:
List list = new ArrayList<>(); // Not thread-safe
// Multiple threads adding/removing = problems
// Correct alternatives:
List safeList = Collections.synchronizedList(new ArrayList<>());
ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>();
Wrong: not handling InterruptedException:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Wrong: ignoring the interruption
}
// Correct: restore interrupt status
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
Wrong: creating too many threads:
// Don't do this for many tasks:
for (int i = 0; i < 10000; i++) {
new Thread(() -> processItem()).start();
}
// Use thread pool instead:
ExecutorService pool = Executors.newFixedThreadPool(10);
Practical Scenarios
Definition
Real-world applications use threads for various scenarios: background data processing while maintaining responsive user interfaces, parallel processing of large datasets to improve performance, handling multiple client requests simultaneously in servers, periodic maintenance tasks like cleanup and monitoring, and I/O operations that would otherwise block the main application flow. Understanding these common patterns helps you recognize when and how to apply threading effectively in your own projects.
Analogy
Practical threading scenarios are like different departments in a modern hospital working simultaneously. The emergency room handles urgent cases immediately (responsive UI thread), while the lab processes multiple blood tests concurrently (parallel data processing). The pharmacy fills prescriptions in the background (background tasks), maintenance staff clean rooms on a schedule (periodic tasks), and multiple nurses handle different patients at the same time (concurrent request handling). Each department operates independently but coordinates when necessary, ensuring the hospital runs efficiently without any single bottleneck stopping the entire operation. This coordinated multitasking is exactly how well-designed concurrent applications operate.
Examples
Background file download:
new Thread(() -> {
downloadLargeFile("http://example.com/data.zip");
SwingUtilities.invokeLater(() -> updateUI("Download complete"));
}).start();
Parallel data processing:
List numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
numbers.parallelStream()
.map(n -> n * n)
.forEach(System.out::println);
Web server handling multiple requests:
ExecutorService requestHandler = Executors.newCachedThreadPool();
while (serverRunning) {
Socket client = serverSocket.accept();
requestHandler.submit(() -> handleClientRequest(client));
}
Periodic cleanup task:
ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor();
cleanup.scheduleAtFixedRate(() -> {
deleteOldLogFiles();
clearTempCache();
}, 1, 24, TimeUnit.HOURS);
Producer-consumer pattern:
BlockingQueue queue = new LinkedBlockingQueue<>();
// Producer thread
new Thread(() -> {
queue.offer("item1");
queue.offer("item2");
}).start();
// Consumer thread
new Thread(() -> {
String item = queue.take();
processItem(item);
}).start();
Summary
You've now mastered the fundamentals of Java threads and concurrency, from creating and managing threads to understanding the challenges and solutions of concurrent programming. Threads allow your applications to perform multiple tasks simultaneously, making them more responsive and efficient, but they require careful handling to avoid race conditions, deadlocks, and other concurrency issues. You've learned to use synchronization to protect shared data, leverage thread-safe collections and atomic operations, and apply ExecutorService for better thread management. These concurrency skills are essential for building modern applications that can handle multiple users, process data efficiently, and maintain responsiveness under load. Next, you'll explore advanced algorithms and data structures, where understanding performance characteristics and choosing the right approach becomes crucial for handling large-scale problems effectively.
Programming Challenge
Challenge: Multi-threaded Download Manager
Task: Create a download manager that can download multiple files concurrently while providing progress updates and handling errors gracefully.
Requirements:
- Create a
DownloadTask
class that simulates downloading a file with random duration (1-5 seconds) - Implement a
DownloadManager
that can handle up to 3 concurrent downloads - Use proper synchronization to track total downloads completed and total bytes downloaded
- Include a progress reporter that prints status every second
- Handle thread interruption properly for graceful shutdown
- Demonstrate thread safety by avoiding race conditions in shared counters
- Use ExecutorService for efficient thread management
Features to implement:
- Thread-safe progress tracking
- Proper exception handling in worker threads
- Clean shutdown mechanism
- Concurrent download with size limits
- Real-time status reporting
Learning Goals: Practice thread pools, synchronization, atomic operations, exception handling in concurrent contexts, and building a realistic multi-threaded application with proper coordination between threads.