A Step-by-Step Guide to Java Multithreading

In the world of Java programming, multithreading is a powerful concept that allows a program to perform multiple tasks concurrently. It enhances the efficiency and responsiveness of applications, especially in scenarios where there are I/O operations or complex computations. This blog will provide a detailed step-by-step guide to Java multithreading, covering fundamental concepts, usage methods, common practices, and best practices.

Table of Contents

  1. Fundamental Concepts of Java Multithreading
    • Process vs. Thread
    • Thread States
    • Synchronization and Race Conditions
  2. Usage Methods in Java Multithreading
    • Creating Threads
    • Starting and Stopping Threads
    • Thread Priorities
  3. Common Practices in Java Multithreading
    • Producer - Consumer Pattern
    • Thread Pools
  4. Best Practices in Java Multithreading
    • Avoiding Deadlocks
    • Using Thread - Safe Classes
  5. Conclusion
  6. References

Fundamental Concepts of Java Multithreading

Process vs. Thread

  • Process: A process is an instance of a program in execution. Each process has its own memory space, system resources, and a single thread of execution (by default). For example, when you open a web browser, a new process is created.
  • Thread: A thread is a lightweight sub - process within a process. Multiple threads can exist within a single process, sharing the same memory space and system resources. Threads are more efficient to create and manage compared to processes.

Thread States

Java threads can be in one of the following states:

  • New: A thread is in the New state when it is created but not yet started.
Thread newThread = new Thread(() -> System.out.println("New thread running"));
// At this point, newThread is in the New state
  • Runnable: When the start() method is called on a thread, it enters the Runnable state. It means the thread is ready to run and is waiting for the CPU to allocate time.
newThread.start();
// Now newThread is in the Runnable state
  • Blocked: A thread enters the Blocked state when it is waiting to acquire a lock. For example, if a thread tries to enter a synchronized block that is already occupied by another thread.
  • Waiting: A thread can enter the Waiting state when it calls methods like wait(), join(), or park(). It will remain in this state until another thread notifies it.
  • Timed Waiting: Similar to the Waiting state, but the thread will wake up after a specified time. Methods like sleep(long millis) and wait(long millis) can put a thread in the Timed Waiting state.
try {
    Thread.sleep(1000); // Thread is in Timed Waiting state for 1 second
} catch (InterruptedException e) {
    e.printStackTrace();
}
  • Terminated: A thread enters the Terminated state when its run() method completes or an unhandled exception occurs.

Synchronization and Race Conditions

  • Synchronization: In multithreaded programs, multiple threads may access and modify shared resources simultaneously. Synchronization is used to ensure that only one thread can access a shared resource at a time. Java provides the synchronized keyword for this purpose.
class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
  • Race Conditions: A race condition occurs when the behavior of a program depends on the relative timing of events in different threads. For example, if two threads try to increment a shared variable simultaneously without proper synchronization, the final value of the variable may be incorrect.

Usage Methods in Java Multithreading

Creating Threads

There are two main ways to create threads in Java:

  • Extending the Thread class:
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("MyThread is running");
    }
}

public class ThreadCreationExample {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
    }
}
  • Implementing the Runnable interface:
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("MyRunnable is running");
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        Thread thread = new Thread(runnable);
        thread.start();
    }
}

Starting and Stopping Threads

  • Starting a thread: As shown above, the start() method is used to start a thread. It creates a new call stack for the thread and calls the run() method.
  • Stopping a thread: In Java, there is no direct way to stop a thread. The stop() method is deprecated because it can leave the program in an inconsistent state. A better way is to use a flag to signal the thread to stop.
class StoppableThread extends Thread {
    private volatile boolean stopped = false;

