AlgoDaily

Concurrency and Multithreading

Read at your own pace. Code execution, quizzes, progress tracking, and the AI tutor are available in the interactive view with JavaScript enabled.

Introduction to Concurrency and Multithreading

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:

public class Main {
  public static void main(String[] args) {
    int number = 10;
    for (int i = 1; i <= number; i++) {
      System.out.println("Thread " + i + " is running");
    }
  }
}

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.

```java
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");
    }
  }
}
```

Introduction to Concurrency and Multithreading Multiple Choice

Which of the following statements is true about threads?

  1. Threads are independent paths of execution within a program.
  2. Threads always run sequentially within a program.
  3. Threads cannot communicate with each other.
  4. Threads have their own program counter and stack.

Creating and Running Threads

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:

public 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");
  }
}

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:

New thread is running
Main 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.

```java
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");
  }
}
```

Creating and Running Threads Fill 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.

Thread Synchronization

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:

// Create a shared counter
class Counter {
  private int value = 0;

  public void increment() {
    // Synchronize access to the shared variable
    synchronized (this) {
      value++;
    }
  }

  public int getValue() {
    return value;
  }
}

// Create an increment thread
class IncrementThread implements Runnable {
  private Counter counter;

  public IncrementThread(Counter counter) {
    this.counter = counter;
  }

  public void run() {
    for (int i = 0; i < 10000; i++) {
      counter.increment();
    }
  }
}

// Usage example
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();

// Print the final counter value
System.out.println("Final Counter Value: " + counter.getValue());
```java
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;

  public void increment() {
    // Synchronize access to the shared variable
    synchronized (this) {
      value++;
    }
  }

  public int getValue() {
    return value;
  }
}

class IncrementThread implements Runnable {
  private Counter counter;

  public IncrementThread(Counter counter) {
    this.counter = counter;
  }

  public void run() {
    for (int i = 0; i < 10000; i++) {
      counter.increment();
    }
  }
}

```

Thread Synchronization Multiple Choice

Which synchronization technique acquires the lock on an object's monitor when a method is called?

Thread Communication

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() and notify(): The wait() and notify() methods are used to block and wake up threads respectively. Threads can call the wait() method to suspend their execution and wait until another thread notifies them using the notify() or notifyAll() method.

  • synchronized keyword: The synchronized keyword is not only used for mutual exclusion but also for thread communication. When a thread owns the monitor of an object using the synchronized keyword, it can use the wait() and notify() 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:

// Create a shared Queue
class Queue {
  private int value;

  // Method to add a value to the Queue
  public synchronized void put(int value) {
    // Wait until the queue is empty
    while (this.value != 0) {
      try {
        wait();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }

    // Add the value to the queue
    this.value = value;

    // Notify other threads that the queue is not empty
    notify();
  }

  // Method to remove a value from the Queue
  public synchronized int get() {
    // Wait until the queue is not empty
    while (this.value == 0) {
      try {
        wait();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }

    // Get the value from the queue
    int temp = this.value;

    // Clear the queue
    this.value = 0;

    // Notify other threads that the queue is empty
    notify();

    // Return the value
    return temp;
  }
}

// Create a producer thread
class Producer implements Runnable {
  private Queue queue;

  public Producer(Queue queue) {
    this.queue = queue;
  }

  public void run() {
    for (int i = 1; i <= 10; i++) {
      queue.put(i);
      System.out.println("Producer put: " + i);
    }
  }
}

// Create a consumer thread
class Consumer implements Runnable {
  private Queue queue;

  public Consumer(Queue queue) {
    this.queue = queue;
  }

  public void run() {
    for (int i = 1; i <= 10; i++) {
      int value = queue.get();
      System.out.println("Consumer got: " + value);
    }
  }
}

// Usage example
Queue queue = new Queue();

// Create producer and consumer threads
Thread producerThread = new Thread(new Producer(queue));
Thread consumerThread = new Thread(new Consumer(queue));

// Start the threads
producerThread.start();
consumerThread.start();

True or False

Thread communication is essential to ensure thread safety and avoid race conditions.

Thread Pooling

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:

${code}
```java
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();
  }
}
```

Thread Pooling Swipe

The ExecutorService interface and the Executors class can be used to implement thread pooling in Java.

Concurrent Collections - CopyOnWriteArrayList

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:

```java
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);
    }
}
```

Concurrent Collections Fill 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.

Atomic Variables

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:

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());
    }
}
```java
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());
    }
}
```

