# Java Concurrency: Threads, Synchronization, and Executors

Table of Contents

Java concurrency enables multiple threads to execute simultaneously, improving application performance and responsiveness. This comprehensive guide covers the essential concepts and best practices.

1. Process vs Thread in Java

The fundamental difference lies in resource sharing and isolation levels.

Process

Definition: An independent execution environment with its own memory space and system resources. A process is an instance of a program in execution.

Characteristics:

  • Separate memory space: Each process has its own heap, stack, and code segments
  • Heavy-weight: More system overhead to create and manage
  • Isolated: Processes cannot directly access each other’s memory
  • Inter-process communication: Requires special mechanisms (pipes, sockets, shared memory)
  • Crash isolation: One process crash doesn’t affect others

Thread

Definition: A lightweight unit of execution within a process that shares the process’s resources.

Characteristics:

  • Shared memory space: All threads in a process share heap memory
  • Light-weight: Less overhead to create and switch between
  • Shared resources: Share memory, file handles, and other process resources
  • Direct communication: Can communicate through shared variables
  • Crash propagation: One thread crash can potentially crash the entire process
PROCESS A PROCESS B
┌─────────────────────┐ ┌─────────────────────┐
│ Heap Memory │ │ Heap Memory │
│ ┌─────┐ ┌─────┐ │ │ ┌─────┐ ┌─────┐ │
│ │Thread│Thread│ │ │ │Thread│Thread│ │
│ │ 1 │ 2 │ │ │ │ 1 │ 2 │ │
│ └─────┘ └─────┘ │ │ └─────┘ └─────┘ │
│ Code Segment │ │ Code Segment │
│ File Handles │ │ File Handles │
└─────────────────────┘ └─────────────────────┘

When to Use What?

Use Processes When:

  • Complete isolation is required
  • Fault tolerance is critical (one failure shouldn’t affect others)
  • Security is important (processes can’t access each other’s memory)
  • Running separate applications
  • Language interoperability (calling programs in different languages)

Use Threads When:

  • Performance is critical (lower overhead)
  • Shared data needs to be accessed frequently
  • Responsive UI applications (background processing)
  • Producer-consumer scenarios
  • Parallel processing of related tasks

In Java, a process is an executing instance of the JVM. Each time you run a Java application, a separate process is created by the OS. Java provides the Process and ProcessBuilder classes to create and manage operating system processes.

2. Creating Threads in Java

There are several ways to create threads in Java:

Method 1: Extending the Thread Class

class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread is running: " + Thread.currentThread().getName());
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " - Count: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Thread interrupted");
}
}
}
}
// Usage
public class ThreadExample {
public static void main(String[] args) {
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
thread1.start(); // Don't call run() directly!
thread2.start();
}
}
class MyTask implements Runnable {
private String taskName;
public MyTask(String name) {
this.taskName = name;
}
@Override
public void run() {
System.out.println(taskName + " is running on: " +
Thread.currentThread().getName());
for (int i = 0; i < 5; i++) {
System.out.println(taskName + " - Count: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Task interrupted");
}
}
}
}
// Usage
public class RunnableExample {
public static void main(String[] args) {
Thread thread1 = new Thread(new MyTask("Task-1"));
Thread thread2 = new Thread(new MyTask("Task-2"));
thread1.start();
thread2.start();
}
}

Method 3: Using Lambda Expressions (Java 8+)

public class LambdaThreadExample {
public static void main(String[] args) {
// Simple lambda
Thread thread = new Thread(() -> {
System.out.println("Lambda thread: " + Thread.currentThread().getName());
});
thread.start();
// Lambda with multiple statements
Thread worker = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("Working... " + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
worker.start();
}
}

Method 4: Using Callable and Future

import java.util.concurrent.*;
class MyCallable implements Callable<Integer> {
private int number;
public MyCallable(int number) {
this.number = number;
}
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= number; i++) {
sum += i;
Thread.sleep(100);
}
return sum;
}
}
// Usage
public class CallableExample {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(new MyCallable(10));
System.out.println("Calculation started...");
// Do other work while calculation happens
System.out.println("Doing other work...");
// Get the result (blocks if not ready)
Integer result = future.get();
System.out.println("Sum: " + result);
executor.shutdown();
}
}

