What is the difference between Sequential, Concurrent, and Parallel Execution? #
What is Sequential Execution?
- Example Scenario: 3 steps in a program
- Step 1: IO-bound (e.g., API call, file read)
- Step 2: IO-bound (e.g., database query, network request)
- Step 3: Combines results from Step 1 & Step 2
- Assumption: Single-core CPU
- Sequential Execution: Run each task after the completion of the previous one.
- Problem: Steps 1 and 2 run one after another, wasting CPU time.
What is Concurrent Execution?
- What if we can start Step 2 while Step 1 is waiting?
- Idea: Start Step 2 without waiting for Step 1 to complete!
- Benefit: Reduces total execution time by utilizing waiting periods.
- Advantages of Concurrent Execution:
- Faster Execution – No wasted time waiting for IO or other activities.
- Efficient Resource Usage – CPU is used for other tasks while a task is waiting.
- Better User Experience – Faster response times in applications.
- Scalability – Handles more tasks in the same time.
- Concurrency = Smart juggling of tasks
- When there's a lot of waiting involved: I/O operations like database calls, API requests, or file reads.
- Single CPU can switch between multiple tasks
- Real-world analogy: A chef cooking multiple dishes. Stir the soup, then chop veggies while waiting for it to boil.
What is Parallel Execution?
- Multi-core CPUs: Modern processors have multiple cores for better performance
- Run Multiple Tasks at Same Time: They can run multiple tasks at the same time - one in each core
Execution Types
Execution Type | How It Works | Real-World Example |
---|---|---|
Sequential | One task at a time | Single chef, one dish at a time |
Concurrent | Switches from one task to another when waiting | Chef multitasking (chopping while boiling) |
Parallel | Multiple tasks at the same time | Multiple chefs cooking different dishes |
MIX OF BOTH | Multiple tasks run at the same time. If any of tasks is waiting, you switch to a different task immediately. | Restaurant with multiple chefs multitasking |
What is we make use of both Concurrency and Parallelism?
- Multiple CPU cores handle tasks simultaneously (parallelism).
- If a task is waiting, CPU switches to another task (concurrency).
- Best of both worlds!
- Fast execution + Efficient resource usage.
How do you decide between extending Thread and implementing Runnable? #
1. Extending Thread
Class
class CountdownThread extends Thread {
public void run() {
for (int i = 500000; i >= 1; i--) {
System.out.println("Countdown: " + i);
}
}
}
public class Main {
public static void main(String[] args) {
new CountdownThread().start();
}
}
2. Implementing Runnable
Interface
class CountdownThreadRunnable implements Runnable {
public void run() {
for (int i = 500000; i >= 1; i--) {
System.out.println("Countdown: " + i);
}
}
}
public class Main {
public static void main(String[] args) {
new Thread(
new CountdownThreadRunnable()).start();
new Thread(
new CountdownThreadRunnable()).start();
}
}
Comparison
Feature | Extend Thread Class |
Implement Runnable Interface |
---|---|---|
Extend Another Class? | ❌ No (Single inheritance limit) | ✅ Yes |
Recommended? | ❌ No | ✅ Yes (Preferred approach) |
When should you use Callable instead of Runnable? #
Runnable
can’t return values, but Callable
can!
import java.util.concurrent.*;
class FactorialTask
implements Callable<Integer> {
private int number;
FactorialTask(int number) {
this.number = number;
}
public Integer call() {
int result = 1;
for (int i = 2; i <= number; i++)
result *= i;
return result;
}
}
public class Main {
public static void main(String[] args)
throws Exception {
ExecutorService executor
= Executors.newSingleThreadExecutor();
Future<Integer> future
= executor.submit(new FactorialTask(5));
System.out.println("Factorial: "
+ future.get());
executor.shutdown();
}
}
Thread Methods - join() vs sleep() vs yield() #
join()
– Waiting for a Thread to Finish
- What? Makes one thread wait for another to complete execution before continuing.
- Why? Useful when a thread must complete before another can proceed.
- Overloaded Method?
join(time)
waits only for a specific time before continuing.public class JoinExample { public static void main(String[] args) throws InterruptedException { Thread t1 = new MyThread(); Thread t2 = new MyThread(); t1.start(); t1.join(); // Main thread waits for t1 to finish t2.start(); // Main thread waits max 1 sec for t2 to finish t2.join(1000); System.out.println( "Main thread finished execution."); } }
yield()
– Suggesting CPU Time Release
- What? Hints to the scheduler that the current thread can pause execution to let other threads run.
- Why? Allows other threads with the same or higher priority to execute first.
- Does the scheduler always listen? No, it’s just a hint, and the scheduler may ignore it.
class YieldExample implements Runnable { public void run() { for (int i = 0; i < 5; i++) { System.out.println( Thread.currentThread().getName() + " is running."); // Giving a chance to other threads Thread.yield(); } } }
sleep(ms)
– Pausing Execution for Some Time
- What? Puts the current thread to sleep for a given time (milliseconds).
- Why? Useful for delays, avoiding busy-waiting, and simulating real-world delays.
- Throws?
InterruptedException
if another thread interrupts it while sleeping.class SleepExample implements Runnable { public void run() { System.out.println( Thread.currentThread().getName() + " is running."); try { Thread.sleep(3000); // Sleep for 3 seconds } catch (InterruptedException e) { e.printStackTrace(); } System.out.println( Thread.currentThread().getName() + " woke up!"); } }
What are the different states of a Thread? #
- NEW State → Thread is created but not started
- Exists before calling
start()
- Example: After
Thread t = new Thread();
but before starting the thread
- Exists before calling
- RUNNABLE State → Thread is ready but waiting for CPU
- Competes with other threads for execution
- Example: After calling
t.start();
but before start of execution
- RUNNING State → Thread is executing its
run()
method- Stays here until it finishes or is interrupted
- BLOCKED/WAITING State → Thread is waiting for something!
- 1: Trying to access a locked resource
- 2: Waiting for another thread to finish
- TERMINATED (DEAD) State → Thread has finished execution
- Cannot be restarted once terminated
- Example: Completed thread automatically enters this state
How to change Thread priority? #
Thread priority determines how important a thread is for CPU scheduling. Priority is represented by an integer value from 1 to 10. Threads with higher priority get preference over lower-priority threads.
It is a hint to the scheduler but does not guarantee execution order.
Constant | Value | Description |
---|---|---|
Thread.MIN_PRIORITY |
1 | Lowest priority |
Thread.NORM_PRIORITY |
5 | Default priority |
Thread.MAX_PRIORITY |
10 | Highest priority |
How to Set Thread Priority?
Use the setPriority(int priority)
method to change priority. Use the getPriority()
method to check the current priority.
class MyThread extends Thread {
public void run() {
System.out.println(
Thread.currentThread().getName() +
" running with priority "
+ Thread.currentThread().getPriority());
}
}
public class ThreadPriorityExample {
public static void main(String[] args) {
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
thread1.setPriority(Thread.MIN_PRIORITY); // Priority 1
thread2.setPriority(Thread.MAX_PRIORITY); // Priority 10
thread1.start();
thread2.start();
}
}
How Does Thread Priority Work?
- Higher-priority threads are more likely to run before lower-priority threads.
- But it is not guaranteed! The CPU scheduler may still choose lower-priority threads.
- If multiple threads have the same priority, the scheduler picks any randomly.
What is the difference between Operating System Processes and Operating System Threads? #
Process: Full application (e.g., Google Chrome).
Thread: A smaller unit inside a process (e.g., Each Each Google Chrome Tab).
Feature | OS Process | OS Thread |
---|---|---|
Definition | Independent program execution | A lightweight process inside a program |
Memory | Each process has its own separate memory space (used by all of its threads) | Shared memory within a process (Threads share memory with other threads of the same process) |
Speed | Slow to start | Faster to start (comparatively) |
Example | Google Chrome | Each Google Chrome Tab |
Why did Java introduce Virtual Threads? #
Traditionally, when using Java Platform Threads, each Java platform thread maps to an OS thread.
Problems with Java Platform Threads
Problem | Why It’s Bad |
---|---|
High Memory Usage | Each OS thread needs ~1MB memory. 10,000 threads → ~10GB memory |
Expensive Context Switching | More OS threads => More CPU time spent switching |
Limited Scalability | Cannot create millions of threads |
IO-Bound Inefficiency | If a platform thread waits for API/DB, an OS thread is blocked doing nothing. |
What are Virtual Threads?
Virtual threads run thousands (or millions) of threads on top of a small pool of OS threads. You can run millions of lightweight threads without high memory usage.
Advantages
Feature | Virtual Threads |
---|---|
Memory Usage | Low |
Context Switching | Cheap |
Scalability | Very high |
Comparison: Traditional Java Threads vs Virtual Threads
Feature | Platform Threads | Virtual Threads |
---|---|---|
Thread Creation | Expensive | Very cheap |
Max Threads | Few thousand | Millions |
Best Used In | CPU-Bound Tasks with Low Number of Threads | High concurrency apps (Web servers) |
When to Use Traditional Java Threads?
- CPU-Bound Tasks with Low Number of Threads – Heavy computations (e.g., image processing, data crunching) that need only a few threads, OS threads are fine.
ExecutorService executor
= Executors.newFixedThreadPool(10);
When to Use Virtual Threads?
- Lots of IO-Bound Tasks – Database queries, API calls, file reads.
- Massive Concurrency – Thousands or millions of tasks (e.g., handling web requests).
- Efficient Resource Usage – No wasted OS threads waiting for IO.
ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor();
Example: Running 100,000 tasks efficiently
public class Main {
public static void main(String[] args) {
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100000; i++) {
executor.submit(
() -> System.out.println(
"Hello from " +
Thread.currentThread()));
}
}
}
}
What is the need for pooling Threads? #
Example: Without a Thread Pool (Inefficient)
- Creates a new thread for every task, which is expensive.
class Task implements Runnable { public void run() { System.out.println( Thread.currentThread().getName() + " executing task."); } } public class Main { public static void main(String[] args) { for (int i = 0; i < 1000; i++) { new Thread(new Task()).start(); } } }
Example: Using a Thread Pool (Efficient)
- Uses fixed-size thread pool to reuse threads.
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; class Task implements Runnable { public void run() { System.out.println( Thread.currentThread().getName() + " executing task."); } } public class Main { public static void main(String[] args) { ExecutorService pool = Executors.newFixedThreadPool(10); for (int i = 0; i < 1000; i++) { pool.execute(new Task()); } pool.shutdown(); } }
Problems Without Thread Pooling
- High Overhead – Creating/destroying threads frequently is expensive.
- Difficult to Manage – Tracking and stopping multiple threads manually is hard.
How Thread Pooling Solves These Issues
- Reuses Existing Threads – No need to create/destroy threads repeatedly.
- Limits Number of Threads – Prevents system overload.
- Efficient Task Execution – Threads are reused for new tasks.
- Easy to Manage – Centralized control using
ExecutorService
.
Key Takeaways
ExecutorService
simplifies Thread Pool management.- Thread pooling avoids issues like excessive thread creation.
How to choose between different options of creating an ExecutorService? #
1. Single Thread Executor
Executors.newSingleThreadExecutor()
creates an executor with a single worker thread that executes submitted tasks sequentially, one at a time.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SingleThreadExecutorExample {
public static void main(String[] args) {
ExecutorService executor
= Executors.newSingleThreadExecutor();
executor.execute(
() ->
System.out.println(
"Logging event: User Login"));
executor.execute(
() ->
System.out.println(
"Logging event: User Logout"));
executor.shutdown();
}
}
2. Fixed Thread Pool
Executors.newFixedThreadPool(int n)
creates a thread pool with a fixed number of threads to execute tasks concurrently, reusing threads for improved performance.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor
= Executors.newFixedThreadPool(5);
for (int i = 1; i <= 100; i++) {
final int fileId = i;
executor.execute(
() -> System.out.println(
"Uploading file " + fileId));
}
executor.shutdown();
}
}
3. Single Thread Scheduled Executor
newSingleThreadScheduledExecutor()
creates a scheduler with a single background thread that executes tasks sequentially, supporting delayed and periodic task execution using scheduling methods.
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class SingleThreadScheduledExecutorExample {
public static void main(String[] args) {
ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor();
// 1. Run once after 24 hours
scheduler.schedule(() ->
System.out.println(
"Running DB 1 Cleanup Once after 24 hours"),
24, TimeUnit.HOURS);
// 2. Run every 24 hours (fixed rate)
scheduler.scheduleAtFixedRate(() ->
System.out.println(
"Running DB 2 Cleanup - Every 24 hours"),
0, 24, TimeUnit.HOURS);
// 3. Run 24 hours
// after previous task ends (fixed delay)
scheduler.scheduleWithFixedDelay(() ->
System.out.println(
"Running DB 3 Cleanup - Every 24 hours"),
0, 24, TimeUnit.HOURS);
}
}
4. Scheduled Thread Pool
Executors.newScheduledThreadPool(int corePoolSize)
creates a thread pool that can schedule commands to run after a delay or periodically using a thread pool with configurable number of threads.
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ThreadPoolScheduledExecutorExample {
public static void main(String[] args) {
// Create a thread pool with 5 threads
ScheduledExecutorService scheduler
= Executors.newScheduledThreadPool(5);
// 1. Run once after 10 seconds
scheduler.schedule(() ->
System.out.println(
"Health Check 1 - One-time after 10 seconds"),
10, TimeUnit.SECONDS);
// 2. Run every 10 seconds (fixed rate)
scheduler.scheduleAtFixedRate(() ->
System.out.println(
"Health Check 2 - Every 10 seconds"),
0, 10, TimeUnit.SECONDS);
// 3. Run 10 seconds
// after previous task ends (fixed delay)
scheduler.scheduleWithFixedDelay(() ->
System.out.println(
"Health Check 3 - Every 10 seconds"),
0, 10, TimeUnit.SECONDS);
}
}
5. Virtual Thread Per Task Executor
Creates a new virtual thread per task, ideal for massively concurrent workloads (e.g., handling millions of requests).
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadExample {
public static void main(String[] args) {
try (ExecutorService executor
= Executors.
newVirtualThreadPerTaskExecutor()) {
executor.submit( () ->
System.out.println(
"Running virtual thread task") );
}
}
}
Key Takeaways
- Single Thread Executor: Executes tasks one by one using a single thread, ensuring sequential processing. Ideal for logging etc...
- Fixed Thread Pool: Uses a fixed number of threads to handle multiple tasks in parallel. Great for managing concurrent workloads like file uploads.
- Single Thread Scheduled Executor: Schedules tasks to run after a delay or periodically with one thread. Perfect for serialized tasks like daily database cleanup or report generation.
- Scheduled Thread Pool: Runs tasks repeatedly at fixed intervals using multiple threads. Useful for recurring jobs like health checks or system monitoring.
- Virtual Thread Per Task Executor: Creates a lightweight virtual thread for each task, enabling massive concurrency.
What is Thread Safety? What are Race Conditions? #
Thread safety ensures multiple threads can access shared resources without conflicts or errors.
Example: Without Thread Safety
package com.in28minutes;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) {
Counter counter = new Counter();
ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor();
Runnable task = () -> {
for (int i = 0; i < 10; i++) {
counter.increment();
}
};
for (int i = 0; i < 100000; i++)
executor.submit(task);
executor.shutdown();
// Wait for tasks to finish
while (!executor.isTerminated()) { }
System.out.println(
"Final Count: " + counter.getCount());
//Final Count: 1000000 //MIGHT BE DIFFERENT
//Final Count: 985813 //MIGHT BE DIFFERENT
}
}
class Counter {
private int count = 0;
public void increment() {
count++; // NOT thread-safe
}
public int getCount() {
return count;
}
}
What Does count++
Actually Do?
The statement count++
looks simple but is actually a three-step operation at the CPU level:
- Read the value of
count
from memory (Load)MOV EAX, [count] ; Load count into register EAX
- Increment the value (Modify)
ADD EAX, 1 ; Increment the value in EAX
- Store the incremented value back to
count
(Write)MOV [count], EAX ; Store updated value back in memory
count++
is NOT atomic
- Steps? Read → Modify → Write. Each step is separate.
- Atomic? Means "all-or-nothing" – can't be interrupted in the middle.
- Problem? Other threads can sneak in between the steps of executing
count++
.
Expected Sequence
Imagine two threads, Thread-1 and Thread-2, running count++
at the same time.
It is possible that the operations are done one after the other in the expected way with the expected result.
Step | Thread-1 (T1) | Thread-2 (T2) | count Value |
---|---|---|---|
1 | Reads count = 1000 |
1000 |
|
2 | Increments value to 1001 |
1000 |
|
3 | Stores 1001 in count |
1001 |
|
4 | Reads count = 1001 |
1001 |
|
5 | Increments value to 1002 |
1001 |
|
6 | Stores 1002 in count |
1002 |
Alternate Sequence
Both threads read the same initial value (1000).They increment separately, but the last write overwrites the first one.
Step | Thread-1 (T1) | Thread-2 (T2) | count Value |
---|---|---|---|
1 | Reads count = 1000 |
1000 |
|
2 | Reads count = 1000 |
1000 |
|
3 | Increments value to 1001 |
1000 |
|
4 | Increments value to 1001 |
1000 |
|
5 | Stores 1001 in count |
1001 |
|
6 | Stores 1001 in count |
1001 ❌ (Lost Update!) |
You do not know which sequence will happen, leading to unpredictable final counts.
What is a Race Condition?
A race condition occurs when two or more threads access shared data at the same time, and the program’s outcome depends on the sequence or timing of their execution.
Race Condition vs Thread Safety
- Race condition: Happens when multiple threads access shared data simultaneously, causing unpredictable results due to timing issues.
- Thread safety: Ensures code behaves correctly when accessed by multiple threads, preventing race conditions.
- Thread-safe code: Uses techniques to control access to shared data. Ensures correct behavior in multithreaded environments.
Solving Race Conditions in Java #
📌 1. Using synchronized
methods and blocks
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
What does synchronized do?
synchronized
locks theincrement()
method- Only one thread can enter
increment()
at a time - Prevents two threads from changing
count
together - Ensures thread-safe update of shared variable
What are the problems with synchronized?
- OLD synchronized collections: A few old Collection classes use synchronized methods
- Example:
Vector
– Thread-safe version ofArrayList
using synchronized methods - Another Example:
Hashtable
– Thread-safe version ofHashMap
- Example Methods in
Vector
class:public synchronized E get(int index)
public synchronized E set(int index, E element)
public synchronized boolean add(E e)
public synchronized void removeAllElements()
- All of the above example methods are synchronized
- Example:
- Problem: What is the problem with these synchronized methods?
- They lock the entire object, even for small operations
- Only one thread can access any method at a time
- Other threads must wait, even for simple reads
- Can become a bottleneck when many threads are used
📌 2. ReentrantLock
– More Flexible Locking
- What? Explicitly locks and unlocks
- Why? Allows more control
import java.util.concurrent.locks.ReentrantLock;
class Counter {
private int count = 0;
private ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
return count;
}
}
📌 3. ReadWriteLock
– Optimized Locking for Read-Heavy Operations
- What? Allows multiple readers but only one writer.
- Why? Improves performance in read-heavy applications.
- When? Best when reads are more frequent than writes.
import java.util.concurrent.locks.ReentrantReadWriteLock;
class SharedResource {
private int data = 0;
private ReentrantReadWriteLock lock
= new ReentrantReadWriteLock();
public void write(int value) {
lock.writeLock().lock();
try {
data = value;
} finally {
lock.writeLock().unlock();
}
}
public int read() {
lock.readLock().lock();
try {
return data;
} finally {
lock.readLock().unlock();
}
}
}
📌 4. Atomic Variable Classes (AtomicInteger
, AtomicBoolean
, AtomicLong
)
- Atomic operation: Sequence of instructions that executes as a single, indivisible unit (thread-safe)
- What? Provides atomic operations to ensure thread safety.
- When? Best for counters and simple numeric operations.
import java.util.concurrent.atomic.AtomicInteger; class Counter { private AtomicInteger count = new AtomicInteger(0); public void increment() { // Atomic operation count.incrementAndGet(); } public void decrement() { // Atomic operation count.decrementAndGet(); } public int getCount() { return count.get(); } }
Useful Atomic Operation Methods in Typical Atomic Variable Classes
get()
– Reads the current value safelyset(value)
– Updates the value directlyincrementAndGet()
– Adds 1 and returns new valuegetAndIncrement()
– Returns current value, then adds 1decrementAndGet()
– Subtracts 1 and returns new valueaddAndGet(n)
– Addsn
and returns resultcompareAndSet(expected, newValue)
– Updates value only if it matches expectedgetAndSet(newValue)
– Replaces with new value and returns old value
📌 5. Concurrent Collections (ConcurrentHashMap
,..)
- A few old Collection classes use synchronized
- Example:
Vector
– Thread-safe version ofArrayList
using synchronized methods - Another Example:
Hashtable
– Thread-safe version ofHashMap
- Example Methods in
Vector
classpublic synchronized E get(int index)
public synchronized E set(int index, E element)
public synchronized boolean add(E e)
public synchronized void removeAllElements()
- Example:
- What is the problem with these thread safe methods?
- They lock the entire object, even for small operations
- Only one thread can access any method at a time
- Other threads must wait, even for simple reads
- Can become a bottleneck when many threads are used
- What is the solution?
- Java provides built-in thread-safe high performant concurrent collections.
- These collections are called Concurrent Collections
Examples of Concurrent Collections
Concurrent Collection | Replaces |
---|---|
ConcurrentHashMap |
Hashtable / HashMap |
CopyOnWriteArrayList |
Vector / ArrayList |
ConcurrentLinkedQueue |
LinkedList |
Example with HashMap
import java.util.*;
import java.util.concurrent.*;
public class VirtualThreadMapBroken {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("count", 0); // Initial count
ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100000; i++) {
executor.submit(() -> {
map.put("count", map.get("count") + 1);
});
}
executor.shutdown();
while (!executor.isTerminated()) { }
// Sometimes < 100000
System.out.println("Final count: "
+ map.get("count"));
}
}
Example with ConcurrentHashMap
import java.util.*;
import java.util.concurrent.*;
public class VirtualThreadMapFixed {
public static void main(String[] args) {
// CHANGE 1: ConcurrentHashMap instead of HashMap
Map<String, Integer> map = new ConcurrentHashMap<>();
map.put("count", 0); // Initial count
ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100000; i++) {
executor.submit(() -> {
// CHANGE 2: Atomic update - compute method!
map.compute("count",
(key, value) -> value + 1 );
});
}
executor.shutdown();
while (!executor.isTerminated()) { }
// ALWAYS 100000
System.out.println("Final count: " + map.get("count"));
}
}
Example Thread Safe Operations on ConcurrentHashMap
putIfAbsent(key, value)
– Adds only if key is not already presentreplace(key, oldValue, newValue)
– Updates value only if old value matchescompute(key, (k, v) -> ...)
– Atomically updates the value using a lambda
Other Concurrent Collection Examples
Class | Use Case |
---|---|
ConcurrentHashMap |
High-performance, thread-safe HashMap , ideal for concurrent read/write operations. |
CopyOnWriteArrayList |
Thread-safe ArrayList , best for frequent reads and infrequent writes. |
CopyOnWriteArraySet |
Thread-safe Set , good for scenarios with more reads than writes. |
ConcurrentLinkedQueue |
Lock-free, thread-safe queue, best for highly concurrent FIFO operations. |
📌 Summary
Technique | Best Used For |
---|---|
synchronized |
Protecting shared resources from concurrent modification. |
ReentrantLock |
Fine-grained locking, tryLock() for timeouts. |
ReadWriteLock |
Read-heavy applications where writes are rare. |
Atomic Variable Classes | Thread safe numeric operations like counters (AtomicInteger ). |
Concurrent Collections | Thread safe high performant collections. |
Locks vs. Synchronized Approach #
Example 1: Using synchronized
package com.in28minutes.concurrency;
public class BiCounter {
private int i = 0;
private int j = 0;
synchronized public void incrementI() {
i++;
}
synchronized public void incrementJ() {
j++;
}
public int getI() {
return i;
}
public int getJ() {
return j;
}
}
Example 2: Using synchronized with Object Lock
package com.in28minutes.concurrency;
public class BiCounter {
private int i = 0;
private int j = 0;
private final Object lockForI = new Object();
private final Object lockForJ = new Object();
public void incrementI() {
synchronized (lockForI) {
i++;
}
}
public void incrementJ() {
synchronized (lockForJ) {
j++;
}
}
public int getI() {
return i;
}
public int getJ() {
return j;
}
}
Example 3: Using ReentrantLock
package com.in28minutes.concurrency;
import java.util.concurrent.locks.ReentrantLock;
public class BiCounterWithLocks {
private int i = 0;
private int j = 0;
private Lock LockForI = new ReentrantLock();
private Lock LockForJ = new ReentrantLock();
public void incrementI() {
lockForI.lock();
i++;
lockForI.unlock();
}
public void incrementJ() {
lockForJ.lock();
j++;
lockForJ.unlock();
}
public int getI() {
return i;
}
public int getJ() {
return j;
}
}
QUICK COMPARISON
1. synchronized
(Simple Version)
- Easy to write
- Blocks all other synchronized methods on the same object
- Good for low-concurrency or simple logic
2. Object Locks (synchronized (lock)
)
- More fine-grained control
- Allows
incrementI()
andincrementJ()
to run at same time
3. ReentrantLock
- Full control with
lock()
,unlock()
,tryLock()
, timeoutslock()
– Manually lock a critical sectionunlock()
– Manually release the lock (always required!)tryLock()
– Try to get the lock without waiting forevertryLock(timeout, unit)
– Wait for a while, give up if not acquired
- Great for advanced concurrency needs
When do you use ThreadLocal? #
- What? Each thread gets its own separate instance of a variable.
- Why? Eliminates race conditions because there is no shared data between threads.
- When? Best for caching per-thread data (e.g., user sessions).
class ThreadLocalExample {
private static ThreadLocal<Integer> threadLocalCount =
ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) {
Runnable task = () -> {
int count = threadLocalCount.get();
threadLocalCount.set(count + 1);
System.out.println(
Thread.currentThread().getName() +
" - " + threadLocalCount.get());
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
}
}
When do you use a Semaphore? #
- What? Semaphore allows a limited number of threads to access a resource.
- Why? Prevents too many threads from accessing a shared resource at the same time.
- When? Best for controlling concurrent access to databases or APIs.
import java.util.concurrent.Semaphore;
class Worker implements Runnable {
private Semaphore semaphore;
Worker(Semaphore semaphore) {
this.semaphore = semaphore;
}
public void run() {
try {
semaphore.acquire(); // Acquire permit
System.out.println(
Thread.currentThread().getName() +
" working...");
Thread.sleep(1000);
semaphore.release(); // Release permit
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class Main {
public static void main(String[] args) {
// Max 2 threads at a time
Semaphore semaphore = new Semaphore(2);
new Thread(new Worker(semaphore)).start();
new Thread(new Worker(semaphore)).start();
new Thread(new Worker(semaphore)).start();
}
}
When do you use a CountDownLatch? #
- What? A thread waits for a set number of threads to finish before proceeding.
- Why? Prevents race conditions by ensuring dependent tasks complete first.
- When? Best for waiting for multiple tasks to complete before continuing.
import java.util.concurrent.CountDownLatch;
class Worker implements Runnable {
private CountDownLatch latch;
Worker(CountDownLatch latch) {
this.latch = latch;
}
public void run() {
System.out.println(
Thread.currentThread().getName() +
" finished work.");
latch.countDown(); // Reduce the latch count
}
}
public class Main {
public static void main(String[] args)
throws InterruptedException {
CountDownLatch latch = new CountDownLatch(2);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new Worker(latch));
t.start();
}
latch.await();
System.out.println(
"At least 2 workers finished execution.");
}
}
Which
Object
Methods Are Used for Inter-Thread Communication? #
Method | Description | When to Use? |
---|---|---|
wait() |
Makes a thread wait until another thread calls notify() or notifyAll() . |
When a thread needs to pause execution until some condition is met. |
notify() |
Wakes up one waiting thread that previously called wait() . |
When a thread completes a task and notifies another thread to continue. |
notifyAll() |
Wakes up all threads waiting on the same object. | When multiple threads are waiting, and all need to be resumed. |
Note: These methods must be called inside a synchronized block to avoid IllegalMonitorStateException
.
Example: Inter-Thread Communication Using wait()
and notify()
- Thread-1 (Main Thread) waits for Thread-2 (Calculator Thread) to finish computing the sum.
- Thread-2 calculates the sum and notifies
Thread-1
usingnotify()
. - Sequence of Execution:
- Main thread acquires a lock on
thread
and callsthread.wait()
. - Calculator thread starts execution and acquires the same lock.
- Once computation is done,
notify()
is called, waking up the main thread. - Main thread resumes execution and prints the result.
- Main thread acquires a lock on
class Calculator extends Thread {
long sumUptoMillion;
public void run() {
synchronized (this) { // Locking on this object
calculateSumUptoMillion();
notify(); // Notify waiting thread
}
}
private void calculateSumUptoMillion() {
for (int i = 0; i < 1000000; i++) {
sumUptoMillion += i;
}
}
}
public class ThreadWaitNotify {
public static void main(String[] args)
throws InterruptedException {
Calculator thread = new Calculator();
synchronized (thread) { // Locking on the same object
thread.start(); // Start calculation thread
thread.wait(); // Wait for notification
}
System.out.println("Sum up to million: "
+ thread.sumUptoMillion);
}
}
Why are these rarely used?
- Requires
synchronized
- Hard to use and blocks all other synchronized methods/blocks on the same object. - Risk of Missed Signals – If
notify()
is called beforewait()
, the thread may wait forever. - Better Alternatives Exist – ReentrantLock, Semaphore, CountDownLatch ...
Why do Deadlocks occur? #
What is a Deadlock?
- A deadlock occurs when two or more threads wait for each other indefinitely, preventing further execution.
Example: Deadlock Situation
Thread-1
locks Resource A and waits for Resource B.Thread-2
locks Resource B and waits for Resource A.- Since neither thread can proceed, the program freezes forever (Deadlock!).
class Resource {
private final String name;
public Resource(String name) {
this.name = name;
}
void use(Resource other) {
// Locking this resource first
synchronized (this) {
System.out.println(
Thread.currentThread().getName()
+ " locked " + this.name);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Now locking the second resource
synchronized (other) {
System.out.println(
Thread.currentThread().getName()
+ " locked " + other.name);
}
}
}
}
public class DeadlockExample {
public static void main(String[] args) {
Resource resourceA
= new Resource("Resource A");
Resource resourceB
= new Resource("Resource B");
// Thread-1 locks A → B
Thread t1 = new Thread(()
-> resourceA.use(resourceB),
"Thread-1");
// Thread-2 locks B → A (Deadlock risk!)
Thread t2 = new Thread(()
-> resourceB.use(resourceA),
"Thread-2");
t1.start();
t2.start();
}
}
Discuss an option to solve above deadlock #
One Option: Lock Resources in the Same Order
- Lock resources in the same order, ensuring a consistent locking hierarchy.
- How?
- Sort resources by unique identifiers (e.g., object hash, resource ID).
- Always lock the lower ID first before the higher ID.
class Resource { private final String name; public Resource(String name) { this.name = name; } void use(Resource other) { Resource first = this.name .compareTo(other.name) < 0 ? this : other; Resource second = this.name .compareTo(other.name) < 0 ? other : this; // Always lock lower-named resource first synchronized (first) { System.out.println( Thread.currentThread().getName() + " locked " + first.name); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (second) { // Lock 2nd resource System.out.println( Thread.currentThread().getName() + " locked " + second.name); } } } } public class DeadlockSolution { public static void main(String[] args) { Resource resourceA = new Resource("Resource A"); Resource resourceB = new Resource("Resource B"); // Both threads now lock in the same order: A → B Thread t1 = new Thread( () -> resourceA.use(resourceB), "Thread-1"); Thread t2 = new Thread( () -> resourceB.use(resourceA), "Thread-2"); t1.start(); t2.start(); } }
Other Options to Prevent or Handle Deadlocks
- Use Try-Lock with Timeout: Replace
synchronized
withReentrantLock.tryLock(timeout)
to avoid waiting forever. If a lock can’t be acquired within the timeout, retry or exit gracefully. - Coarse-Grained Locking: Use a single global lock instead of multiple fine-grained ones. This reduces concurrency but guarantees that threads won’t compete for multiple resources simultaneously.
- Avoid Nested Locks: Design code so that threads acquire only one lock at a time. This simple discipline removes the possibility of circular waits and eliminates deadlocks entirely.
ExecutorService vs. ForkJoinPool: When to use each? #
Example: Sum an array using parallelism
import java.util.concurrent.*;
class SumTask extends RecursiveTask<Integer> {
private int[] arr;
private int start, end;
SumTask(int[] arr, int start, int end) {
this.arr = arr;
this.start = start;
this.end = end;
}
protected Integer compute() {
if (end - start <= 2) {
int sum = 0;
for (int i = start; i < end; i++) sum += arr[i];
return sum;
}
int mid = (start + end) / 2;
SumTask left = new SumTask(arr, start, mid);
SumTask right = new SumTask(arr, mid, end);
left.fork();
return right.compute() + left.join();
}
}
public class Main {
public static void main(String[] args) {
int[] numbers = {1, 2, 3, 4, 5, 6, 7, 8};
ForkJoinPool pool = new ForkJoinPool();
int sum = pool.invoke(
new SumTask(numbers, 0, numbers.length));
System.out.println("Sum: " + sum);
}
}
ForkJoinPool
- When? Best for divide-and-conquer tasks like sorting, searching.
- Why? Efficiently splits tasks across multiple CPU cores.
Comparison
Feature | ExecutorService | ForkJoinPool |
---|---|---|
Best For? | Independent tasks | Recursive tasks (divide-and-conquer algorithms) |
Example Use Case | Running 100 different tasks in parallel | Addition of 10 Million numbers |
How can you enhance parallelization of code using Streams? #
- Multi-core CPUs: Modern processors have multiple cores for better performance
- Parallelization: Run Multiple Tasks at Same Time (one in each core)
- Functional code can easily be parallelized:
parallel()
orparallelStream()
: Enables parallel execution.parallel()
is useful if you start with sequential stream, then decide to switch to parallel..parallelStream()
is cleaner and more direct for starting in parallel.
- How does it work?
- Stream is split into multiple parts
- Each part runs on a different core
- Finally, the results are combined
List<Integer> numbers =
IntStream.rangeClosed(1, 1_000_000)
.boxed()
.collect(Collectors.toList());
long count1 = numbers.stream()
.parallel()
.filter(n -> n % 2 == 0)
.count();
long count2 = numbers.parallelStream()
.filter(n -> n % 2 == 0)
.count();