Java Multithreading Tutorial
Learn Java multithreading from the fundamentals with practical examples covering Thread, Runnable, thread lifecycle, synchronization, race conditions, deadlocks, ExecutorService, Callable, Future, BlockingQueue, and concurrency best practices.
This tutorial focuses on understanding both the API and the reasoning behind concurrent Java programs, so you can move from basic thread examples to safer real-world designs.
Introduction
Modern applications often need to perform multiple tasks concurrently. A web server may process many requests at the same time, a desktop application may perform background work while keeping its interface responsive, and a backend service may execute independent operations concurrently.
Java provides built-in concurrency support through threads and the java.util.concurrent package.
A thread is an independent path of execution within a process. Multiple threads can share the same process memory, which makes communication between tasks efficient but also introduces risks such as race conditions and inconsistent shared state.
Multithreading is commonly used in:
- Web servers
- Spring Boot applications
- Network programming
- Database applications
- Background processing
- Real-time applications
- Desktop applications
- Game development
- File and data processing
By the end of this tutorial, you will understand how to create and coordinate threads and how to choose higher-level concurrency tools instead of manually managing threads for every task.
What You Will Learn
- What a thread is
- Process vs thread
- Creating threads with
Thread - Creating tasks with
Runnable - Using
CallableandFuture - Important thread methods
- Thread lifecycle and states
- Race conditions
- Synchronization
- Atomic variables
- Deadlocks
- Executor Framework
- Thread pools
BlockingQueue- Graceful shutdown
- Concurrency best practices
- Practical multithreading projects
Process vs Thread
A process is an executing program with its own memory space and system resources.
A thread is an execution path inside a process. Threads belonging to the same process generally share heap memory and other process resources.
A simplified model looks like this:
1Java Application / Process 2│ 3├── Main Thread 4├── Request Worker 5├── Background Worker 6└── Scheduled Task
Threads are useful when tasks can make progress independently or when an application needs to remain responsive while background work is running.
Concurrency vs Parallelism
These terms are related but not identical.
Concurrency means multiple tasks can make progress during the same period. The runtime may switch between tasks even on a single CPU core.
Parallelism means multiple tasks are actually executing at the same time, typically on multiple CPU cores.
1Concurrency 2Task A ──► Task A ──► 3 Task B ──► Task B ──► 4 5Parallelism 6Core 1: Task A ─────────────► 7Core 2: Task B ─────────────►
A Java program can use concurrency without achieving true parallel execution. The JVM and operating system decide how runnable threads are scheduled.
Creating a Thread
Java provides the Thread class for representing an execution thread.
There are two classic approaches:
- Extend
Thread - Implement
Runnable
In production applications, prefer describing the task separately from the thread that executes it. This is why Runnable, Callable, and executor-based APIs are generally more flexible than extending Thread.
Extending Thread
A simple example:
1class GreetingThread extends Thread { 2 3 @Override 4 public void run() { 5 System.out.println("Hello from: " + Thread.currentThread().getName()); 6 } 7} 8 9public class ThreadExample { 10 11 public static void main(String[] args) throws InterruptedException { 12 Thread worker = new GreetingThread(); 13 14 worker.start(); 15 worker.join(); 16 17 System.out.println("Main thread finished"); 18 } 19}
Possible output:
1Hello from: Thread-0 2Main thread finished
The exact automatically generated thread name can vary.
Why start() Matters
Use:
1worker.start();
not:
1worker.run();
Calling start() asks the JVM to start a new thread, after which the JVM invokes run() on that thread.
Calling run() directly is simply a normal method call and does not create a new thread.
Runnable Interface
Runnable represents a task that does not return a result.
1class GreetingTask implements Runnable { 2 3 @Override 4 public void run() { 5 System.out.println( 6 "Running on: " + Thread.currentThread().getName() 7 ); 8 } 9} 10 11public class RunnableExample { 12 13 public static void main(String[] args) throws InterruptedException { 14 Thread worker = new Thread( 15 new GreetingTask(), 16 "greeting-worker" 17 ); 18 19 worker.start(); 20 worker.join(); 21 } 22}
A lambda can make a small Runnable even shorter:
1Thread worker = new Thread( 2 () -> System.out.println("Background task"), 3 "worker" 4); 5 6worker.start();
Why Runnable Is Usually Better Than Extending Thread
Extending Thread couples your task to the thread implementation.
With Runnable, the task can be executed by:
- A manually created
Thread - An
ExecutorService - A scheduled executor
- Other concurrency infrastructure
For example:
1Runnable task = () -> { 2 System.out.println("Processing order"); 3};
The task describes what should happen, while the executor can decide how and when it should run.
This separation becomes especially valuable in larger applications.
Thread Methods
| Method | Purpose |
|---|---|
start() | Starts a new thread |
run() | Contains the task logic |
sleep() | Suspends the currently executing thread for a period |
join() | Waits for another thread to finish |
interrupt() | Requests interruption |
isAlive() | Checks whether a thread has started and has not terminated |
getName() | Gets the thread name |
setName() | Changes the thread name |
currentThread() | Gets the currently executing thread |
Thread.sleep()
sleep() pauses the current thread for approximately the specified duration.
1public class SleepExample { 2 3 public static void main(String[] args) { 4 for (int second = 1; second <= 3; second++) { 5 System.out.println("Second: " + second); 6 7 try { 8 Thread.sleep(1000); 9 } catch (InterruptedException e) { 10 Thread.currentThread().interrupt(); 11 System.out.println("Task interrupted"); 12 break; 13 } 14 } 15 } 16}
Important: sleep() does not release a monitor lock held by the thread. If code is sleeping while holding a synchronized monitor, other threads may still be unable to enter that synchronized section.
Thread.join()
join() allows one thread to wait for another thread to terminate.
1public class JoinExample { 2 3 public static void main(String[] args) 4 throws InterruptedException { 5 6 Thread worker = new Thread( 7 () -> System.out.println("Worker completed"), 8 "worker" 9 ); 10 11 worker.start(); 12 13 worker.join(); 14 15 System.out.println("Main completed after worker"); 16 } 17}
Output:
1Worker completed 2Main completed after worker
Without join(), the main thread could continue before the worker finishes.
Interrupting a Thread
interrupt() is best understood as a request for interruption. It does not forcibly kill a thread.
A well-designed task should respond to interruption when appropriate.
1public class InterruptExample { 2 3 public static void main(String[] args) 4 throws InterruptedException { 5 6 Thread worker = new Thread(() -> { 7 try { 8 while (!Thread.currentThread().isInterrupted()) { 9 System.out.println("Working..."); 10 Thread.sleep(500); 11 } 12 } catch (InterruptedException e) { 13 Thread.currentThread().interrupt(); 14 System.out.println("Worker interrupted"); 15 } 16 }); 17 18 worker.start(); 19 20 Thread.sleep(1200); 21 worker.interrupt(); 22 23 worker.join(); 24 System.out.println("Main finished"); 25 } 26}
When a thread blocked in methods such as sleep() receives an interrupt, the method can throw InterruptedException and clear the thread's interrupted status. Restoring the status with:
1Thread.currentThread().interrupt();
is an important pattern when the current method cannot fully handle the interruption itself.
Thread Lifecycle
Java exposes thread states through Thread.State.
A simplified lifecycle is:
1NEW 2 | 3 | start() 4 v 5RUNNABLE 6 | 7 +-----> BLOCKED 8 | 9 +-----> WAITING 10 | 11 +-----> TIMED_WAITING 12 | 13 v 14TERMINATED
Java's RUNNABLE state covers threads that are ready to run and threads that are actually running according to the JVM's state model. RUNNING is not a separate Thread.State enum value.
Thread States
| State | Meaning |
|---|---|
NEW | Thread object created but start() has not been called |
RUNNABLE | Thread is eligible to run or is running |
BLOCKED | Waiting to acquire a monitor lock |
WAITING | Waiting indefinitely for another thread/action |
TIMED_WAITING | Waiting for a specified period |
TERMINATED | Thread execution has completed |
You can inspect a thread's state:
1Thread worker = new Thread( 2 () -> System.out.println("Working") 3); 4 5System.out.println(worker.getState()); 6 7worker.start(); 8worker.join(); 9 10System.out.println(worker.getState());
Typical output:
1NEW 2TERMINATED
The intermediate state can vary depending on scheduling.
Race Conditions
A race condition occurs when the correctness of a program depends on the timing or interleaving of concurrent operations.
Consider:
1class Counter { 2 3 private int value = 0; 4 5 public void increment() { 6 value++; 7 } 8 9 public int getValue() { 10 return value; 11 } 12}
The expression:
1value++;
is a read-modify-write operation. When multiple threads execute it concurrently, updates can be lost.
For example:
1Thread A reads 10 2Thread B reads 10 3Thread A writes 11 4Thread B writes 11 5 6Expected: 12 7Actual: 11
This is why shared mutable state requires careful synchronization.
Synchronization
The synchronized keyword can protect a critical section using an object's monitor.
1class Counter { 2 3 private int value = 0; 4 5 public synchronized void increment() { 6 value++; 7 } 8 9 public synchronized int getValue() { 10 return value; 11 } 12}
Now only one thread at a time can execute a synchronized instance method for the same Counter object.
Testing a Thread-Safe Counter
1public class CounterExample { 2 3 public static void main(String[] args) 4 throws InterruptedException { 5 6 Counter counter = new Counter(); 7 8 Runnable task = () -> { 9 for (int i = 0; i < 10_000; i++) { 10 counter.increment(); 11 } 12 }; 13 14 Thread first = new Thread(task); 15 Thread second = new Thread(task); 16 17 first.start(); 18 second.start(); 19 20 first.join(); 21 second.join(); 22 23 System.out.println("Final count: " + counter.getValue()); 24 } 25}
Output:
1Final count: 20000
Because the increment operation is synchronized, the updates are protected from concurrent modification.
Synchronized Blocks
You do not always need to synchronize an entire method.
1class Account { 2 3 private final Object lock = new Object(); 4 private int balance; 5 6 public void deposit(int amount) { 7 synchronized (lock) { 8 balance += amount; 9 } 10 } 11 12 public int getBalance() { 13 synchronized (lock) { 14 return balance; 15 } 16 } 17}
Synchronizing only the critical section can reduce lock scope and make the protected region easier to reason about.
Atomic Variables
For simple atomic updates, classes such as AtomicInteger can be a better fit than explicit locking.
1import java.util.concurrent.atomic.AtomicInteger; 2 3public class AtomicCounter { 4 5 private final AtomicInteger value = 6 new AtomicInteger(); 7 8 public void increment() { 9 value.incrementAndGet(); 10 } 11 12 public int getValue() { 13 return value.get(); 14 } 15 16 public static void main(String[] args) 17 throws InterruptedException { 18 19 AtomicCounter counter = new AtomicCounter(); 20 21 Runnable task = () -> { 22 for (int i = 0; i < 10_000; i++) { 23 counter.increment(); 24 } 25 }; 26 27 Thread first = new Thread(task); 28 Thread second = new Thread(task); 29 30 first.start(); 31 second.start(); 32 33 first.join(); 34 second.join(); 35 36 System.out.println(counter.getValue()); 37 } 38}
Atomic classes are particularly useful for simple state transitions and counters. They are not a universal replacement for locks when multiple variables must change as one consistent operation.
volatile
The volatile keyword provides visibility guarantees for a variable between threads.
For example:
1class Worker implements Runnable { 2 3 private volatile boolean running = true; 4 5 @Override 6 public void run() { 7 while (running) { 8 // Perform work. 9 } 10 } 11 12 public void stop() { 13 running = false; 14 } 15}
volatile is useful for visibility of independent state such as a shutdown flag.
However, volatile does not make compound operations such as:
1count++;
atomic.
For compound state updates, use synchronization, atomic classes, or another appropriate concurrency mechanism.
Callable
Runnable does not return a result and cannot directly declare checked exceptions.
Callable<V> represents a task that can return a result and throw an exception.
1import java.util.concurrent.Callable; 2 3public class CallableExample { 4 5 public static void main(String[] args) 6 throws Exception { 7 8 Callable<Integer> calculation = () -> { 9 return 20 + 30; 10 }; 11 12 System.out.println(calculation.call()); 13 } 14}
In real applications, Callable is commonly submitted to an executor rather than calling call() directly.
Future
A Future<V> represents the result of an asynchronous computation.
1import java.util.concurrent.ExecutorService; 2import java.util.concurrent.Executors; 3import java.util.concurrent.Future; 4 5public class FutureExample { 6 7 public static void main(String[] args) 8 throws Exception { 9 10 ExecutorService executor = 11 Executors.newSingleThreadExecutor(); 12 13 try { 14 Future<Integer> future = executor.submit( 15 () -> 10 * 20 16 ); 17 18 System.out.println("Result: " + future.get()); 19 } finally { 20 executor.shutdown(); 21 } 22 } 23}
Output:
1Result: 200
Calling get() waits until the result is available if the task has not finished yet.
Executor Framework
Creating a new thread manually for every task can become difficult to manage.
The Executor Framework provides higher-level APIs for submitting tasks and managing worker threads.
Important types include:
ExecutorExecutorServiceScheduledExecutorServiceCallableFutureExecutors
Instead of thinking:
1Task -> Create Thread -> Start Thread -> Manage Thread
you can think:
1Tasks -> ExecutorService -> Worker Threads
This separates task submission from thread management.
Fixed Thread Pool
1import java.util.concurrent.ExecutorService; 2import java.util.concurrent.Executors; 3 4public class ExecutorExample { 5 6 public static void main(String[] args) 7 throws InterruptedException { 8 9 ExecutorService executor = 10 Executors.newFixedThreadPool(2); 11 12 try { 13 for (int taskId = 1; taskId <= 4; taskId++) { 14 final int id = taskId; 15 16 executor.submit(() -> { 17 System.out.println( 18 "Task " + id 19 + " running on " 20 + Thread.currentThread().getName() 21 ); 22 }); 23 } 24 } finally { 25 executor.shutdown(); 26 } 27 28 if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { 29 executor.shutdownNow(); 30 } 31 } 32}
There are four submitted tasks but only two worker threads in the pool. The executor reuses those workers to process the tasks.
The exact execution order is not guaranteed.
ExecutorService Shutdown
An executor should be shut down when it is no longer needed.
1executor.shutdown();
shutdown() stops accepting new tasks and allows previously submitted tasks to complete.
If immediate interruption is required:
1executor.shutdownNow();
A common graceful pattern is:
1executor.shutdown(); 2 3if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { 4 executor.shutdownNow(); 5}
Do not rely on shutdownNow() as a guaranteed force-kill. It attempts to interrupt running tasks, and task code must cooperate with interruption.
ScheduledExecutorService
For delayed or periodic tasks, use ScheduledExecutorService.
1import java.util.concurrent.Executors; 2import java.util.concurrent.ScheduledExecutorService; 3import java.util.concurrent.TimeUnit; 4 5public class ScheduledTaskExample { 6 7 public static void main(String[] args) 8 throws InterruptedException { 9 10 ScheduledExecutorService scheduler = 11 Executors.newScheduledThreadPool(1); 12 13 try { 14 scheduler.schedule( 15 () -> System.out.println("Task executed"), 16 2, 17 TimeUnit.SECONDS 18 ); 19 20 Thread.sleep(2500); 21 } finally { 22 scheduler.shutdown(); 23 } 24 } 25}
This schedules a task to execute after a delay.
Deadlock
A deadlock occurs when threads wait indefinitely for locks held by one another.
A typical pattern is:
1Thread A 2 | 3 +-- locks Resource 1 4 | 5 +-- waits for Resource 2 6 7Thread B 8 | 9 +-- locks Resource 2 10 | 11 +-- waits for Resource 1
Neither thread can proceed.
Deadlock Example
1public class DeadlockExample { 2 3 private static final Object LOCK_A = new Object(); 4 private static final Object LOCK_B = new Object(); 5 6 public static void main(String[] args) { 7 8 Thread first = new Thread(() -> { 9 synchronized (LOCK_A) { 10 System.out.println("First locked A"); 11 12 synchronized (LOCK_B) { 13 System.out.println("First locked B"); 14 } 15 } 16 }); 17 18 Thread second = new Thread(() -> { 19 synchronized (LOCK_B) { 20 System.out.println("Second locked B"); 21 22 synchronized (LOCK_A) { 23 System.out.println("Second locked A"); 24 } 25 } 26 }); 27 28 first.start(); 29 second.start(); 30 } 31}
This code demonstrates a dangerous lock-ordering pattern. Depending on scheduling, the threads can become permanently blocked.
Do not use this pattern in production code.
How to Prevent Deadlocks
Useful strategies include:
- Acquire multiple locks in a consistent global order.
- Keep lock scope small.
- Avoid unnecessary nested locking.
- Prefer higher-level concurrency utilities when appropriate.
- Consider lock-free or atomic structures for simple state.
- Use timed lock acquisition with
tryLock()when the design requires it. - Keep resource ownership clear.
A consistent lock order is one of the simplest ways to prevent circular wait.
ReentrantLock
ReentrantLock provides explicit locking capabilities.
1import java.util.concurrent.locks.Lock; 2import java.util.concurrent.locks.ReentrantLock; 3 4class SafeCounter { 5 6 private final Lock lock = new ReentrantLock(); 7 private int value; 8 9 public void increment() { 10 lock.lock(); 11 12 try { 13 value++; 14 } finally { 15 lock.unlock(); 16 } 17 } 18 19 public int getValue() { 20 lock.lock(); 21 22 try { 23 return value; 24 } finally { 25 lock.unlock(); 26 } 27 } 28}
The finally block is important because the lock must be released even if the protected code throws an exception.
ReentrantLock can also support features such as timed and interruptible lock acquisition that are not directly expressed by a basic synchronized block.
BlockingQueue
BlockingQueue is useful for producer-consumer designs.
The producer adds work, while the consumer retrieves work. The queue coordinates waiting when it is empty or full.
1import java.util.concurrent.BlockingQueue; 2import java.util.concurrent.LinkedBlockingQueue; 3 4public class ProducerConsumerExample { 5 6 public static void main(String[] args) 7 throws InterruptedException { 8 9 BlockingQueue<Integer> queue = 10 new LinkedBlockingQueue<>(5); 11 12 Thread producer = new Thread(() -> { 13 try { 14 for (int value = 1; value <= 5; value++) { 15 queue.put(value); 16 System.out.println("Produced: " + value); 17 } 18 } catch (InterruptedException e) { 19 Thread.currentThread().interrupt(); 20 } 21 }); 22 23 Thread consumer = new Thread(() -> { 24 try { 25 for (int i = 1; i <= 5; i++) { 26 int value = queue.take(); 27 System.out.println("Consumed: " + value); 28 } 29 } catch (InterruptedException e) { 30 Thread.currentThread().interrupt(); 31 } 32 }); 33 34 producer.start(); 35 consumer.start(); 36 37 producer.join(); 38 consumer.join(); 39 } 40}
Possible output:
1Produced: 1 2Produced: 2 3Consumed: 1 4Consumed: 2 5Produced: 3 6Consumed: 3 7...
The exact order can vary because producer and consumer execute concurrently.
ConcurrentHashMap
When multiple threads need to access a shared map, ConcurrentHashMap provides a thread-safe alternative designed for concurrent access.
1import java.util.concurrent.ConcurrentHashMap; 2 3public class ConcurrentMapExample { 4 5 public static void main(String[] args) { 6 7 ConcurrentHashMap<String, Integer> visits = 8 new ConcurrentHashMap<>(); 9 10 visits.merge("home", 1, Integer::sum); 11 visits.merge("home", 1, Integer::sum); 12 visits.merge("courses", 1, Integer::sum); 13 14 System.out.println(visits); 15 } 16}
Output:
1{home=2, courses=1}
The exact display order of map entries is not guaranteed.
CountDownLatch
CountDownLatch allows one or more threads to wait until a fixed number of events have occurred.
1import java.util.concurrent.CountDownLatch; 2 3public class CountDownLatchExample { 4 5 public static void main(String[] args) 6 throws InterruptedException { 7 8 CountDownLatch ready = 9 new CountDownLatch(3); 10 11 Runnable service = () -> { 12 try { 13 System.out.println( 14 Thread.currentThread().getName() 15 + " initialized" 16 ); 17 } finally { 18 ready.countDown(); 19 } 20 }; 21 22 new Thread(service, "Service-A").start(); 23 new Thread(service, "Service-B").start(); 24 new Thread(service, "Service-C").start(); 25 26 ready.await(); 27 28 System.out.println("All services initialized"); 29 } 30}
The main thread waits until all three workers call countDown().
Semaphore
A Semaphore can limit the number of threads that access a resource at the same time.
1import java.util.concurrent.Semaphore; 2 3public class SemaphoreExample { 4 5 public static void main(String[] args) { 6 7 Semaphore permits = new Semaphore(2); 8 9 Runnable task = () -> { 10 try { 11 permits.acquire(); 12 13 System.out.println( 14 Thread.currentThread().getName() 15 + " using resource" 16 ); 17 18 Thread.sleep(500); 19 20 } catch (InterruptedException e) { 21 Thread.currentThread().interrupt(); 22 } finally { 23 permits.release(); 24 } 25 }; 26 27 for (int i = 1; i <= 5; i++) { 28 new Thread(task, "Worker-" + i).start(); 29 } 30 } 31}
At most two workers can hold a permit at the same time.
Thread-Safe Design
The safest concurrent code often minimizes shared mutable state.
Instead of having many threads directly modify the same object:
1Thread A ─┐ 2Thread B ─┼──> Shared Mutable Object 3Thread C ─┘
consider designs where tasks communicate through well-defined concurrency utilities:
1Producer -> BlockingQueue -> Consumer
or:
1Tasks -> ExecutorService -> Results
Reducing shared mutable state can make concurrent systems easier to test and maintain.
Best Practices
- Prefer
RunnableorCallablefor tasks instead of extendingThread. - Use
ExecutorServicefor managing groups of tasks. - Give important worker threads meaningful names.
- Keep synchronized sections small.
- Minimize shared mutable state.
- Use atomic classes for simple atomic state transitions.
- Use
volatilefor visibility requirements, not as a replacement for synchronization. - Always release explicit locks in a
finallyblock. - Acquire multiple locks in a consistent order.
- Handle
InterruptedExceptiondeliberately. - Restore the interrupt flag when propagating or otherwise not fully handling interruption.
- Shut down executor services when they are no longer needed.
- Avoid creating unlimited threads.
- Do not assume concurrent output order.
- Avoid unnecessary synchronization.
- Prefer high-level concurrency utilities when they express the problem more clearly.
- Measure performance before introducing parallelism solely for speed.
Common Multithreading Mistakes
Calling run() Instead of start()
Wrong:
1Thread thread = new Thread(task); 2thread.run();
Correct:
1Thread thread = new Thread(task); 2thread.start();
The first version does not create a new execution thread.
Forgetting to Shut Down an Executor
Avoid leaving executor services running indefinitely.
1ExecutorService executor = 2 Executors.newFixedThreadPool(4); 3 4// submit tasks... 5 6executor.shutdown();
For application-level executors, lifecycle ownership should be explicit rather than blindly shutting down a shared executor that other components still need.
Swallowing InterruptedException
Avoid:
1catch (InterruptedException e) { 2 // Ignore 3}
If the current method cannot handle the interruption, restore the interrupt status:
1catch (InterruptedException e) { 2 Thread.currentThread().interrupt(); 3}
Using volatile for count++
This does not make increment atomic:
1volatile int count; 2 3count++;
Use AtomicInteger or synchronization when the update must be atomic.
Assuming Thread Order
Do not expect:
1thread1.start(); 2thread2.start();
to guarantee that thread 1 finishes first.
Thread scheduling is nondeterministic.
Practice Project: Download Manager
A download manager can execute independent download tasks concurrently.
1import java.util.concurrent.ExecutorService; 2import java.util.concurrent.Executors; 3import java.util.concurrent.TimeUnit; 4 5public class DownloadManager { 6 7 static class DownloadTask implements Runnable { 8 9 private final String fileName; 10 11 DownloadTask(String fileName) { 12 this.fileName = fileName; 13 } 14 15 @Override 16 public void run() { 17 try { 18 System.out.println( 19 "Starting: " + fileName 20 ); 21 22 Thread.sleep(1000); 23 24 System.out.println( 25 "Completed: " + fileName 26 ); 27 28 } catch (InterruptedException e) { 29 Thread.currentThread().interrupt(); 30 31 System.out.println( 32 "Cancelled: " + fileName 33 ); 34 } 35 } 36 } 37 38 public static void main(String[] args) 39 throws InterruptedException { 40 41 ExecutorService executor = 42 Executors.newFixedThreadPool(2); 43 44 try { 45 executor.submit( 46 new DownloadTask("Java.pdf") 47 ); 48 49 executor.submit( 50 new DownloadTask("Spring.pdf") 51 ); 52 53 executor.submit( 54 new DownloadTask("Docker.pdf") 55 ); 56 57 } finally { 58 executor.shutdown(); 59 } 60 61 if (!executor.awaitTermination( 62 5, 63 TimeUnit.SECONDS 64 )) { 65 executor.shutdownNow(); 66 } 67 } 68}
This example demonstrates an important production pattern:
1Tasks 2 | 3 v 4Fixed Thread Pool 5 | 6 +--> Worker 1 7 | 8 +--> Worker 2
Only a limited number of downloads run concurrently.
Practice Project: Bank Account
A shared bank account is a good example for understanding race conditions.
1class BankAccount { 2 3 private int balance; 4 5 public BankAccount(int initialBalance) { 6 this.balance = initialBalance; 7 } 8 9 public synchronized boolean withdraw(int amount) { 10 if (amount > balance) { 11 return false; 12 } 13 14 balance -= amount; 15 return true; 16 } 17 18 public synchronized int getBalance() { 19 return balance; 20 } 21}
Test it with multiple threads:
1public class BankAccountExample { 2 3 public static void main(String[] args) 4 throws InterruptedException { 5 6 BankAccount account = 7 new BankAccount(1000); 8 9 Runnable withdrawal = () -> { 10 if (account.withdraw(100)) { 11 System.out.println( 12 Thread.currentThread().getName() 13 + " withdrew 100" 14 ); 15 } else { 16 System.out.println( 17 Thread.currentThread().getName() 18 + " could not withdraw" 19 ); 20 } 21 }; 22 23 Thread user1 = 24 new Thread(withdrawal, "User-1"); 25 26 Thread user2 = 27 new Thread(withdrawal, "User-2"); 28 29 user1.start(); 30 user2.start(); 31 32 user1.join(); 33 user2.join(); 34 35 System.out.println( 36 "Remaining balance: " 37 + account.getBalance() 38 ); 39 } 40}
Synchronization ensures that the balance check and update happen safely as one protected operation.
Practice Project: Producer-Consumer
Build a producer-consumer application using BlockingQueue.
Requirements:
- Create a bounded queue.
- Create one producer.
- Create one or more consumers.
- Produce a sequence of tasks.
- Consume each task.
- Handle interruption correctly.
- Wait for all worker threads to finish.
- Shut down the application cleanly.
A useful extension is to replace manually created consumer threads with an ExecutorService.
Practice Project: Task Manager
Build a task manager that:
- Accepts multiple tasks.
- Uses
ExecutorService. - Executes tasks concurrently.
- Uses
Callablefor tasks that return values. - Uses
Futureto retrieve results. - Handles task failures.
- Supports graceful shutdown.
- Demonstrates cancellation with interruption.
Example architecture:
1Task Manager 2 | 3 v 4ExecutorService 5 | 6 +---- Task 1 7 | 8 +---- Task 2 9 | 10 +---- Task 3 11 | 12 v 13Future Results
Practice Exercises
Exercise 1: Countdown Timer
Create a task that counts down from 10 to 1 with a one-second delay.
Requirements:
- Use
Runnable. - Use
Thread.sleep(). - Handle
InterruptedException. - Restore the interrupt status when appropriate.
Exercise 2: Concurrent File Processing
Create three independent file-processing tasks and execute them using a fixed thread pool.
Requirements:
- Use
ExecutorService. - Limit the number of concurrent workers.
- Print task start and completion messages.
- Shut down the executor correctly.
Exercise 3: Bank Account
Create a shared BankAccount accessed by multiple threads.
Requirements:
- Support deposit.
- Support withdrawal.
- Prevent negative balances.
- Protect shared state.
- Verify the final balance.
Exercise 4: Ticket Booking System
Create a ticket-booking system where multiple users attempt to reserve a limited number of seats.
Requirements:
- Prevent overselling.
- Protect the shared seat count.
- Test the system with multiple threads.
- Print successful and failed bookings.
Exercise 5: Producer-Consumer
Implement a bounded producer-consumer system using BlockingQueue.
Requirements:
- One producer.
- Multiple consumers.
- Bounded queue.
- Graceful interruption.
- Correct task completion.
Exercise 6: Concurrent Web Requests
Create simulated network tasks using Callable<String>.
Requirements:
- Submit tasks to an executor.
- Return a response string from each task.
- Retrieve results using
Future. - Handle failures.
- Compare sequential and concurrent execution.
Java Concurrency Learning Roadmap
1Java Fundamentals 2 | 3 v 4Thread and Runnable 5 | 6 v 7Thread Lifecycle 8 | 9 v 10sleep() / join() / interrupt() 11 | 12 v 13Race Conditions 14 | 15 v 16synchronized 17 | 18 v 19Atomic Variables and volatile 20 | 21 v 22Callable and Future 23 | 24 v 25ExecutorService 26 | 27 v 28BlockingQueue 29 | 30 v 31Locks and Synchronization Utilities 32 | 33 v 34CompletableFuture 35 | 36 v 37Concurrent Collections 38 | 39 v 40Advanced Concurrency Projects
Summary
Java multithreading allows applications to execute multiple tasks concurrently, but concurrency also introduces challenges such as race conditions, visibility problems, deadlocks, and thread lifecycle management.
In this tutorial, you learned:
- What threads are and how they relate to processes.
- The difference between concurrency and parallelism.
- How to create threads using
Thread. - How to represent tasks using
Runnable. - Why
start()andrun()behave differently. - How
sleep(),join(), andinterrupt()work. - How Java represents thread states.
- What race conditions are.
- How
synchronizedprotects shared state. - When atomic variables are useful.
- What
volatileprovides and what it does not provide. - How
CallableandFuturesupport result-producing tasks. - How
ExecutorServicemanages worker threads. - How to use scheduled executors.
- What deadlocks are and how lock ordering can prevent them.
- How
ReentrantLock,BlockingQueue,ConcurrentHashMap,CountDownLatch, andSemaphoresupport common concurrency patterns. - How to design safer concurrent applications by reducing shared mutable state.
For modern Java development, focus on understanding the higher-level concurrency APIs rather than creating and manually managing a large number of raw threads.