Key Points to Remember

  • Always call start(), not run() directly
  • Implementing Runnable is preferred over extending Thread (allows extending other classes)
  • Use thread pools (ExecutorService) for better resource management
  • Handle InterruptedException properly
  • Remember to shut down ExecutorService instances to avoid resource leaks

3. Synchronization in Java

Synchronization ensures thread safety by controlling access to shared resources, preventing race conditions and data inconsistency.

Why Synchronization is Needed

class Counter {
private int count = 0;
// Without synchronization - NOT thread-safe
public void increment() {
count++; // This is actually three operations:
// 1. Read count
// 2. Increment value
// 3. Write count back
}
public int getCount() {
return count;
}
}
// Problem demonstration
public class RaceConditionExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Expected: 2000, Actual: Unpredictable (likely less than 2000)
System.out.println("Count: " + counter.getCount());
}
}

Synchronized Methods

class SynchronizedCounter {
private int count = 0;
// Synchronized method - thread-safe
public synchronized void increment() {
count++;
}
public synchronized void decrement() {
count--;
}
public synchronized int getCount() {
return count;
}
}

Synchronized Blocks

class BankAccount {
private double balance = 0;
private final Object lock = new Object();
public void deposit(double amount) {
synchronized (lock) {
balance += amount;
System.out.println("Deposited: " + amount + ", Balance: " + balance);
}
}
public void withdraw(double amount) {
synchronized (lock) {
if (balance >= amount) {
balance -= amount;
System.out.println("Withdrawn: " + amount + ", Balance: " + balance);
} else {
System.out.println("Insufficient funds");
}
}
}
public double getBalance() {
synchronized (lock) {
return balance;
}
}
}

Static Synchronization

class DatabaseConnection {
private static int connectionCount = 0;
// Synchronized on class object
public static synchronized void incrementConnections() {
connectionCount++;
}
// Equivalent to above
public static void incrementConnectionsAlt() {
synchronized (DatabaseConnection.class) {
connectionCount++;
}
}
}

4. Deadlock in Multithreading

Deadlock is a situation where two or more threads are blocked forever, waiting for each other to release resources.

Deadlock Example

public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Acquired lock1");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock2...");
synchronized (lock2) {
System.out.println("Thread 1: Acquired lock2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Acquired lock2");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock1...");
synchronized (lock1) {
System.out.println("Thread 2: Acquired lock1");
}
}
});
thread1.start();
thread2.start();
// This will result in deadlock!
}
}

Coffman Conditions for Deadlock

All four conditions must be present simultaneously:

  1. Mutual Exclusion: Resources cannot be shared
  2. Hold and Wait: A thread holds at least one resource while waiting for others
  3. No Preemption: Resources cannot be forcibly taken from threads
  4. Circular Wait: A circular chain of threads waiting for each other’s resources

5. Avoiding Deadlocks

Strategy 1: Lock Ordering

public class LockOrderingSolution {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public void method1() {
synchronized (lock1) { // Always acquire lock1 first
synchronized (lock2) {
// Critical section
}
}
}
public void method2() {
synchronized (lock1) { // Same order: lock1 then lock2
synchronized (lock2) {
// Critical section
}
}
}
}

Strategy 2: Timeout-Based Locking

import java.util.concurrent.locks.*;
import java.util.concurrent.TimeUnit;
public class TimeoutLockingSolution {
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
public void transfer() throws InterruptedException {
while (true) {
boolean gotLock1 = lock1.tryLock(100, TimeUnit.MILLISECONDS);
if (!gotLock1) {
continue;
}
try {
boolean gotLock2 = lock2.tryLock(100, TimeUnit.MILLISECONDS);
if (!gotLock2) {
continue;
}
try {
// Perform transfer
System.out.println("Transfer successful");
break;
} finally {
lock2.unlock();
}
} finally {
lock1.unlock();
}
}
}
}

Strategy 3: Single Lock Strategy