    @Override
    public void run() {
        while (!stopped) {
            // Do some work
            System.out.println("Thread is running");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void stopThread() {
        stopped = true;
    }
}

public class StopThreadExample {
    public static void main(String[] args) {
        StoppableThread thread = new StoppableThread();
        thread.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread.stopThread();
    }
}

Thread Priorities

Threads in Java have priorities that range from 1 (lowest) to 10 (highest). The default priority is 5. You can set the priority of a thread using the setPriority() method.

Thread thread = new Thread(() -> System.out.println("Thread with priority"));
thread.setPriority(Thread.MAX_PRIORITY);
thread.start();

Common Practices in Java Multithreading

Producer - Consumer Pattern

The Producer - Consumer pattern is a classic multithreading pattern where one or more producer threads generate data and put it into a shared buffer, and one or more consumer threads take data from the buffer and process it.

import java.util.LinkedList;
import java.util.Queue;

class ProducerConsumerExample {
    private static final Queue<Integer> buffer = new LinkedList<>();
    private static final int MAX_SIZE = 5;

    static class Producer implements Runnable {
        @Override
        public void run() {
            try {
                while (true) {
                    synchronized (buffer) {
                        while (buffer.size() == MAX_SIZE) {
                            buffer.wait();
                        }
                        int item = (int) (Math.random() * 100);
                        buffer.add(item);
                        System.out.println("Produced: " + item);
                        buffer.notifyAll();
                    }
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    static class Consumer implements Runnable {
        @Override
        public void run() {
            try {
                while (true) {
                    synchronized (buffer) {
                        while (buffer.isEmpty()) {
                            buffer.wait();
                        }
                        int item = buffer.poll();
                        System.out.println("Consumed: " + item);
                        buffer.notifyAll();
                    }
                    Thread.sleep(200);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        Thread producerThread = new Thread(new Producer());
        Thread consumerThread = new Thread(new Consumer());

        producerThread.start();
        consumerThread.start();
    }
}

Thread Pools

Thread pools are used to manage a group of pre - created threads. Instead of creating a new thread for each task, tasks are submitted to the thread pool, and the pool assigns them to available threads. Java provides the ExecutorService interface and its implementations like ThreadPoolExecutor and Executors for creating thread pools.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);

        for (int i = 0; i < 5; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " is being executed by " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        executor.shutdown();
    }
}

Best Practices in Java Multithreading

Avoiding Deadlocks

A deadlock occurs when two or more threads are blocked forever, each waiting for the other to release a lock. To avoid deadlocks:

  • Lock Ordering: Always acquire locks in the same order. For example, if two threads need to acquire locks A and B, both should acquire them in the order A then B.
  • Use tryLock(): Instead of using lock() which can block indefinitely, use tryLock() which returns immediately whether the lock is acquired or not.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class DeadlockAvoidance {
    private static final Lock lock1 = new ReentrantLock();
    private static final Lock lock2 = new ReentrantLock();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            if (lock1.tryLock()) {
                try {
                    System.out.println("Thread 1 acquired lock1");
                    if (lock2.tryLock()) {
                        try {
                            System.out.println("Thread 1 acquired lock2");
                        } finally {
                            lock2.unlock();
                        }
                    }
                } finally {
                    lock1.unlock();
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            if (lock1.tryLock()) {
                try {
                    System.out.println("Thread 2 acquired lock1");
                    if (lock2.tryLock()) {
                        try {
                            System.out.println("Thread 2 acquired lock2");
                        } finally {
                            lock2.unlock();
                        }
                    }
                } finally {
                    lock1.unlock();
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

Using Thread - Safe Classes

Java provides many thread - safe classes like ConcurrentHashMap, CopyOnWriteArrayList, and AtomicInteger. These classes are designed to be used in multithreaded environments without the need for external synchronization.

import java.util.concurrent.ConcurrentHashMap;

public class ThreadSafeClassExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        map.put("key1", 1);
        map.put("key2", 2);
        // Multiple threads can safely access and modify this map
    }
}

Conclusion

Java multithreading is a powerful feature that can significantly improve the performance and responsiveness of Java applications. By understanding the fundamental concepts, using the right usage methods, applying common practices, and following best practices, developers can write robust and efficient multithreaded code. However, multithreading also introduces complexity such as race conditions and deadlocks, so it should be used with caution.

References