Atomic Variables Fill In

Atomic variables ensure _________________ of operations, without interference from other threads.

Locks and Conditions

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:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Main {

    private Lock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        Main main = new Main();

        // Create multiple threads
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    main.increment();
                }
            });
            thread.start();
        }

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Count: " + main.count);
    }
}
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:

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();
            }
            // Consume data
            isDataReady = false;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        Main main = new Main();

        // Create producer thread
        Thread producerThread = new Thread(() -> {
            try {
                Thread.sleep(2000);
                main.producer();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        // Create consumer thread
        Thread consumerThread = new Thread(() -> {
            try {
                main.consumer();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

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

        try {
            producerThread.join();
            consumerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

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.

```java
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();
            }
            // Consume data
            isDataReady = false;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        Main main = new Main();

        // Create producer thread
        Thread producerThread = new Thread(() -> {
            try {
                Thread.sleep(2000);
                main.producer();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        // Create consumer thread
        Thread consumerThread = new Thread(() -> {
            try {
                main.consumer();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

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

        try {
            producerThread.join();
            consumerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

```

Locks and Conditions Multiple Choice

Which of the following is NOT true about locks and conditions in Java?

Thread Safety and Best Practices

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:

  1. 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.

  2. 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.

  3. 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.

  4. Minimize Lock Scope: Acquiring and releasing locks should be done in the smallest possible scope. This reduces the chances of contention and improves performance.

  5. 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:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {

    private Lock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        Counter counter = new Counter();

        // Create multiple threads
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            thread.start();
        }

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Count: " + counter.getCount());
    }
}

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.

```java
try {
    // Critical Section
    lock.lock();

    // Thread-safe code
} finally {
    lock.unlock();
}
```

Thread Safety and Best Practices Fill In

In Java, proper synchronization is achieved using mechanisms like locks, synchronized blocks, or ___________.

Parallel Streams

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:

// 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);

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.

```java
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);
    }
}
```

Parallel Streams Fill In

To execute stream operations in parallel, you can convert a sequential stream to a ______________ using the parallelStream() method.

Thread Executors

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.

```
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.

Multiple Choice

Which of the following is NOT an advantage of using thread executors in Java?

Futures and Promises

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:

import java.util.concurrent.CompletableFuture;

public class Main {
  public static void main(String[] args) {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
      // Perform asynchronous task here
      return "Hello, world!";
    });

    // Perform other tasks while waiting for the future to complete
    System.out.println("Executing other tasks...");

    // Wait for the future to complete and retrieve the result
    String result = future.get();
    System.out.println("Future completed: " + result);
  }
}

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:

import java.util.concurrent.CompletableFuture;

public class Main {
  public static void main(String[] args) {
    CompletableFuture<String> future = new CompletableFuture<>();

    // Pass the future to another part of your code
    performAsynchronousOperation(future);

    // Complete the promise with a value
    future.complete("Hello, world!");

    // Retrieve the value from the completed promise
    String result = future.get();
    System.out.println("Promise completed: " + result);
  }

  private static void performAsynchronousOperation(CompletableFuture<String> future) {
    // Perform asynchronous operation and complete the promise
    // with the result
  }
}
```java
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();
    }
  }
}
```

True or False

Futures in Java are represented by the Future interface.

Thread Deadlocks

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:

  1. Mutual Exclusion: Threads must request exclusive control of the resources they need and cannot be shared.
  2. Hold and Wait: Threads must hold at least one resource while waiting for another resource.
  3. No Preemption: Resources cannot be forcibly taken away from a thread; they can only be released voluntarily.
  4. 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:

// 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");
    }
  }
});

// Start the threads
thread1.start();
thread2.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.

```java
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");
        }
      }
    });

    // Start the threads
    thread1.start();
    thread2.start();
  }
}

class Resource {
}    
```

Thread Deadlocks Multiple Choice

Which of the following conditions is necessary for thread deadlocks to occur?

Generating Complete

Generating complete for this lesson!

Open interactive view →