public class SingleLockSolution {
private final Object lock = new Object();
private int resource1 = 0;
private int resource2 = 0;
public void operation1() {
synchronized (lock) {
resource1++;
resource2++;
}
}
public void operation2() {
synchronized (lock) {
resource2--;
resource1--;
}
}
}

Strategy 4: Lock-Free Programming

import java.util.concurrent.atomic.*;
public class LockFreeSolution {
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // Atomic operation
}
public int getCount() {
return counter.get();
}
}

6. The volatile Keyword

The volatile keyword ensures visibility and ordering of variables across multiple threads.

Visibility Problem

class VisibilityExample {
private boolean flag = false; // Without volatile
public void writer() {
flag = true; // Thread 1 sets flag
}
public void reader() {
while (!flag) { // Thread 2 might never see the change!
// This could be an infinite loop
}
System.out.println("Flag is now true!");
}
}

Solution with volatile

class VolatileVisibilityExample {
private volatile boolean flag = false; // With volatile
public void writer() {
flag = true; // Change immediately visible to all threads
}
public void reader() {
while (!flag) {
// Thread will see the change when it happens
}
System.out.println("Flag is now true!");
}
}

When to Use volatile

public class VolatileUseCase {
// Good use case: Simple flag
private volatile boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
// Do work
}
}
// Bad use case: Counter (use AtomicInteger instead)
private volatile int counter = 0; // NOT thread-safe for increment
public void increment() {
counter++; // Still not atomic!
}
}

volatile vs synchronized

public class VolatileVsSynchronized {
// volatile: Only guarantees visibility
private volatile int value = 0;
public void incrementVolatile() {
value++; // NOT thread-safe (read-modify-write)
}
// synchronized: Guarantees both visibility and atomicity
public synchronized void incrementSync() {
value++; // Thread-safe
}
}

7. Wait and Notify Mechanism

The wait() and notify() mechanism provides inter-thread communication.

Basic Wait-Notify Pattern

class WaitNotifyExample {
private boolean condition = false;
private final Object lock = new Object();
// Waiting thread
public void waitingMethod() throws InterruptedException {
synchronized (lock) {
while (!condition) { // Always use while loop, not if
System.out.println("Waiting for condition...");
lock.wait(); // Releases the lock and waits
System.out.println("Woke up, checking condition again...");
}
System.out.println("Condition is true, proceeding...");
}
}
// Notifying thread
public void notifyingMethod() {
synchronized (lock) {
condition = true;
System.out.println("Condition set to true, notifying...");
lock.notify(); // or lock.notifyAll();
}
}
}

Producer-Consumer Pattern

import java.util.*;
class ProducerConsumer {
private Queue<Integer> queue = new LinkedList<>();
private final int CAPACITY = 5;
private final Object lock = new Object();
public void produce() throws InterruptedException {
int value = 0;
while (true) {
synchronized (lock) {
while (queue.size() == CAPACITY) {
System.out.println("Queue full, producer waiting...");
lock.wait();
}
queue.add(value);
System.out.println("Produced: " + value);
value++;
lock.notifyAll(); // Wake up consumers
Thread.sleep(1000);
}
}
}
public void consume() throws InterruptedException {
while (true) {
synchronized (lock) {
while (queue.isEmpty()) {
System.out.println("Queue empty, consumer waiting...");
lock.wait();
}
int value = queue.poll();
System.out.println("Consumed: " + value);
lock.notifyAll(); // Wake up producers
Thread.sleep(1000);
}
}
}
}

Wait-Notify Best Practices

public class WaitNotifyBestPractices {
private final Object lock = new Object();
private boolean condition = false;
public void correctWaitPattern() throws InterruptedException {
synchronized (lock) {
// ALWAYS use while loop, not if
while (!condition) {
lock.wait();
}
// Process when condition is true
}
}
public void incorrectWaitPattern() throws InterruptedException {
synchronized (lock) {
// WRONG: Using if instead of while
if (!condition) {
lock.wait();
}
// Might process even if condition is false (spurious wakeup)
}
}
public void notifyPattern() {
synchronized (lock) {
condition = true;
// Use notify() if only one thread needs to wake up
lock.notify();
// Use notifyAll() if multiple threads might be waiting
// lock.notifyAll();
}
}
}

