Introduction to Concurrency and Multithreading
Concurrency and multithreading are fundamental concepts in programming. They allow multiple tasks or threads to run concurrently, improving the performance and efficiency of a program.
In Java, concurrency is achieved by creating multiple threads that can execute independent tasks simultaneously. Each thread has its own execution context, including its own program counter, stack, and local variables.
Multithreading is particularly important in scenarios where an application needs to perform multiple operations simultaneously, such as processing user requests on a web server or performing calculations in parallel.
Concurrency and multithreading provide several benefits, including:
Improved responsiveness: By executing multiple tasks concurrently, applications can respond to user input faster and provide a smoother user experience.
Efficient resource utilization: Multithreading allows for better utilization of system resources, enabling the execution of multiple tasks in parallel and making efficient use of available processing power.
Code modularity: By dividing tasks into separate threads, applications can be structured in a more modular way, making it easier to understand and maintain the code.
Let's take a look at a simple example:
1public class Main {
2 public static void main(String[] args) {
3 int number = 10;
4 for (int i = 1; i <= number; i++) {
5 System.out.println("Thread " + i + " is running");
6 }
7 }
8}
In this example, we create a Main
class with a main
method. Inside the main
method, we create a loop that prints the message "Thread [i] is running" for each iteration. By running this program, we can see the threads executing concurrently.
xxxxxxxxxx
public class Main {
public static void main(String[] args) {
// Replace with your Java logic here
int number = 10;
for (int i = 1; i <= number; i++) {
System.out.println("Thread " + i + " is running");
}
}
}
Let's test your knowledge. Click the correct answer from the options.
Which of the following statements is true about threads?
- Threads are independent paths of execution within a program.
- Threads always run sequentially within a program.
- Threads cannot communicate with each other.
- Threads have their own program counter and stack.
Click the option that best answers the question.
Creating and Running Threads
In Java, threads are created and run using the Thread
class. To create a new thread, you can extend the Thread
class or implement the Runnable
interface.
Here's an example of creating and running a thread using the Runnable
interface:
1public class Main {
2 public static void main(String[] args) {
3 Thread thread = new Thread(new MyRunnable());
4 thread.start();
5 System.out.println("Main thread is running");
6 }
7}
8
9class MyRunnable implements Runnable {
10 public void run() {
11 System.out.println("New thread is running");
12 }
13}
In this example, we create a class named Main
with a main
method. Inside the main
method, we create a new Thread
object and pass an instance of the MyRunnable
class to it. The MyRunnable
class implements the Runnable
interface, which requires the implementation of the run
method. The run
method contains the code that will be executed when the new thread starts. We then start the new thread using the start
method.
When the program is executed, it will output:
1New thread is running
2Main thread is running
The output shows that the new thread is running concurrently with the main thread. The order of execution between the two threads is determined by the operating system scheduler.
xxxxxxxxxx
class Main {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
System.out.println("Main thread is running");
}
}
class MyRunnable implements Runnable {
public void run() {
System.out.println("New thread is running");
}
}
Try this exercise. Fill in the missing part by typing it in.
In Java, threads are created and run using the __________
class. To create a new thread, you can extend the __________
class or implement the __________
interface.
Write the missing line below.
Thread Synchronization
In Java, when multiple threads access and modify shared data simultaneously, it can lead to race conditions and inconsistent results. Thread synchronization is the process of coordinating the access to shared data between multiple threads to ensure data integrity and prevent race conditions.
Why do we need thread synchronization?
We need thread synchronization in scenarios where multiple threads are accessing and modifying shared data concurrently. Without synchronization, the operations performed by multiple threads may interfere with each other, leading to unexpected and incorrect results. By synchronizing the access to shared data, we can ensure that only one thread can access the data at a time, avoiding race conditions and maintaining data consistency.
Synchronization techniques in Java
Java provides several synchronization techniques to coordinate the access to shared data:
Synchronized methods
: Methods can be declared as synchronized to ensure that only one thread can execute the method at a time. The synchronized keyword acquires an exclusive lock on the object's monitor when the method is called, preventing other threads from executing the method concurrently.Synchronized blocks
: Specific blocks of code can be enclosed within a synchronized block to achieve synchronization. The synchronized block acquires the lock on the specified object's monitor before executing the enclosed code, ensuring that only one thread can execute the code block at a time.Volatile variables
: The volatile keyword can be used to declare variables that should be accessed by multiple threads. When a variable is declared as volatile, it ensures that reads and writes to the variable are atomic, guaranteeing visibility of the variable's value to all threads.Locks
: The Lock interface and its implementations, such as ReentrantLock, provide more fine-grained control over thread synchronization. Locks allow for advanced features like fairness, condition variables, and interruptible locks.
Each synchronization technique has its advantages and use cases. The choice of synchronization technique depends on the specific requirements and complexity of the concurrent code.
Let's take a look at an example that demonstrates thread synchronization using a synchronized block:
1// Create a shared counter
2class Counter {
3 private int value = 0;
4
5 public void increment() {
6 // Synchronize access to the shared variable
7 synchronized (this) {
8 value++;
9 }
10 }
11
12 public int getValue() {
13 return value;
14 }
15}
16
17// Create an increment thread
18class IncrementThread implements Runnable {
19 private Counter counter;
20
21 public IncrementThread(Counter counter) {
22 this.counter = counter;
23 }
24
25 public void run() {
26 for (int i = 0; i < 10000; i++) {
27 counter.increment();
28 }
29 }
30}
31
32// Usage example
33Counter counter = new Counter();
34
35// Create multiple threads
36Thread thread1 = new Thread(new IncrementThread(counter));
37Thread thread2 = new Thread(new IncrementThread(counter));
38
39// Start the threads
40thread1.start();
41thread2.start();
42
43// Print the final counter value
44System.out.println("Final Counter Value: " + counter.getValue());
xxxxxxxxxx
}
class Main {
public static void main(String[] args) {
// Synchronization using synchronized block
Counter counter = new Counter();
// Create multiple threads
Thread thread1 = new Thread(new IncrementThread(counter));
Thread thread2 = new Thread(new IncrementThread(counter));
// Start the threads
thread1.start();
thread2.start();
// Wait for threads to finish
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// Print the final counter value
System.out.println("Final Counter Value: " + counter.getValue());
}
}
class Counter {
private int value = 0;
Try this exercise. Click the correct answer from the options.
Which synchronization technique acquires the lock on an object's monitor when a method is called?
Click the option that best answers the question.
- Synchronized methods
- Synchronized blocks
- Volatile variables
- Locks
Thread Communication
In concurrent programming, it is often necessary for threads to communicate with each other to synchronize their actions and share data. Thread communication enables threads to coordinate their execution and avoid race conditions.
Why do we need thread communication?
Thread communication is essential when multiple threads are executing concurrently and need to cooperate to accomplish a task. Without communication, threads may interfere with each other, leading to inconsistent results and race conditions. By enabling threads to exchange information, synchronize their actions, and properly share data, we can ensure thread safety and avoid conflicts.
Techniques for Thread Communication
Java provides several techniques for thread communication:
wait()
andnotify()
: Thewait()
andnotify()
methods are used to block and wake up threads respectively. Threads can call thewait()
method to suspend their execution and wait until another thread notifies them using thenotify()
ornotifyAll()
method.synchronized
keyword: Thesynchronized
keyword is not only used for mutual exclusion but also for thread communication. When a thread owns the monitor of an object using thesynchronized
keyword, it can use thewait()
andnotify()
methods to communicate with other threads. This ensures that threads can safely wait and notify each other while accessing shared resources.
Let's take a look at an example that demonstrates thread communication using the wait()
and notify()
methods:
1// Create a shared Queue
2class Queue {
3 private int value;
4
5 // Method to add a value to the Queue
6 public synchronized void put(int value) {
7 // Wait until the queue is empty
8 while (this.value != 0) {
9 try {
10 wait();
11 } catch (InterruptedException e) {
12 e.printStackTrace();
13 }
14 }
15
16 // Add the value to the queue
17 this.value = value;
18
19 // Notify other threads that the queue is not empty
20 notify();
21 }
22
23 // Method to remove a value from the Queue
24 public synchronized int get() {
25 // Wait until the queue is not empty
26 while (this.value == 0) {
27 try {
28 wait();
29 } catch (InterruptedException e) {
30 e.printStackTrace();
31 }
32 }
33
34 // Get the value from the queue
35 int temp = this.value;
36
37 // Clear the queue
38 this.value = 0;
39
40 // Notify other threads that the queue is empty
41 notify();
42
43 // Return the value
44 return temp;
45 }
46}
47
48// Create a producer thread
49class Producer implements Runnable {
50 private Queue queue;
51
52 public Producer(Queue queue) {
53 this.queue = queue;
54 }
55
56 public void run() {
57 for (int i = 1; i <= 10; i++) {
58 queue.put(i);
59 System.out.println("Producer put: " + i);
60 }
61 }
62}
63
64// Create a consumer thread
65class Consumer implements Runnable {
66 private Queue queue;
67
68 public Consumer(Queue queue) {
69 this.queue = queue;
70 }
71
72 public void run() {
73 for (int i = 1; i <= 10; i++) {
74 int value = queue.get();
75 System.out.println("Consumer got: " + value);
76 }
77 }
78}
79
80// Usage example
81Queue queue = new Queue();
82
83// Create producer and consumer threads
84Thread producerThread = new Thread(new Producer(queue));
85Thread consumerThread = new Thread(new Consumer(queue));
86
87// Start the threads
88producerThread.start();
89consumerThread.start();
Are you sure you're getting this? Is this statement true or false?
Thread communication is essential to ensure thread safety and avoid race conditions.
Press true if you believe the statement is correct, or false otherwise.
Thread Pooling
In concurrent programming, thread pooling is a technique used to manage a group of reusable threads that can be shared among multiple tasks. By using a thread pool, we can improve performance and reduce the overhead of creating and destroying threads for each task.
Why do we need thread pooling?
Creating a new thread for each task can be expensive due to the overhead of thread creation and destruction. Additionally, creating a large number of threads can lead to resource exhaustion and poor performance. Thread pooling addresses these issues by maintaining a pool of pre-initialized threads that are ready to execute tasks.
How does thread pooling work?
A thread pool consists of a fixed number of threads that are created when the pool is initialized. These threads continuously wait for tasks to be assigned to them. When a task arrives, it is picked up by an available thread from the pool and executed. Once the task is completed, the thread is returned back to the pool and made available for the next task.
Thread pooling can be implemented using the ExecutorService
interface and the Executors
class in Java. The ExecutorService
provides high-level methods for submitting tasks to the thread pool and managing the execution of tasks. The Executors
class provides factory methods for creating different types of thread pools.
Let's take a look at an example that demonstrates thread pooling using the ExecutorService
and Executors
classes:
1${code}
xxxxxxxxxx
class Main {
public static void main(String[] args) {
// Create a thread pool with 3 threads
ExecutorService executor = Executors.newFixedThreadPool(3);
// Submit tasks to the thread pool
for (int i = 1; i <= 10; i++) {
final int taskNumber = i;
executor.submit(() -> {
System.out.println("Task " + taskNumber + " executed on thread: " + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
// Shutdown the thread pool
executor.shutdown();
}
}
Build your intuition. Is this statement true or false?
The ExecutorService
interface and the Executors
class can be used to implement thread pooling in Java.
Press true if you believe the statement is correct, or false otherwise.
Concurrent Collections
In concurrent programming, it is important to use data structures that are thread-safe, meaning they can be accessed and modified by multiple threads simultaneously without causing data corruption or inconsistencies. Java provides a set of concurrent collections specifically designed for this purpose.
CopyOnWriteArrayList
One commonly used concurrent collection in Java is the CopyOnWriteArrayList
. It is an implementation of the List
interface that allows thread-safe access and modification of its elements. The CopyOnWriteArrayList
guarantees that all write operations are performed on a separate copy of the underlying array, while the original array remains unchanged.
Let's take a look at an example that demonstrates the usage of CopyOnWriteArrayList
:
xxxxxxxxxx
import java.util.concurrent.CopyOnWriteArrayList;
public class Main {
public static void main(String[] args) {
// Create a concurrent list
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// Add elements to the list
list.add("Java");
list.add("Spring Boot");
list.add("MySQL");
// Iterate over the list
for (String element : list) {
System.out.println(element);
}
// Add an element while iterating
list.add("Hibernate");
// Print the updated list
System.out.println(list);
}
}
Let's test your knowledge. Fill in the missing part by typing it in.
One commonly used concurrent collection in Java is the _____________
. It is an implementation of the List
interface that allows thread-safe access and modification of its elements. The _____________
guarantees that all write operations are performed on a separate copy of the underlying array, while the original array remains unchanged.
Write the missing line below.
Atomic Variables
In concurrent programming, atomic variables are special types of variables that ensure atomicity of operations, meaning they provide a way to perform operations on the variable as if they are executed atomically, without interference from other threads.
One common use case of atomic variables is for implementing thread-safe counters. The java.util.concurrent.atomic.AtomicInteger
class provides a way to create an integer variable that can be safely accessed and modified by multiple threads.
Here's an example that demonstrates the usage of AtomicInteger
to implement a thread-safe counter:
1import java.util.concurrent.atomic.AtomicInteger;
2
3public class Main {
4 public static void main(String[] args) {
5 AtomicInteger counter = new AtomicInteger(0);
6
7 Thread t1 = new Thread(() -> {
8 for (int i = 0; i < 1000; i++) {
9 counter.incrementAndGet();
10 }
11 });
12
13 Thread t2 = new Thread(() -> {
14 for (int i = 0; i < 1000; i++) {
15 counter.incrementAndGet();
16 }
17 });
18
19 t1.start();
20 t2.start();
21
22 try {
23 t1.join();
24 t2.join();
25 } catch (InterruptedException e) {
26 e.printStackTrace();
27 }
28
29 System.out.println("Counter value: " + counter.get());
30 }
31}
xxxxxxxxxx
}
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) {
AtomicInteger counter = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.incrementAndGet();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.incrementAndGet();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter value: " + counter.get());
Are you sure you're getting this? Fill in the missing part by typing it in.
Atomic variables ensure _ of operations, without interference from other threads.
Write the missing line below.
Locks and Conditions
In concurrent programming, locks and conditions are advanced synchronization mechanisms that provide more flexibility and control over the execution flow of threads.
Locks
Locks are used to control access to shared resources by multiple threads. When a thread wants exclusive access to a shared resource, it acquires the lock associated with that resource. Other threads trying to acquire the same lock will be blocked until the lock is released.
Java provides the java.util.concurrent.locks.Lock
interface that defines the basic operations of a lock. One of the commonly used lock implementations is java.util.concurrent.locks.ReentrantLock
.
Here's an example of how to use a ReentrantLock
to control access to a shared resource:
1import java.util.concurrent.locks.Lock;
2import java.util.concurrent.locks.ReentrantLock;
3
4public class Main {
5
6 private Lock lock = new ReentrantLock();
7 private int count = 0;
8
9 public void increment() {
10 lock.lock();
11 try {
12 count++;
13 } finally {
14 lock.unlock();
15 }
16 }
17
18 public static void main(String[] args) {
19 Main main = new Main();
20
21 // Create multiple threads
22 for (int i = 0; i < 10; i++) {
23 Thread thread = new Thread(() -> {
24 for (int j = 0; j < 1000; j++) {
25 main.increment();
26 }
27 });
28 thread.start();
29 }
30
31 try {
32 Thread.sleep(2000);
33 } catch (InterruptedException e) {
34 e.printStackTrace();
35 }
36
37 System.out.println("Count: " + main.count);
38 }
39}
Conditions
Conditions are used to manage the execution flow of threads within a lock. They allow threads to wait until a specific condition is met and to signal other threads when the condition becomes true.
Java provides the java.util.concurrent.locks.Condition
interface for working with conditions. Conditions are created from a lock using the newCondition()
method.
Here's an example of how to use a condition to coordinate the execution of two threads:
1import java.util.concurrent.locks.Condition;
2import java.util.concurrent.locks.Lock;
3import java.util.concurrent.locks.ReentrantLock;
4
5public class Main {
6
7 private Lock lock = new ReentrantLock();
8 private Condition condition = lock.newCondition();
9 private boolean isDataReady = false;
10
11 public void producer() throws InterruptedException {
12 lock.lock();
13 try {
14 // Produce data
15 isDataReady = true;
16 // Signal the consumer
17 condition.signal();
18 } finally {
19 lock.unlock();
20 }
21 }
22
23 public void consumer() throws InterruptedException {
24 lock.lock();
25 try {
26 while (!isDataReady) {
27 // Wait for data to be ready
28 condition.await();
29 }
30 // Consume data
31 isDataReady = false;
32 } finally {
33 lock.unlock();
34 }
35 }
36
37 public static void main(String[] args) {
38 Main main = new Main();
39
40 // Create producer thread
41 Thread producerThread = new Thread(() -> {
42 try {
43 Thread.sleep(2000);
44 main.producer();
45 } catch (InterruptedException e) {
46 e.printStackTrace();
47 }
48 });
49
50 // Create consumer thread
51 Thread consumerThread = new Thread(() -> {
52 try {
53 main.consumer();
54 } catch (InterruptedException e) {
55 e.printStackTrace();
56 }
57 });
58
59 producerThread.start();
60 consumerThread.start();
61
62 try {
63 producerThread.join();
64 consumerThread.join();
65 } catch (InterruptedException e) {
66 e.printStackTrace();
67 }
68 }
69}
In this example, the producer
thread acquires the lock, produces some data, and signals the consumer
thread using the condition's signal()
method. The consumer
thread waits for the data to be ready by calling the condition's await()
method.
By using locks and conditions, you can implement advanced thread synchronization scenarios where precise control over the execution flow is required.
xxxxxxxxxx
}
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
private boolean isDataReady = false;
public void producer() throws InterruptedException {
lock.lock();
try {
// Produce data
isDataReady = true;
// Signal the consumer
condition.signal();
} finally {
lock.unlock();
}
}
public void consumer() throws InterruptedException {
lock.lock();
try {
while (!isDataReady) {
// Wait for data to be ready
condition.await();
}
Try this exercise. Click the correct answer from the options.
Which of the following is NOT true about locks and conditions in Java?
Click the option that best answers the question.
- Locks are used to control access to shared resources by multiple threads
- Conditions are used to manage the execution flow of threads within a lock
- Locks and conditions can only be used together
- Conditions allow threads to wait until a specific condition is met
Thread Safety and Best Practices
Thread safety is the property of a code that guarantees safe execution by multiple threads concurrently without any interference or race conditions.
When writing concurrent code in Java, it is essential to follow certain guidelines and best practices to ensure thread safety. Here are some important practices to keep in mind:
Use Proper Synchronization: One of the key practices is to use appropriate synchronization mechanisms to protect shared resources. This can be achieved using locks, synchronized blocks, or atomic variables.
Avoid Mutable Shared State: Minimize mutable shared state as much as possible. Immutable objects and thread-local variables are preferred to avoid issues related to shared mutable state.
Make Immutable Objects: Immutable objects are inherently thread-safe since they cannot be modified once created. By designing classes to be immutable, you eliminate the need for synchronization.
Minimize Lock Scope: Acquiring and releasing locks should be done in the smallest possible scope. This reduces the chances of contention and improves performance.
Use Thread-Safe Classes: Whenever possible, use thread-safe classes provided by the Java Concurrency API. These classes are designed to handle concurrent access correctly.
By following these best practices, you can write concurrent code that is less prone to race conditions and produces correct and consistent results.
Here's an example of using proper synchronization to ensure thread safety:
1import java.util.concurrent.locks.Lock;
2import java.util.concurrent.locks.ReentrantLock;
3
4public class Counter {
5
6 private Lock lock = new ReentrantLock();
7 private int count = 0;
8
9 public void increment() {
10 lock.lock();
11 try {
12 count++;
13 } finally {
14 lock.unlock();
15 }
16 }
17
18 public int getCount() {
19 lock.lock();
20 try {
21 return count;
22 } finally {
23 lock.unlock();
24 }
25 }
26
27 public static void main(String[] args) {
28 Counter counter = new Counter();
29
30 // Create multiple threads
31 for (int i = 0; i < 10; i++) {
32 Thread thread = new Thread(() -> {
33 for (int j = 0; j < 1000; j++) {
34 counter.increment();
35 }
36 });
37 thread.start();
38 }
39
40 try {
41 Thread.sleep(2000);
42 } catch (InterruptedException e) {
43 e.printStackTrace();
44 }
45
46 System.out.println("Count: " + counter.getCount());
47 }
48}
In this example, the Counter
class provides a thread-safe implementation of a counter. The increment
and getCount
methods are properly synchronized using a ReentrantLock
to ensure that the shared count
variable is updated and accessed safely by multiple threads.
xxxxxxxxxx
try {
// Critical Section
lock.lock();
// Thread-safe code
} finally {
lock.unlock();
}
Build your intuition. Fill in the missing part by typing it in.
In Java, proper synchronization is achieved using mechanisms like locks, synchronized blocks, or ___.
Write the missing line below.
Parallel Streams
In Java, parallel streams provide a convenient way to perform parallel processing on collections. Parallel streams allow you to split the elements of a stream into multiple chunks and process them concurrently across multiple threads.
To execute stream operations in parallel, you can convert a sequential stream to a parallel stream using the parallelStream()
method. This enables the stream operations to be executed on multiple threads simultaneously.
Here's an example of using parallel streams to process a list of numbers:
1// Creating a list of numbers
2List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
3
4// Using sequential stream
5System.out.println("Sequential Stream:");
6numbers.stream()
7 .forEach(System.out::println);
8
9// Using parallel stream
10System.out.println("Parallel Stream:");
11numbers.parallelStream()
12 .forEach(System.out::println);
In this example, we have a list of numbers and we use both a sequential stream (stream()
) and a parallel stream (parallelStream()
) to process and print the numbers. The output shows that the elements are processed in parallel, potentially improving the processing speed.
It's important to note that not all stream operations are suitable for parallel processing. Some operations, such as forEachOrdered()
, may introduce additional synchronization overhead and reduce the performance benefits of parallel streams. Therefore, it's recommended to analyze the specific use case and performance requirements before using parallel streams.
Parallel streams are a powerful feature of Java that can significantly improve the performance of processing collections in concurrent and multi-threaded environments. However, it's important to use them judiciously and consider the trade-offs between performance and complexity.
xxxxxxxxxx
class Main {
public static void main(String[] args) {
// Creating a list of numbers
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Using sequential stream
System.out.println("Sequential Stream:");
numbers.stream()
.forEach(System.out::println);
// Using parallel stream
System.out.println("Parallel Stream:");
numbers.parallelStream()
.forEach(System.out::println);
}
}
Are you sure you're getting this? Fill in the missing part by typing it in.
To execute stream operations in parallel, you can convert a sequential stream to a __ using the parallelStream()
method.
Write the missing line below.
Thread Executors
In Java, thread executors provide a higher-level framework for managing and executing tasks. They provide a convenient and efficient way to create and manage thread pools, which can significantly improve the performance and scalability of concurrent applications.
Thread executors
are part of the java.util.concurrent
package and provide various implementations of the ExecutorService
interface. The ExecutorService
interface is a higher-level replacement for working directly with threads.
Using thread executors, you can:
- Create thread pools with a fixed number of threads, cached threads, or scheduled threads.
- Submit tasks for execution to the thread pool.
- Control the execution and completion of tasks.
One of the key advantages of using thread executors is the ability to reuse threads, which reduces the overhead of creating and destroying threads for each task. Thread executors also provide built-in thread management features, such as thread pooling, thread reuse, and thread synchronization.
xxxxxxxxxx
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadExecutorsExample {
public static void main(String[] args) {
// Create a fixed thread pool with 5 threads
ExecutorService executor = Executors.newFixedThreadPool(5);
// Submit tasks to the executor
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " started");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Task " + taskId + " completed");
});
}
// Shutdown the executor
executor.shutdown();
}
}
The above example demonstrates the usage of the Executors
class to create a fixed thread pool with 5 threads. The newFixedThreadPool()
method takes the maximum number of threads as an argument and returns an ExecutorService
object.
We then submit 10 tasks to the executor using the submit()
method. Each task is a lambda expression that prints a message, sleeps for 1 second, and then prints another message. The sleep()
method is used to simulate some work being done in the task. Finally, we call the shutdown()
method to gracefully shutdown the executor.
By using thread executors, we can manage and execute tasks concurrently in a controlled manner.
Let's test your knowledge. Click the correct answer from the options.
Which of the following is NOT an advantage of using thread executors in Java?
Click the option that best answers the question.
- Improved performance and scalability
- Simplified thread management
- Reduced thread overhead
- Elimination of thread synchronization
Futures and Promises
In concurrent programming, futures and promises are abstractions that represent the results of asynchronous operations.
Futures
A future
is a placeholder for a value that may not be available yet. It allows you to perform other operations while waiting for the result of the asynchronous operation. Once the result becomes available, you can retrieve it from the future object.
In Java, the CompletableFuture
class provides a way to create and work with futures. You can use the supplyAsync
method to asynchronously execute a task and return a future representing the task's result.
Here's an example of using a future in Java with CompletableFuture:
1import java.util.concurrent.CompletableFuture;
2
3public class Main {
4 public static void main(String[] args) {
5 CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
6 // Perform asynchronous task here
7 return "Hello, world!";
8 });
9
10 // Perform other tasks while waiting for the future to complete
11 System.out.println("Executing other tasks...");
12
13 // Wait for the future to complete and retrieve the result
14 String result = future.get();
15 System.out.println("Future completed: " + result);
16 }
17}
In this example, we create a CompletableFuture that executes a task asynchronously and returns the result as a future. While waiting for the future to complete, we can continue executing other tasks. Once the future is complete, we can retrieve the result using the get
method.
Promises
A promise
is a complement to the future. It represents a value that will be resolved in the future. Unlike a future, a promise can be completed manually by setting its value. Once the promise is completed, the associated future can retrieve the value.
Promises are commonly used in scenarios where you want to control the completion of an asynchronous operation. For example, you can create a promise and pass its associated future to another part of your code. That part can complete the promise when the asynchronous operation is done.
The CompletableFuture class in Java provides methods to create and work with promises. For example, you can use the CompletableFuture
constructor to create a promise, and the complete
method to manually complete the promise with a value.
Here's an example of using a promise in Java with CompletableFuture:
1import java.util.concurrent.CompletableFuture;
2
3public class Main {
4 public static void main(String[] args) {
5 CompletableFuture<String> future = new CompletableFuture<>();
6
7 // Pass the future to another part of your code
8 performAsynchronousOperation(future);
9
10 // Complete the promise with a value
11 future.complete("Hello, world!");
12
13 // Retrieve the value from the completed promise
14 String result = future.get();
15 System.out.println("Promise completed: " + result);
16 }
17
18 private static void performAsynchronousOperation(CompletableFuture<String> future) {
19 // Perform asynchronous operation and complete the promise
20 // with the result
21 }
22}
xxxxxxxxxx
class Main {
public static void main(String[] args) {
// Replace with your Java logic here
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Hello, world!";
});
System.out.println("Executing other tasks...");
System.out.println("Waiting for future to complete...");
future.thenAccept(result -> {
System.out.println("Future completed: " + result);
});
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Let's test your knowledge. Is this statement true or false?
Futures in Java are represented by the Future
interface.
Press true if you believe the statement is correct, or false otherwise.
Thread Deadlocks
Thread deadlocks occur when two or more threads are blocked forever, waiting for each other to release the resources they hold. It happens when threads acquire resources in different orders, resulting in a cyclical dependency between the threads.
Causes of Thread Deadlocks
There are four necessary conditions for thread deadlocks:
- Mutual Exclusion: Threads must request exclusive control of the resources they need and cannot be shared.
- Hold and Wait: Threads must hold at least one resource while waiting for another resource.
- No Preemption: Resources cannot be forcibly taken away from a thread; they can only be released voluntarily.
- Circular Wait: There is a circular chain of two or more threads, where each thread is waiting for a resource held by the next thread.
Example of Thread Deadlock
Consider the following code snippet:
1// Create two resources
2Resource resource1 = new Resource();
3Resource resource2 = new Resource();
4
5// Create two threads
6Thread thread1 = new Thread(() -> {
7 synchronized (resource1) {
8 System.out.println("Thread 1 acquired resource 1");
9 try {
10 Thread.sleep(1000);
11 } catch (InterruptedException e) {
12 e.printStackTrace();
13 }
14 synchronized (resource2) {
15 System.out.println("Thread 1 acquired resource 2");
16 }
17 }
18});
19
20Thread thread2 = new Thread(() -> {
21 synchronized (resource2) {
22 System.out.println("Thread 2 acquired resource 2");
23 synchronized (resource1) {
24 System.out.println("Thread 2 acquired resource 1");
25 }
26 }
27});
28
29// Start the threads
30thread1.start();
31thread2.start();
In this example, thread1
acquires resource1
and then waits for resource2
, while thread2
acquires resource2
and then waits for resource1
. This creates a circular dependency between the two threads, leading to a deadlock.
Preventing Thread Deadlocks
To prevent deadlocks, you can follow the following guidelines:
- Avoid holding multiple locks: If possible, try to use only one lock at a time to avoid circular dependencies.
- Lock resources in a consistent order: If multiple locks are needed, make sure to acquire them in a consistent order across all threads.
- Use timeout and tryLock(): Use timouts and non-blocking lock acquisition to handle exceptional cases.
- Avoid nested locks: Be cautious when acquiring locks within locks, as it can increase the chances of deadlocks.
By following these guidelines and designing your code carefully, you can minimize the chances of thread deadlocks and ensure the smooth execution of concurrent programs.
xxxxxxxxxx
}
class Main {
public static void main(String[] args) {
// Create two resources
Resource resource1 = new Resource();
Resource resource2 = new Resource();
// Create two threads
Thread thread1 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 1 acquired resource 1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resource2) {
System.out.println("Thread 1 acquired resource 2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (resource2) {
System.out.println("Thread 2 acquired resource 2");
synchronized (resource1) {
System.out.println("Thread 2 acquired resource 1");
}
}
});
Let's test your knowledge. Click the correct answer from the options.
Which of the following conditions is necessary for thread deadlocks to occur?
Click the option that best answers the question.
- Mutual Exclusion
- Hold and Wait
- Preemption
- Circular Wait
Generating complete for this lesson!