Java Multithreading Basics – Part 2
Advanced Concepts: Communication, Concurrency Utilities, and Best Practices
7. Inter-Thread Communication in Java
Why Threads Need to Communicate
In a multithreaded program, sometimes threads need to coordinate their actions. For example, in a producer-consumer scenario, the producer should wait when the buffer is full, and the consumer should wait when the buffer is empty. This is where inter-thread communication is required.
The Problem Without Communication
Without proper communication, threads would end up using inefficient techniques like busy waiting (constantly checking a condition), which consumes CPU resources unnecessarily.
Key Methods for Inter-Thread Communication
Java provides three methods from the Object class to allow threads to communicate safely:
Method | Description |
---|---|
wait() | Causes the current thread to wait until another thread invokes notify() or notifyAll() on the same object. |
notify() | Wakes up a single thread that is waiting on the object's monitor. |
notifyAll() | Wakes up all threads waiting on the object's monitor. |
These methods must be called within a synchronized context, or they will throw IllegalMonitorStateException.
Example: Producer-Consumer with wait() and notify()
class SharedResource {
private int data;
private boolean available = false;
public synchronized void produce(int value) {
while (available) {
try {
wait();
} catch (InterruptedException e) {}
}
data = value;
System.out.println("Produced: " + data);
available = true;
notify();
}
public synchronized void consume() {
while (!available) {
try {
wait();
} catch (InterruptedException e) {}
}
System.out.println("Consumed: " + data);
available = false;
notify();
}
}
class Producer extends Thread {
SharedResource resource;
Producer(SharedResource r) {
this.resource = r;
}
public void run() {
for (int i = 1; i <= 5; i++) {
resource.produce(i);
}
}
}
class Consumer extends Thread {
SharedResource resource;
Consumer(SharedResource r) {
this.resource = r;
}
public void run() {
for (int i = 1; i <= 5; i++) {
resource.consume();
}
}
}
public class ProducerConsumerDemo {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Producer p = new Producer(resource);
Consumer c = new Consumer(resource);
p.start();
c.start();
}
}
Output:
Produced: 1
Consumed: 1
Produced: 2
Consumed: 2
...
Produced: 5
Consumed: 5
8. Thread Deadlock
Deadlock is a condition where two or more threads are blocked forever, each waiting for the other to release a lock. It occurs when multiple threads hold locks on different resources and wait indefinitely to acquire locks held by each other.
Conditions for Deadlock
A deadlock can occur if all four of the following conditions are true simultaneously:
- Mutual Exclusion – Each resource is held by only one thread at a time.
- Hold and Wait – A thread holding at least one resource is waiting to acquire additional resources held by other threads.
- No Preemption – A resource cannot be forcibly taken from a thread.
- Circular Wait – Two or more threads form a circular chain, each waiting for a resource held by the next.
Example: Classic Deadlock in Java
class Lock1 {}
class Lock2 {}
public class DeadlockExample {
private final Lock1 lock1 = new Lock1();
private final Lock2 lock2 = new Lock2();
public void method1() {
synchronized (lock1) {
System.out.println(Thread.currentThread().getName() + " locked Lock1");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock2) {
System.out.println(Thread.currentThread().getName() + " locked Lock2");
}
}
}
public void method2() {
synchronized (lock2) {
System.out.println(Thread.currentThread().getName() + " locked Lock2");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock1) {
System.out.println(Thread.currentThread().getName() + " locked Lock1");
}
}
}
public static void main(String[] args) {
DeadlockExample obj = new DeadlockExample();
Thread t1 = new Thread(() -> obj.method1(), "Thread-1");
Thread t2 = new Thread(() -> obj.method2(), "Thread-2");
t1.start();
t2.start();
}
}
Expected Output:
Thread-1 locked Lock1
Thread-2 locked Lock2
// Then it hangs due to deadlock
Both threads are waiting forever because:
- Thread-1 holds Lock1 and wants Lock2.
- Thread-2 holds Lock2 and wants Lock1.
How to Prevent Deadlock
- Avoid Nested Locks – Don’t acquire multiple locks at the same time.
- Use Lock Ordering – Always acquire locks in a fixed, global order.
- Use Try-Lock (from java.util.concurrent.locks) – Allows timeout while waiting for a lock.
- Limit Synchronized Scope – Only synchronize necessary code blocks.
- Use Higher-Level Concurrency Tools – Like thread-safe queues, semaphores, etc.
Deadlock-Free Version Using Lock Ordering
public void methodSafe() {
synchronized (lock1) {
System.out.println(Thread.currentThread().getName() + " locked Lock1");
synchronized (lock2) {
System.out.println(Thread.currentThread().getName() + " locked Lock2");
}
}
}
Both threads now acquire locks in the same order: Lock1 → Lock2, preventing circular wait.
9. Thread Safety in Java
What is Thread Safety?
A piece of code is said to be thread-safe if it behaves correctly and predictably when accessed by multiple threads concurrently. In other words, a thread-safe class or method ensures no race conditions, data corruption, or unexpected behavior occurs, even when multiple threads access shared data.
Common Causes of Thread Safety Issues
- Shared mutable data
- Lack of synchronization
- Improper use of static variables
- Incorrect usage of local or instance variables in multithreaded code
Ways to Achieve Thread Safety
1. Using Synchronization
We have already discussed how synchronized methods and blocks prevent thread interference by allowing only one thread at a time to access the critical section of code.
2. Using Immutable Objects
Immutable objects are naturally thread-safe. Since their state cannot be changed after creation, there’s no risk of concurrent modification.
Example: Immutable Class
final class Employee {
private final String name;
private final int id;
public Employee(String name, int id) {
this.name = name;
this.id = id;
}
// No setters provided
public String getName() {
return name;
}
public int getId() {
return id;
}
}
Immutable classes like String, Integer, and LocalDate in Java are inherently thread-safe.
3. Using the volatile Keyword
volatile ensures that updates to a variable are always visible to all threads. It prevents threads from caching variables and forces them to read the latest value from main memory.
Example: Volatile Flag
class Shared {
volatile boolean running = true;
public void stopRunning() {
running = false;
}
}
class VolatileExample extends Thread {
Shared shared;
VolatileExample(Shared s) {
this.shared = s;
}
public void run() {
while (shared.running) {
// Do something
}
System.out.println("Thread stopped.");
}
public static void main(String[] args) throws InterruptedException {
Shared s = new Shared();
VolatileExample t = new VolatileExample(s);
t.start();
Thread.sleep(1000);
s.stopRunning();
}
}
Output:
Thread stopped.
Without volatile, the change may not be visible to the running thread due to caching.
4. Using Atomic Variables (java.util.concurrent.atomic)
In multi-threaded programs, when multiple threads update the same variable, race conditions can occur. Normally, we use synchronized blocks or locks to ensure thread safety.
However, synchronization has a performance cost because it involves locking/unlocking mechanisms.
To solve this, Java provides atomic classes (e.g., AtomicInteger, AtomicLong, AtomicBoolean, etc.) in the java.util.concurrent.atomic package.
Atomic variables achieve thread safety without locks by using CAS (Compare-And-Swap), a special CPU-level instruction.
What is CAS (Compare-And-Swap)?
- CAS is a lock-free mechanism used by atomic classes to update a variable safely when multiple threads are modifying it.
- Instead of locking the variable, CAS works with three components:
- V → The memory location (the current value of the variable).
- A → The expected value (the value the thread thinks is currently stored).
- B → The new value to update.
Working:
- CAS checks if V == A.
- If true, it updates the value to B.
- If false, it means another thread has already modified the variable, so CAS retries the operation with the latest value.
This ensures atomic updates without locking the resource.
Code Example: Atomic Variables with CAS
import java.util.concurrent.atomic.AtomicInteger;
class AtomicCounter extends Thread {
static AtomicInteger count = new AtomicInteger(0);
public void run() {
for (int i = 0; i < 1000; i++) {
// Internally uses CAS (Compare-And-Swap)
count.incrementAndGet();
}
}
public static void main(String[] args) throws InterruptedException {
AtomicCounter t1 = new AtomicCounter();
AtomicCounter t2 = new AtomicCounter();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final Count: " + count);
}
}
Output
Final Count: 2000
How CAS Works in the Example
- When count.incrementAndGet() is called:
- CAS reads the current value of count.
- Compares it with the expected value.
- If they match, it updates the value by adding 1.
- If another thread already changed the value, CAS retries with the updated value.
This ensures both threads (t1 and t2) update the count safely without locks.
Key Advantage of CAS:
- Faster than synchronization because there is no locking/unlocking overhead.
- Used in highly concurrent applications (e.g., concurrent hash maps, atomic counters).
Limitation of CAS:
- CAS may cause starvation if many threads keep retrying and failing to update (called the ABA problem).
5. Using Thread-safe Collections
- Legacy Synchronized Collections – Vector, Hashtable
- Modern Concurrent Collections – ConcurrentHashMap, CopyOnWriteArrayList, etc.
When to Choose What?
Technique | Best Use Case |
---|---|
Synchronization | Small critical sections |
Immutable Objects | Shared read-only data |
volatile | Flags or simple state signals |
Atomic Variables | High-performance counters |
Concurrent Collections | Multi-threaded read/write data |
10. Concurrency Utilities in Java (java.util.concurrent)
The java.util.concurrent package provides high-level tools and utilities to manage threads more efficiently and safely. These tools eliminate the need to manually manage thread creation, synchronization, and resource sharing in most scenarios.
10.1 Executor Framework
When managing concurrency, manually creating and starting threads (new Thread(...)) is inefficient:
- Too many threads = memory overhead and context switching.
- Too few threads = tasks waiting, poor CPU utilization.
To solve this, Java introduced the Executor Framework (java.util.concurrent package), which manages thread creation, scheduling, and execution automatically.
The ExecutorService interface represents a pool of worker threads that can run tasks in the background.
Key Methods in ExecutorService
- execute(Runnable task) → Submits a Runnable task for execution.
- submit(Runnable/Callable task) → Submits a task and returns a Future object to track it.
- shutdown() → Initiates an orderly shutdown (no new tasks).
- shutdownNow() → Attempts to stop all running tasks immediately.
- awaitTermination(timeout, unit) → Waits for termination of tasks within a time limit.
Types of Thread Pools (via Executors class)
Java provides factory methods in the Executors utility class to create different types of thread pools:
1. Fixed Thread Pool
- Creates a pool with a fixed number of threads.
- If all threads are busy, new tasks wait in a queue.
- Good for a steady number of long-running tasks.
Example:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class Task implements Runnable {
private int taskId;
Task(int id) {
this.taskId = id;
}
public void run() {
System.out.println(Thread.currentThread().getName() + " is executing task " + taskId);
try {
Thread.sleep(1000); // simulate work
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3); // 3 threads
for (int i = 1; i <= 6; i++) {
executor.execute(new Task(i));
}
executor.shutdown();
}
}
Output (sample):
pool-1-thread-1 is executing task 1
pool-1-thread-2 is executing task 2
pool-1-thread-3 is executing task 3
pool-1-thread-1 is executing task 4
pool-1-thread-2 is executing task 5
pool-1-thread-3 is executing task 6
(At most 3 tasks execute concurrently.)
2. Cached Thread Pool
- Creates unbounded threads as needed, but reuses idle threads.
- Good for many short-lived, lightweight tasks.
- May lead to resource exhaustion if too many tasks flood the pool.
Example:
ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 1; i <= 5; i++) {
executor.execute(new Task(i));
}
executor.shutdown();
Output (sample):
pool-1-thread-1 is executing task 1
pool-1-thread-2 is executing task 2
pool-1-thread-3 is executing task 3
pool-1-thread-4 is executing task 4
pool-1-thread-5 is executing task 5
(New threads created as needed, but reused for future tasks.)
3. Single Thread Executor
- Only one thread executes tasks sequentially.
- Ensures FIFO order execution.
- Good for tasks that must not run in parallel (e.g., writing to a file).
Example:
ExecutorService executor = Executors.newSingleThreadExecutor();
for (int i = 1; i <= 3; i++) {
executor.execute(new Task(i));
}
executor.shutdown();
Output:
pool-1-thread-1 is executing task 1
pool-1-thread-1 is executing task 2
pool-1-thread-1 is executing task 3
(Always same thread, sequential execution.)
4. Scheduled Thread Pool
- Supports delayed and periodic task execution.
- Useful for scheduling jobs (like cron jobs).
Example:
import java.util.concurrent.*;
class ScheduledExample {
public static void main(String[] args) {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// Task with delay
scheduler.schedule(() -> {
System.out.println(Thread.currentThread().getName() + " executed after 2 seconds");
}, 2, TimeUnit.SECONDS);
// Task at fixed rate
scheduler.scheduleAtFixedRate(() -> {
System.out.println(Thread.currentThread().getName() + " running every 3 seconds");
}, 1, 3, TimeUnit.SECONDS);
// Let it run for some time
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
scheduler.shutdown();
}
}
Output (sample):
pool-1-thread-1 executed after 2 seconds
pool-1-thread-2 running every 3 seconds
pool-1-thread-2 running every 3 seconds
pool-1-thread-2 running every 3 seconds
10.2 Callable and Future
1. Problem with Runnable
- The Runnable interface is useful for defining tasks to run in a thread.
- However, it has two limitations:
- It cannot return a result.
- It cannot throw checked exceptions.
Example (Runnable):
class MyRunnable implements Runnable {
public void run() {
System.out.println("Runnable running...");
}
}
👉 Here, you can only run logic, but you don’t get any result back.
2. What is Callable?
- Introduced in Java 5 (java.util.concurrent).
- Similar to Runnable but can return a result and throw exceptions.
- Defined as:
public interface Callable {
V call() throws Exception;
}
- V → return type of the result.
3. What is Future?
- When you submit a Callable task to an ExecutorService, it immediately returns a Future object.
- Future acts as a placeholder for the result that will be computed in the future.
Future Methods:
- get() → waits if necessary and returns the result.
- isDone() → checks if task is finished.
- cancel(boolean mayInterruptIfRunning) → cancels the task.
- isCancelled() → checks if task was cancelled.
4. Example: Callable with Future
import java.util.concurrent.*;
class MyCallable implements Callable {
public Integer call() throws Exception {
System.out.println(Thread.currentThread().getName() + " is calculating...");
Thread.sleep(1000); // Simulate long computation
return 42; // Returning result
}
public static void main(String[] args) throws Exception {
// Create a single thread executor
ExecutorService executor = Executors.newSingleThreadExecutor();
// Submit task and get Future
Future future = executor.submit(new MyCallable());
System.out.println("Task submitted, waiting for result...");
// Blocking call, waits until result is available
Integer result = future.get();
System.out.println("Result: " + result);
executor.shutdown();
}
}
5. Sample Output
Task submitted, waiting for result...
pool-1-thread-1 is calculating...
Result: 42
10.3 ScheduledExecutorService
Used to schedule tasks to run after a delay or periodically.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.schedule(() -> System.out.println("Delayed Task"), 2, TimeUnit.SECONDS);
10.4 Synchronizers
When multiple threads work together, we often need to coordinate their execution — making some threads wait for others, controlling access to resources, or synchronizing phases of work.
The java.util.concurrent package provides several synchronizer utilities to achieve this.
1. CountDownLatch
- A synchronizer that allows one or more threads to wait until a set of operations being performed by other threads completes.
- Uses a counter initialized with a number of tasks.
- Each time a worker thread finishes, it calls countDown().
- The waiting thread calls await() and resumes only when the counter reaches zero.
Key Methods:
- countDown() → Decrements the count.
- await() → Blocks until count reaches zero.
Example:
import java.util.concurrent.CountDownLatch;
class Worker extends Thread {
private CountDownLatch latch;
Worker(CountDownLatch latch) {
this.latch = latch;
}
public void run() {
System.out.println(Thread.currentThread().getName() + " finished work");
latch.countDown(); // reduce count
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3);
new Worker(latch).start();
new Worker(latch).start();
new Worker(latch).start();
latch.await(); // main thread waits
System.out.println("All workers finished. Proceeding...");
}
}
Output (sample):
Thread-0 finished work
Thread-1 finished work
Thread-2 finished work
All workers finished. Proceeding...
Use Case: Waiting for multiple services to start before continuing (e.g., database + cache + API ready before server starts).
2. Semaphore
- A counting synchronizer that controls access to a resource by limiting the number of threads that can access it at once.
- Works like a permit system:
- acquire() → request a permit (blocks if none available).
- release() → return a permit.
Example: Parking Lot
import java.util.concurrent.Semaphore;
class ParkingLot extends Thread {
private Semaphore semaphore;
ParkingLot(Semaphore s) {
this.semaphore = s;
}
public void run() {
try {
semaphore.acquire(); // get a permit
System.out.println(Thread.currentThread().getName() + " got parking");
Thread.sleep(1000); // simulate parking
System.out.println(Thread.currentThread().getName() + " leaving");
semaphore.release(); // free permit
} catch (InterruptedException e) {}
}
public static void main(String[] args) {
Semaphore s = new Semaphore(2); // Only 2 cars allowed at once
for (int i = 1; i <= 5; i++) {
new ParkingLot(s).start();
}
}
}
Output (sample):
Thread-0 got parking
Thread-1 got parking
Thread-0 leaving
Thread-1 leaving
Thread-2 got parking
Thread-3 got parking
Thread-2 leaving
Thread-3 leaving
Thread-4 got parking
Thread-4 leaving
Use Case: Controlling access to limited resources like database connections or API rate-limiting.
3. ReentrantLock
- A lock mechanism that works like synchronized but with more flexibility.
- Features:
- Can try acquiring lock with tryLock().
- Can interrupt while waiting (lockInterruptibly()).
- Supports fair locks (first-come, first-served).
- A thread can acquire the lock multiple times (reentrant behavior).
Example:
import java.util.concurrent.locks.ReentrantLock;
class LockExample {
private ReentrantLock lock = new ReentrantLock();
public void print() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " acquired lock");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
LockExample obj = new LockExample();
Runnable task = () -> obj.print();
new Thread(task).start();
new Thread(task).start();
}
}
Output:
Thread-0 acquired lock
Thread-1 acquired lock
Use Case: Fine-grained control over locking beyond synchronized.
4. CyclicBarrier
- A barrier that makes threads wait until a fixed number of threads have reached the barrier point.
- Once all threads reach, the barrier is reset and they all proceed.
Example:
import java.util.concurrent.*;
class BarrierExample {
public static void main(String[] args) {
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("All threads reached barrier, proceeding...");
});
Runnable task = () -> {
try {
System.out.println(Thread.currentThread().getName() + " waiting");
barrier.await();
System.out.println(Thread.currentThread().getName() + " proceeding");
} catch (Exception e) {}
};
new Thread(task).start();
new Thread(task).start();
new Thread(task).start();
}
}
Output:
Thread-0 waiting
Thread-1 waiting
Thread-2 waiting
All threads reached barrier, proceeding...
Thread-2 proceeding
Thread-0 proceeding
Thread-1 proceeding
Use Case: Multi-phase tasks like simulations or parallel computations.
5. Phaser
Definition:
- A synchronization aid (like CyclicBarrier) but more flexible.
- Supports multiple phases of synchronization.
- Allows dynamic registration and deregistration of parties (threads).
Key Points:
- Threads call arriveAndAwaitAdvance() to signal phase completion and wait for others.
- register() → add new parties.
- arriveAndDeregister() → remove a party.
- onAdvance(int phase, int parties) can be overridden for custom behavior when a phase completes.
Use Case:
- Workflows broken into steps (batch jobs, multi-stage processing, simulations).
Example:
import java.util.concurrent.Phaser;
class Worker implements Runnable {
private final Phaser phaser;
private final String name;
Worker(Phaser phaser, String name) {
this.phaser = phaser;
this.name = name;
phaser.register(); // register thread dynamically
}
@Override
public void run() {
try {
// Phase 0 work
System.out.println(name + " doing phase 0 work");
Thread.sleep(300);
phaser.arriveAndAwaitAdvance();
// Phase 1 work
System.out.println(name + " doing phase 1 work");
Thread.sleep(300);
phaser.arriveAndAwaitAdvance();
// Phase 2 work
System.out.println(name + " doing phase 2 work");
Thread.sleep(300);
phaser.arriveAndDeregister(); // completed all phases
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public class PhaserDemo {
public static void main(String[] args) {
Phaser phaser = new Phaser(1); // main thread registers itself
new Thread(new Worker(phaser, "T1")).start();
new Thread(new Worker(phaser, "T2")).start();
new Thread(new Worker(phaser, "T3")).start();
// Main thread participates in phases
for (int phase = 0; phase < 3; phase++) {
phaser.arriveAndAwaitAdvance();
System.out.println("---- Phase " + phase + " completed ----");
}
phaser.arriveAndDeregister(); // main thread done
}
}
Sample Output:
T1 doing phase 0 work
T2 doing phase 0 work
T3 doing phase 0 work
---- Phase 0 completed ----
T1 doing phase 1 work
T2 doing phase 1 work
T3 doing phase 1 work
---- Phase 1 completed ----
T1 doing phase 2 work
T2 doing phase 2 work
T3 doing phase 2 work
---- Phase 2 completed ----
6. Exchanger
Definition:
- A synchronization point where two threads can exchange data.
- Each thread presents an object, waits, and then receives the object from its partner.
Key Methods:
V exchange(V x) throws InterruptedException
V exchange(V x, long timeout, TimeUnit unit)
throws InterruptedException, TimeoutException
Key Points:
- Blocks until a partner arrives.
- Ensures happens-before relationship between exchanging threads.
- Throws InterruptedException if interrupted, or TimeoutException if timeout expires.
Use Case:
- Producer–consumer handoff (double-buffering).
- Data swaps between paired tasks.
- Synchronizing paired computations.
Example:
import java.util.concurrent.Exchanger;
public class ExchangerExample {
public static void main(String[] args) {
Exchanger exchanger = new Exchanger<>();
new Thread(() -> {
try {
String data = "Data from Thread-1";
System.out.println("Thread-1 exchanging: " + data);
String response = exchanger.exchange(data);
System.out.println("Thread-1 received: " + response);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
String data = "Data from Thread-2";
System.out.println("Thread-2 exchanging: " + data);
String response = exchanger.exchange(data);
System.out.println("Thread-2 received: " + response);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
Output:
Thread-1 exchanging: Data from Thread-1
Thread-2 exchanging: Data from Thread-2
Thread-2 received: Data from Thread-1
Thread-1 received: Data from Thread-2
11. Thread Groups in Java
What is a Thread Group?
A Thread Group in Java is a way to group multiple threads into a single unit. It allows you to manage multiple threads together, such as interrupting all threads in a group or checking how many threads are active.
Thread groups were introduced in earlier versions of Java to simplify bulk thread management, but their usage is now considered outdated and replaced by better tools in java.util.concurrent.
Structure of Thread Groups
Thread groups can also contain sub-groups, forming a tree-like hierarchy of threads.
ThreadGroup parentGroup = new ThreadGroup("Parent Group");
ThreadGroup childGroup = new ThreadGroup(parentGroup, "Child Group");
Common Use Cases (Historical)
- Organizing threads logically
- Bulk interrupt or suspend (discouraged now)
- Monitoring thread activity
Creating Threads in a Thread Group
class MyRunnable implements Runnable {
public void run() {
System.out.println(Thread.currentThread().getName() + " running in group: " +
Thread.currentThread().getThreadGroup().getName());
}
public static void main(String[] args) {
ThreadGroup group = new ThreadGroup("MyGroup");
Thread t1 = new Thread(group, new MyRunnable(), "Thread-1");
Thread t2 = new Thread(group, new MyRunnable(), "Thread-2");
t1.start();
t2.start();
}
}
Output:
Thread-1 running in group: MyGroup
Thread-2 running in group: MyGroup
Useful Methods in ThreadGroup
Method | Description |
---|---|
getName() | Returns the group name |
activeCount() | Returns number of active threads in the group |
list() | Prints information about the group and its threads |
interrupt() | Interrupts all active threads in the group |
Example: Interrupting All Threads in a Group
class GroupTask extends Thread {
GroupTask(ThreadGroup group, String name) {
super(group, name);
}
public void run() {
while (!Thread.currentThread().isInterrupted()) {
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Preserve interrupt status
}
}
System.out.println(Thread.currentThread().getName() + " stopped");
}
public static void main(String[] args) throws InterruptedException {
ThreadGroup group = new ThreadGroup("WorkerGroup");
GroupTask t1 = new GroupTask(group, "Worker-1");
GroupTask t2 = new GroupTask(group, "Worker-2");
t1.start();
t2.start();
Thread.sleep(2000);
group.interrupt(); // Interrupt all threads in the group
}
}
Why Thread Groups Are Rarely Used Today
- No real isolation: You can still access threads individually.
- Limited functionality: Lacks flexibility and control.
- Better alternatives: Executors, ThreadPools, and other concurrency utilities offer better design and control patterns.
✅ Recommended only for basic thread classification, not control.
12. Java Memory Model (JMM) and Happens-Before Relationship
What is the Java Memory Model (JMM)?
The Java Memory Model (JMM) defines how threads interact through memory and what behaviors are allowed in a multithreaded program. It provides the rules for visibility, ordering, and atomicity of variable reads/writes across multiple threads.
Before JMM was introduced, thread behavior was often unpredictable because of:
- Instruction reordering by compilers or CPUs
- Caching variables locally by threads
Core Concepts in JMM
1. Visibility
Changes made by one thread may not be visible to other threads unless certain conditions (like synchronization or volatile) are met.
Example (Non-Visible Write):
class Example {
boolean flag = false;
public void writer() {
flag = true; // may be cached and not visible to other threads immediately
}
public void reader() {
while (!flag) {
// may loop forever if flag not visible
}
System.out.println("Flag seen as true");
}
}
Using volatile ensures the value of flag is always read from main memory, not from thread-local cache.
2. Reordering
The JVM and CPU can reorder instructions to improve performance, as long as the result is consistent in a single-threaded context. However, this can break multithreaded logic if threads assume a specific order of execution.
3. Happens-Before Relationship
This is the heart of JMM. It defines when the effects of one action (write) are guaranteed to be visible to another action (read).
Rules of Happens-Before:
Rule | Description |
---|---|
Program Order Rule | In a single thread, instructions follow source-code order. |
Monitor Lock Rule | An unlock on a monitor happens-before every subsequent lock on that same monitor. |
Volatile Variable Rule | A write to a volatile variable happens-before every subsequent read of that same variable. |
Thread Start Rule | A call to Thread.start() happens-before the actions of the started thread. |
Thread Join Rule | The calling thread’s actions after join() happen-after the thread has completed. |
Example: Volatile Ensuring Visibility
class SharedData {
volatile boolean flag = false;
public void writer() {
flag = true;
}
public void reader() {
while (!flag) {
// Won’t loop forever now, as volatile ensures visibility
}
System.out.println("Reader sees flag as true");
}
}
Example: Happens-Before with start() and join()
class MyTask extends Thread {
private int value = 0;
public void run() {
value = 42;
}
public static void main(String[] args) throws InterruptedException {
MyTask t = new MyTask();
t.start(); // start happens-before join
t.join(); // join ensures main thread sees updated value
System.out.println("Value: " + t.value); // Guaranteed to print 42
}
}
How JMM Helps Developers
JMM provides a consistent set of rules to reason about:
- Which variables are shared safely
- Which actions are atomic or visible
- How synchronization enforces order and visibility
13. Best Practices for Multithreading in Java
Writing multithreaded code is not just about using threads—it's about writing safe, efficient, and maintainable concurrent programs. Below are industry-recommended best practices to follow when working with threads in Java.
1. Prefer Higher-Level Concurrency APIs
Avoid using low-level thread management (Thread, synchronized, wait/notify) unless necessary. Use:
- ExecutorService for thread pooling and task submission
- Callable and Future for return values
- ConcurrentHashMap, CopyOnWriteArrayList, etc., for thread-safe data structures
- Synchronizers like CountDownLatch, Semaphore, and ReentrantLock
2. Minimize the Scope of Synchronization
Only synchronize the critical section (the minimal part of code that modifies shared state). This improves performance and reduces the risk of deadlocks.
synchronized (this) {
// Only update shared data here
}
3. Avoid Deadlocks
Prevent deadlocks by:
- Locking resources in a consistent global order
- Avoiding nested synchronized blocks
- Using tryLock() with timeout from ReentrantLock
4. Use Immutable Objects Wherever Possible
Immutable objects are naturally thread-safe and eliminate the need for synchronization.
final class Coordinate {
private final int x, y;
public Coordinate(int x, int y) {
this.x = x;
this.y = y;
}
}
5. Don’t Rely on Thread Priorities
Thread scheduling is OS-dependent and priorities are not reliable. Design threads assuming equal scheduling.
6. Avoid Busy Waiting
Do not use loops that repeatedly check a condition. Use proper coordination mechanisms like:
- wait() / notify()
- CountDownLatch
- BlockingQueue
7. Use volatile Only When Appropriate
Use volatile for flags and simple state visibility, not for compound operations (like count++), which are not atomic.
8. Handle Exceptions in Threads
Threads silently die when exceptions occur unless you catch them.
Thread t = new Thread(() -> {
try {
// your code
} catch (Exception e) {
// log and handle
}
});
Also consider using:
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
System.out.println("Exception in thread " + t.getName() + ": " + e.getMessage());
});
9. Shutdown ExecutorServices Properly
Always call shutdown() or shutdownNow() on ExecutorService to prevent thread leakage.
executor.shutdown();
10. Keep Thread-Safety Documentation Clear
Always document which methods are thread-safe and which are not. This helps team members avoid unintended misuse.
11. Profile and Test Concurrent Code
- Use tools like JVisualVM, JFR, or YourKit for thread profiling.
- Write unit tests for concurrency using libraries like Awaitility.
- Use Thread.sleep() carefully in tests, as it can make tests flaky.
12. Avoid Shared Mutable State When Possible
If each thread has its own data, there's no need for synchronization at all.
Common Pitfalls to Avoid
Mistake | Problem |
---|---|
Starting a thread twice | IllegalThreadStateException |
Using sleep() instead of wait() | Doesn’t release lock |
Accessing shared data without sync | Race conditions |
Catching and ignoring InterruptedException | May block shutdown |
Summary
In this blog, we covered the advanced aspects of Java multithreading. We began with inter-thread communication using wait() and notify(), followed by understanding deadlocks and strategies to avoid them. We explored how to achieve thread safety using synchronization, immutability, volatile, and atomic variables. We also delved into the powerful concurrency utilities in the java.util.concurrent package, including ExecutorService, Callable, Future, CountDownLatch, and ReentrantLock. We looked at ThreadGroups, the Java Memory Model, and concluded with a list of multithreading best practices and interview questions.
This completes our two-part series on Java Multithreading. We hope this comprehensive guide has helped you understand both the fundamentals and advanced concepts of concurrent programming in Java.