8. Executors in Java Concurrency

Executors provide a high-level abstraction for managing and controlling thread execution.

Core Executor Interfaces

import java.util.concurrent.*;
// Basic executor interface
public interface Executor {
void execute(Runnable command);
}
// Extended interface with lifecycle management
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
}
// Scheduling capabilities
public interface ScheduledExecutorService extends ExecutorService {
ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);
ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
long initialDelay,
long period,
TimeUnit unit);
}

Creating Executors

import java.util.concurrent.*;
public class ExecutorExamples {
public void demonstrateExecutors() {
// 1. Single thread executor
ExecutorService singleExecutor = Executors.newSingleThreadExecutor();
// 2. Fixed thread pool
ExecutorService fixedPool = Executors.newFixedThreadPool(5);
// 3. Cached thread pool (creates threads as needed)
ExecutorService cachedPool = Executors.newCachedThreadPool();
// 4. Scheduled executor
ScheduledExecutorService scheduledPool =
Executors.newScheduledThreadPool(3);
// 5. Work stealing pool (Java 8+)
ExecutorService workStealingPool =
Executors.newWorkStealingPool();
}
}

Using ExecutorService

import java.util.concurrent.*;
import java.util.*;
public class ExecutorServiceExample {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(3);
// Submit Runnable tasks
executor.submit(() -> {
System.out.println("Task 1 executing on: " +
Thread.currentThread().getName());
});
// Submit Callable task
Future<Integer> future = executor.submit(() -> {
Thread.sleep(1000);
return 42;
});
System.out.println("Result: " + future.get());
// Submit multiple tasks
List<Callable<String>> tasks = Arrays.asList(
() -> "Task 1 result",
() -> "Task 2 result",
() -> "Task 3 result"
);
List<Future<String>> results = executor.invokeAll(tasks);
for (Future<String> result : results) {
System.out.println(result.get());
}
// Shutdown executor
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
}
}

Scheduled Executor

import java.util.concurrent.*;
public class ScheduledExecutorExample {
public static void main(String[] args) {
ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(2);
// Schedule one-time task with delay
scheduler.schedule(() -> {
System.out.println("Task executed after 3 seconds");
}, 3, TimeUnit.SECONDS);
// Schedule recurring task at fixed rate
scheduler.scheduleAtFixedRate(() -> {
System.out.println("Recurring task: " + System.currentTimeMillis());
}, 0, 2, TimeUnit.SECONDS);
// Schedule with fixed delay between executions
scheduler.scheduleWithFixedDelay(() -> {
System.out.println("Task with fixed delay");
}, 1, 3, TimeUnit.SECONDS);
// Shutdown after 10 seconds
scheduler.schedule(() -> {
scheduler.shutdown();
}, 10, TimeUnit.SECONDS);
}
}

Thread Pool Configuration

import java.util.concurrent.*;
public class CustomThreadPoolExample {
public static void main(String[] args) {
// Custom thread pool with all parameters
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // core pool size
10, // maximum pool size
60, // keep alive time
TimeUnit.SECONDS, // time unit
new LinkedBlockingQueue<>(100), // work queue
new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);
// Configure additional settings
executor.allowCoreThreadTimeOut(true);
executor.prestartAllCoreThreads();
// Use the executor
for (int i = 0; i < 20; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " on " +
Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executor.shutdown();
}
}

Best Practices

  1. Always shutdown executors to avoid resource leaks
  2. Use appropriate pool size based on CPU-bound vs I/O-bound tasks
  3. Handle rejected tasks with proper rejection policies
  4. Monitor thread pools for performance tuning
  5. Use invokeAll() or invokeAny() for batch operations
  6. Prefer ExecutorService over manual thread creation
  7. Use try-with-resources or ensure proper cleanup
  8. Set meaningful thread names for easier debugging
// Proper executor cleanup
ExecutorService executor = Executors.newFixedThreadPool(5);
try {
// Submit tasks
executor.submit(() -> System.out.println("Task"));
} finally {
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts

Comments