Concurrent
Threads are fundamental to creating concurrent programs in Java. Java provides built-in support for multithreaded programming.
A thread is a separate path of execution in a program. A single process can contain multiple threads, all executing concurrently.
Creating a Thread:
There are two primary ways to create a thread in Java:
- By extending the Thread class.
- By implementing the Runnable interface.
-
Extending the Thread class:
class MyThread extends Thread {
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println("Thread " + i);
}
}
}
public class Main {
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start();
}
}
Implementing the Runnable interface:
class MyRunnable implements Runnable {
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println("Runnable " + i);
}
}
}
public class Main {
public static void main(String[] args) {
Thread t1 = new Thread(new MyRunnable());
t1.start();
}
}
Thread Lifecycle
New
: When you create an instance of Thread class.Runnable
: When you call the start() method.Running
: The thread is currently being executed.Blocked/Waiting
: The thread is waiting for other threads to release some resources.Dead/Terminated
: The thread has finished its execution.
ExecutorService with Future
Commonly Used Methods from Executors Class:
newFixedThreadPool(int nThreads)
: Creates a thread pool with a fixed number of threads.newCachedThreadPool()
: Creates a thread pool that creates new threads as needed and reuses available threads.newSingleThreadExecutor()
: Creates an executor that uses a single worker thread.newScheduledThreadPool(int corePoolSize)
: Creates a thread pool that can schedule commands to run after a given delay or to execute periodically.
// Creates a thread pool with 5 threads.
ExecutorService executorService = Executors.newFixedThreadPool(5);
// Submit tasks to the executor
Future<String> future = executorService.submit(() -> {
// Do some task...
return "Task result";
});
// Fetch the result
try {
String result = future.get(); // This blocks until the result is available
System.out.println(result);
} catch(InterruptedException | ExecutionException e) {
e.printStackTrace();
}
// Shutdown the executor
executorService.shutdown();
Problems with threads
Multithreading, while powerful, introduces several challenges and potential pitfalls. Here are some common problems associated with threads:
Race Conditions
: This occurs when two or more threads access shared data and try to change it simultaneously. Since the thread scheduling algorithm can swap between threads at any time, you don't know the order in which the threads will attempt to access the shared data.Deadlocks
: This happens when two or more threads wait forever for a lock/resource because a cycle exists in the wait-for graph. An example is when thread A locks resource 1 and waits for resource 2, while thread B locks resource 2 and waits for resource 1.Starvation
: This is when a thread is unable to gain regular access to shared resources and is unable to make progress. This happens with priority-based scheduling where a thread with low priority may have to wait for a long time while higher priority threads keep getting scheduled.Live-Locks
: Similar to a deadlock, but the threads involved are not completely blocked. They're actively trying to resolve their situation, typically by releasing some locks and retrying, but they always end up in the same scenario.Thread Leakage
: This is when a system continually creates threads that never get terminated. This can slowly consume resources and can lead to system instability.Priority Inversion
: This is when a higher-priority thread is waiting for a lower-priority thread to release a lock, but the lower-priority thread keeps getting preempted by other threads, thus delaying the higher-priority thread.Memory Interference
: Threads might overwrite each other's updates if not properly synchronized, leading to inconsistent data states.Nested Monitor Lockout
: This is when a thread waits indefinitely by repeatedly trying to acquire two locks while holding one but never being able to acquire both because another thread holds one of them.Slipped Conditions
: This is when a program checks a condition (like a boolean flag) and then makes a decision based on that check. By the time it acts on the decision, the condition might have changed, leading to unpredictable behavior.Resource Thrashing
: This can happen when threads often need to swap resources in and out of memory or cache, leading to significant performance overhead.Higher Overhead
: Creating, destroying, and switching between threads introduces a performance overhead. If not managed efficiently, the cost of this overhead can outweigh the benefits.Complex Debugging
: Debugging multithreaded applications can be challenging, as issues may not be reproducible consistently due to the non-deterministic nature of thread execution.Ordering Issues
: The relative order of execution in a multithreaded system can be different each time the program runs, leading to unexpected results.
Mitigating these problems often requires careful design, thorough testing, and a deep understanding of concurrency. Various tools and best practices, such as static analysis tools, thread sanitizers, and design patterns (like the single writer principle), can also help in addressing these challenges.
Thread overhead
The overhead of system threads refers to the additional resources and time required by the operating system to manage and switch between threads. The exact overhead can vary depending on the operating system and the hardware, but here are some general components of the overhead associated with system threads:
Memory Overhead
:
Each thread requires its own stack memory. This memory is used to keep track of the local variables, return addresses, and to manage function calls for that thread. Typically, thread stacks can be quite large (e.g., 1 MB in size for many modern operating systems, though it's configurable). If you spawn thousands of threads, this can quickly consume a lot of memory. There's also a smaller amount of heap memory used for thread management structures in the kernel.Context Switching
:
Switching between threads (context switching) requires the operating system to save the current state of a thread (its context) and load the saved state of another thread. This operation involves storing and retrieving register values, program counters, stack pointers, and more. Context switching can be expensive in terms of time, especially if it happens frequently. This is because every context switch might involve cache misses which can slow down the CPU due to the need to fetch data from main memory instead of the faster cache.Scheduling Overhead
:
The operating system's scheduler determines which thread runs, when it runs, and for how long. Making these decisions requires computational resources. More threads mean more decisions to be made by the scheduler. If there are too many threads, the system might spend more time deciding which thread to run next than doing actual useful work.Creation and Termination
:
Creating a thread involves allocating memory, setting up its context, and adding it to the scheduling system. Similarly, terminating a thread involves cleanup and deallocation tasks. Both these processes can introduce overhead.Synchronization Overhead
:
When multiple threads access shared resources, synchronization mechanisms (like mutexes, semaphores, or condition variables) are required to ensure safe access. Acquiring and releasing locks, waiting on condition variables, etc., all add overhead, both in terms of time and the kernel resources required to manage these synchronization primitives.Kernel-Level Operations
:
Certain thread-related operations, like creating a thread or changing its state, might require a transition from user mode to kernel mode. These mode switches are costlier than regular function calls.Resource Contention
:
If multiple threads are contending for the same CPU, I/O, or other system resources, it can lead to bottlenecks and reduced overall system performance. It's worth noting that threads provide a powerful way to design concurrent and parallel applications, but they come with inherent overheads. To minimize these overheads and design efficient multithreaded applications, developers should:
Limit the number of threads to what's necessary.
- Reduce the frequency of context switches.
- Use efficient synchronization techniques and data structures.
- Opt for thread pooling instead of frequently creating and destroying threads.
Exercises
Basic Thread Creation
Objective: Create a program with three threads. Each thread prints its name and sleeps for a random time between 1 to 5 seconds. Tasks: Create three threads.
- Use Thread.sleep() to make them sleep for a random duration.
- Run the program and observe the output.
Basic Executor
Objective: Familiarize yourself with the executor framework. Tasks:
- Create a fixed thread pool with a size of 2 using Executors.newFixedThreadPool().
- Submit 5 tasks that print the thread's name and complete after a random duration.
- Observe the output and understand how the executor reuses threads.
Future and Callable
Objective: Understand how to submit tasks that return a result. Tasks:
- Use the executor framework to submit a list of Callable tasks.
- Each task should sleep for a random duration and then return its name.
- Collect the results using Future objects and print them in the main thread.
Solutions
Basic Thread Creation
public class Exercise1 {
public static void main(String[] args) {
// Create three threads
for (int i = 1; i <= 3; i++) {
Thread thread = new Thread(() -> {
try {
// Sleep for a random time between 1 to 5 seconds
int sleepTime = 1000 * (1 + (int) (Math.random() * 5));
System.out.println(Thread.currentThread().getName() + " is sleeping for " + sleepTime + " ms");
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " has woken up");
});
thread.start();
}
}
}
Basic Executor
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Exercise3 {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
for (int i = 0; i < 5; i++) {
executor.submit(() -> {
try {
int sleepTime = 1000 * (1 + (int) (Math.random() * 5));
System.out.println(Thread.currentThread().getName() + " is working");
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " has finished work");
});
}
executor.shutdown();
}
}
Future and Callable
import java.util.concurrent.*;
public class Exercise4 {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Future<String> future = executor.submit(() -> {
int sleepTime = 1000 * (1 + (int) (Math.random() * 5));
Thread.sleep(sleepTime);
return Thread.currentThread().getName();
});
futures.add(future);
}
for (Future<String> future : futures) {
try {
System.out.println("Task in thread " + future.get() + " has finished");
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
executor.shutdown();
}
}