Java Multithreading: Threads, Runnables and Executors

This blog covers the basics of Java multithreading, how to create and manage threads using threads, runnables and executors, and how to synchronize threads using various techniques.

1. Introduction to Java Multithreading

What is Java multithreading and why is it important? In this section, you will learn the basics of creating and running multiple threads in Java using different approaches. You will also understand the benefits and challenges of multithreading, and how to use some of the tools and techniques that Java provides to manage concurrency.

A thread is a unit of execution that runs independently from other threads in a program. A thread has its own stack, program counter, and local variables, but shares the heap, static variables, and resources with other threads. A thread can be in one of several states, such as new, runnable, running, blocked, waiting, or terminated.

Multithreading is the ability of a program to execute multiple threads concurrently. This means that more than one thread can be active at the same time, sharing the CPU and memory resources. Multithreading can improve the performance and responsiveness of a program, especially when it involves tasks that are IO-intensive (such as reading from a file or network) or CPU-intensive (such as performing complex calculations or processing large amounts of data).

However, multithreading also introduces some challenges and risks, such as race conditions, deadlocks, memory consistency errors, and thread starvation. These are situations where the outcome of a program depends on the unpredictable order or timing of thread execution, or where threads interfere with each other’s work or resources. To avoid these problems, you need to use proper synchronization and coordination mechanisms, such as locks, conditions, atomic variables, and concurrent collections.

Java provides several ways to create and run threads, such as extending the Thread class, implementing the Runnable interface, and using executors. Each of these approaches has its own advantages and disadvantages, and you will learn how to use them in the following sections. You will also learn how to control and monitor the state, priority, and interruption of threads, and how to join threads to wait for their completion.

By the end of this section, you will have a solid foundation of Java multithreading, and you will be ready to explore more advanced topics in the next sections.

2. Creating Threads in Java

In this section, you will learn how to create and start threads in Java using two different approaches: extending the Thread class and implementing the Runnable interface. You will also learn the advantages and disadvantages of each approach, and how to choose the best one for your needs.

The Thread class is a built-in class in Java that represents a thread of execution. You can create a thread by extending the Thread class and overriding its run() method. The run() method contains the code that the thread will execute when it is started. To start the thread, you need to create an instance of your subclass and call its start() method. The start() method will invoke the run() method in a separate thread of execution.

Here is an example of how to create and start a thread by extending the Thread class:

// Define a subclass of Thread
class MyThread extends Thread {
    // Override the run() method
    public void run() {
        // Print a message
        System.out.println("Hello from MyThread!");
    }
}

// Create and start an instance of MyThread
MyThread myThread = new MyThread();
myThread.start();

The Runnable interface is a functional interface in Java that represents a task that can be executed by a thread. You can create a thread by implementing the Runnable interface and providing an implementation for its run() method. The run() method contains the code that the thread will execute when it is started. To start the thread, you need to create an instance of your class that implements Runnable and pass it to the constructor of the Thread class. Then, you can call the start() method of the Thread class to invoke the run() method in a separate thread of execution.

Here is an example of how to create and start a thread by implementing the Runnable interface:

// Define a class that implements Runnable
class MyRunnable implements Runnable {
    // Implement the run() method
    public void run() {
        // Print a message
        System.out.println("Hello from MyRunnable!");
    }
}

// Create and start an instance of Thread with MyRunnable as the target
Thread thread = new Thread(new MyRunnable());
thread.start();

Both approaches can create and start threads in Java, but they have some differences that you should consider. Here are some of the advantages and disadvantages of each approach:

  • Extending the Thread class
    • Advantages:
      • You can override other methods of the Thread class, such as interrupt(), join(), or getName().
      • You can access the thread’s properties directly, such as its id, name, or priority.
    • Disadvantages:
      • You cannot extend any other class, since Java does not support multiple inheritance.
      • You cannot reuse the same class for different tasks, since the run() method is fixed.
      • You cannot use lambda expressions, since the Thread class is not a functional interface.
  • Implementing the Runnable interface
    • Advantages:
      • You can extend any other class, since Java supports multiple interface implementation.
      • You can reuse the same class for different tasks, by passing different parameters to the constructor or the run() method.
      • You can use lambda expressions, since the Runnable interface is a functional interface.
    • Disadvantages:
      • You cannot override other methods of the Thread class, unless you subclass it.
      • You cannot access the thread’s properties directly, unless you use the Thread.currentThread() method.

In general, implementing the Runnable interface is preferred over extending the Thread class, since it provides more flexibility and reusability. However, you may choose to extend the Thread class if you need to customize its behavior or access its properties.

Now that you know how to create and start threads in Java, you may wonder how to manage them. In the next section, you will learn how to control and monitor the state, priority, and interruption of threads, and how to join threads to wait for their completion.

2.1. Extending the Thread Class

In this section, you will learn how to create and start threads in Java by extending the Thread class. You will also learn the advantages and disadvantages of this approach, and when to use it.

The Thread class is a built-in class in Java that represents a thread of execution. A thread is a unit of execution that runs independently from other threads in a program. A thread has its own stack, program counter, and local variables, but shares the heap, static variables, and resources with other threads.

To create a thread by extending the Thread class, you need to do the following steps:

  1. Define a subclass of the Thread class and override its run() method. The run() method contains the code that the thread will execute when it is started.
  2. Create an instance of your subclass and call its start() method. The start() method will invoke the run() method in a separate thread of execution.

Here is an example of how to create and start a thread by extending the Thread class:

// Define a subclass of Thread
class MyThread extends Thread {
    // Override the run() method
    public void run() {
        // Print a message
        System.out.println("Hello from MyThread!");
    }
}

// Create and start an instance of MyThread
MyThread myThread = new MyThread();
myThread.start();

By extending the Thread class, you can customize the behavior and properties of your thread. For example, you can override other methods of the Thread class, such as interrupt(), join(), or getName(). You can also access the thread’s properties directly, such as its id, name, or priority.

However, extending the Thread class also has some drawbacks. For example, you cannot extend any other class, since Java does not support multiple inheritance. You also cannot reuse the same class for different tasks, since the run() method is fixed. You also cannot use lambda expressions, since the Thread class is not a functional interface.

In general, extending the Thread class is a simple and straightforward way to create and start threads in Java, but it is not very flexible or reusable. You should use this approach only if you need to customize the behavior or properties of your thread, or if you have a simple and specific task to execute.

In the next section, you will learn another way to create and start threads in Java, by implementing the Runnable interface. You will also learn the advantages and disadvantages of this approach, and how to choose the best one for your needs.

2.2. Implementing the Runnable Interface

In this section, you will learn how to create and start threads in Java by implementing the Runnable interface. You will also learn the advantages and disadvantages of this approach, and when to use it.

The Runnable interface is a functional interface in Java that represents a task that can be executed by a thread. A thread is a unit of execution that runs independently from other threads in a program. A thread has its own stack, program counter, and local variables, but shares the heap, static variables, and resources with other threads.

To create a thread by implementing the Runnable interface, you need to do the following steps:

  1. Define a class that implements the Runnable interface and provide an implementation for its run() method. The run() method contains the code that the thread will execute when it is started.
  2. Create an instance of your class that implements Runnable and pass it to the constructor of the Thread class. The Thread class is a built-in class in Java that represents a thread of execution.
  3. Call the start() method of the Thread class to invoke the run() method in a separate thread of execution.

Here is an example of how to create and start a thread by implementing the Runnable interface:

// Define a class that implements Runnable
class MyRunnable implements Runnable {
    // Implement the run() method
    public void run() {
        // Print a message
        System.out.println("Hello from MyRunnable!");
    }
}

// Create and start an instance of Thread with MyRunnable as the target
Thread thread = new Thread(new MyRunnable());
thread.start();

By implementing the Runnable interface, you can achieve more flexibility and reusability than by extending the Thread class. For example, you can extend any other class, since Java supports multiple interface implementation. You can also reuse the same class for different tasks, by passing different parameters to the constructor or the run() method. You can also use lambda expressions, since the Runnable interface is a functional interface.

However, implementing the Runnable interface also has some drawbacks. For example, you cannot override other methods of the Thread class, unless you subclass it. You also cannot access the thread’s properties directly, unless you use the Thread.currentThread() method. The Thread.currentThread() method returns a reference to the current thread of execution.

In general, implementing the Runnable interface is a preferred and recommended way to create and start threads in Java, since it provides more flexibility and reusability. You should use this approach unless you need to customize the behavior or properties of your thread, or if you have a simple and specific task to execute.

In the next section, you will learn how to manage threads in Java, such as controlling and monitoring their state, priority, and interruption, and joining them to wait for their completion.

3. Managing Threads in Java

In this section, you will learn how to manage threads in Java, such as controlling and monitoring their state, priority, and interruption, and joining them to wait for their completion. You will also learn some of the tools and techniques that Java provides to handle concurrency and avoid common problems.

A thread is a unit of execution that runs independently from other threads in a program. A thread can be in one of several states, such as new, runnable, running, blocked, waiting, or terminated. The state of a thread determines its readiness and eligibility to use the CPU and memory resources. You can use the getState() method of the Thread class to get the current state of a thread.

The priority of a thread is a numerical value that indicates the relative importance of a thread to the scheduler. The scheduler is the part of the JVM that decides which thread to run next. The priority of a thread can range from 1 (lowest) to 10 (highest), with 5 being the default. You can use the setPriority() and getPriority() methods of the Thread class to set and get the priority of a thread. The priority of a thread is only a hint to the scheduler, and it does not guarantee the order or frequency of thread execution.

The interruption of a thread is a mechanism that allows one thread to signal another thread to stop what it is doing and do something else. You can use the interrupt() method of the Thread class to interrupt a thread. The interrupted thread can check if it has been interrupted by using the isInterrupted() or interrupted() methods of the Thread class. The interrupted thread can then decide how to respond to the interruption, such as throwing an InterruptedException, terminating itself, or ignoring the interruption.

The joining of threads is a mechanism that allows one thread to wait for another thread to finish its execution. You can use the join() method of the Thread class to join a thread. The join() method blocks the current thread until the target thread terminates or the specified timeout expires. The join() method can also throw an InterruptedException if the current thread is interrupted while waiting.

Managing threads in Java can be challenging and error-prone, especially when dealing with concurrency and synchronization issues. Concurrency is the ability of a program to execute multiple threads concurrently, sharing the CPU and memory resources. Synchronization is the coordination and control of the access and modification of shared data and resources by multiple threads. Concurrency and synchronization can lead to problems such as race conditions, deadlocks, memory consistency errors, and thread starvation. These are situations where the outcome of a program depends on the unpredictable order or timing of thread execution, or where threads interfere with each other’s work or resources.

To avoid these problems, you need to use proper synchronization and coordination mechanisms, such as locks, conditions, atomic variables, and concurrent collections. These are tools and techniques that Java provides to ensure the correctness and consistency of concurrent programs. You will learn more about these tools and techniques in the next section.

3.1. Thread Lifecycle and States

In this section, you will learn about the lifecycle and states of threads in Java. You will also learn how to use the getState() method of the Thread class to get the current state of a thread.

A thread is a unit of execution that runs independently from other threads in a program. A thread has its own stack, program counter, and local variables, but shares the heap, static variables, and resources with other threads.

A thread can be in one of several states during its lifecycle, such as new, runnable, running, blocked, waiting, or terminated. The state of a thread determines its readiness and eligibility to use the CPU and memory resources. The state of a thread can change due to various events, such as the invocation of the start(), sleep(), wait(), notify(), join(), or interrupt() methods, or the completion or exception of the run() method.

Here is a brief description of each state of a thread:

  • New: The thread is created but not yet started. It is in this state before the invocation of the start() method.
  • Runnable: The thread is ready to run but not yet running. It is in this state after the invocation of the start() method, or after returning from the blocked, waiting, or sleeping state. The thread is waiting for the scheduler to assign it a CPU time slice.
  • Running: The thread is running and executing its code. It is in this state when the scheduler has assigned it a CPU time slice. The thread can leave this state due to various reasons, such as yielding, sleeping, waiting, blocking, or terminating.
  • Blocked: The thread is blocked and waiting for a monitor lock. It is in this state when it tries to enter a synchronized block or method, but the lock is already acquired by another thread. The thread will remain in this state until the lock is released by the owner thread.
  • Waiting: The thread is waiting indefinitely for another thread to perform a specific action. It is in this state when it invokes the wait(), join(), or park() methods without a timeout. The thread will remain in this state until another thread invokes the notify(), notifyAll(), interrupt(), or unpark() methods on the same object.
  • Timed waiting: The thread is waiting for a specified amount of time for another thread to perform a specific action. It is in this state when it invokes the sleep(), wait(), join(), or park() methods with a timeout. The thread will remain in this state until the timeout expires, or another thread invokes the notify(), notifyAll(), interrupt(), or unpark() methods on the same object.
  • Terminated: The thread is terminated and no longer alive. It is in this state when the run() method completes normally or throws an exception, or when the stop() method is invoked. The thread cannot be restarted once it is terminated.

You can use the getState() method of the Thread class to get the current state of a thread. The getState() method returns an enum value of the Thread.State type, which corresponds to one of the states described above. You can use the getState() method to monitor the state of a thread and perform appropriate actions accordingly.

Here is an example of how to use the getState() method to get the current state of a thread:

// Create a thread by implementing Runnable
class MyRunnable implements Runnable {
    public void run() {
        // Print a message
        System.out.println("Hello from MyRunnable!");
    }
}

// Create and start an instance of Thread with MyRunnable as the target
Thread thread = new Thread(new MyRunnable());
thread.start();

// Get and print the current state of the thread
Thread.State state = thread.getState();
System.out.println("The current state of the thread is: " + state);

The output of the above code may vary depending on the timing of the thread execution, but it could be something like this:

Hello from MyRunnable!
The current state of the thread is: TERMINATED

In this section, you learned about the lifecycle and states of threads in Java, and how to use the getState() method to get the current state of a thread. In the next section, you will learn how to set and get the priority of a thread, and how it affects the scheduling of threads.

3.2. Thread Priority and Scheduling

How does the JVM decide which thread to execute next? In this section, you will learn how to influence the order and duration of thread execution using thread priority and thread scheduling. You will also understand the limitations and challenges of these mechanisms, and how to use them effectively.

Thread priority is a numerical value that indicates the importance of a thread relative to other threads. The higher the priority, the more likely the thread is to be selected for execution. You can set the priority of a thread using the setPriority(int) method of the Thread class, and get the priority using the getPriority() method. The priority value can range from Thread.MIN_PRIORITY (1) to Thread.MAX_PRIORITY (10), with Thread.NORM_PRIORITY (5) as the default.

Here is an example of how to set and get the priority of a thread:

// Create a thread with a custom priority
Thread highPriorityThread = new Thread(new MyRunnable());
highPriorityThread.setPriority(8);

// Get the priority of the current thread
int currentPriority = Thread.currentThread().getPriority();

Thread scheduling is the process of allocating CPU time to threads based on their priority and other factors. The JVM uses a preemptive scheduling algorithm, which means that a higher-priority thread can interrupt a lower-priority thread that is currently running. The JVM also uses a time-slicing technique, which means that each thread gets a fixed amount of CPU time (called a quantum) before it is switched to another thread. The quantum size may vary depending on the operating system and the JVM implementation.

Here are some of the benefits and drawbacks of thread priority and scheduling:

  • Benefits:
    • You can control the relative importance and urgency of threads, and give more CPU time to the threads that need it most.
    • You can improve the performance and responsiveness of your program, especially when it involves tasks that have different levels of complexity or priority.
  • Drawbacks:
    • You cannot guarantee the exact order or timing of thread execution, since the JVM may also consider other factors, such as the availability of resources, the number of threads, or the system load.
    • You may encounter problems such as starvation, livelock, or priority inversion, where a lower-priority thread is prevented from running or completing its task due to the interference of higher-priority threads.

To use thread priority and scheduling effectively, you should follow some best practices, such as:

  • Use thread priority sparingly and wisely, and avoid setting extreme values (such as MIN_PRIORITY or MAX_PRIORITY) unless absolutely necessary.
  • Do not rely on thread priority alone to determine the behavior of your program, and always test your program on different platforms and environments.
  • Use synchronization and coordination mechanisms, such as locks, conditions, or concurrent collections, to ensure the correctness and consistency of your program.
  • Use executors and thread pools, which we will discuss in the next section, to manage the creation and execution of threads more efficiently and easily.

By the end of this section, you should have a good understanding of thread priority and scheduling, and how to use them to influence the order and duration of thread execution. In the next section, you will learn how to interrupt and join threads, and how to handle exceptions in multithreaded programs.

3.3. Thread Interruption and Joining

How can you stop a thread from running or wait for it to finish? In this section, you will learn how to use the thread interruption and thread joining mechanisms to control the termination and completion of threads. You will also learn how to handle exceptions in multithreaded programs, and how to use the UncaughtExceptionHandler interface to deal with uncaught exceptions.

Thread interruption is a cooperative mechanism that allows one thread to request another thread to stop its execution. The requesting thread can call the interrupt() method of the Thread class to set the interrupted status of the target thread to true. The target thread can check its interrupted status by calling the isInterrupted() method of the Thread class, or the interrupted() method of the Thread class, which also clears the interrupted status. The target thread can then decide how to respond to the interruption request, such as terminating itself, throwing an exception, or ignoring it.

Here is an example of how to interrupt and check the interrupted status of a thread:

// Create a thread that runs an infinite loop
Thread infiniteLoopThread = new Thread(new Runnable() {
    public void run() {
        while (true) {
            // Do something
        }
    }
});

// Start the thread
infiniteLoopThread.start();

// Interrupt the thread after 5 seconds
Thread.sleep(5000);
infiniteLoopThread.interrupt();

// Check the interrupted status of the thread
System.out.println("Is the thread interrupted? " + infiniteLoopThread.isInterrupted());

Some methods that block the thread, such as sleep(), wait(), or join(), can also detect the interrupted status of the thread and throw an InterruptedException when they are interrupted. The thread can catch the exception and handle it accordingly.

Here is an example of how to handle an InterruptedException:

// Create a thread that sleeps for 10 seconds
Thread sleepingThread = new Thread(new Runnable() {
    public void run() {
        try {
            // Sleep for 10 seconds
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            // Handle the interruption
            System.out.println("The thread was interrupted!");
        }
    }
});

// Start the thread
sleepingThread.start();

// Interrupt the thread after 5 seconds
Thread.sleep(5000);
sleepingThread.interrupt();

Thread joining is a mechanism that allows one thread to wait for another thread to finish its execution. The waiting thread can call the join() method of the Thread class to block itself until the target thread terminates. The join() method can also take a timeout parameter, which specifies the maximum time to wait for the target thread. If the timeout expires, the join() method returns and the waiting thread resumes its execution.

Here is an example of how to join and wait for a thread:

// Create a thread that prints a message after 5 seconds
Thread delayedThread = new Thread(new Runnable() {
    public void run() {
        try {
            // Sleep for 5 seconds
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            // Handle the interruption
            e.printStackTrace();
        }
        // Print a message
        System.out.println("Hello from delayedThread!");
    }
});

// Start the thread
delayedThread.start();

// Join and wait for the thread
delayedThread.join();

// Print a message
System.out.println("Hello from main thread!");

Exceptions are inevitable in multithreaded programs, and you need to handle them properly to avoid unexpected behavior or errors. You can use the try-catch-finally blocks to handle the exceptions that occur within the run() method of a thread. However, if an exception escapes the run() method and is not caught by any handler, it is called an uncaught exception. By default, the JVM prints the stack trace of the uncaught exception to the standard error stream and terminates the thread, but does not affect the other threads or the main thread.

If you want to handle the uncaught exceptions in a different way, you can use the UncaughtExceptionHandler interface, which defines a single method: uncaughtException(Thread, Throwable). This method is invoked when a thread terminates due to an uncaught exception. You can implement this interface and provide your own logic to handle the uncaught exceptions, such as logging, reporting, or recovering. You can set the uncaught exception handler for a thread using the setUncaughtExceptionHandler(UncaughtExceptionHandler) method of the Thread class, or for all threads using the setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler) method of the Thread class.

Here is an example of how to use the UncaughtExceptionHandler interface:

// Define a class that implements UncaughtExceptionHandler
class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {
    // Implement the uncaughtException() method
    public void uncaughtException(Thread t, Throwable e) {
        // Print the thread and the exception details
        System.out.println("Thread: " + t.getName());
        System.out.println("Exception: " + e.getMessage());
    }
}

// Create a thread that throws an exception
Thread exceptionThread = new Thread(new Runnable() {
    public void run() {
        // Throw an exception
        throw new RuntimeException("Something went wrong!");
    }
});

// Set the uncaught exception handler for the thread
exceptionThread.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());

// Start the thread
exceptionThread.start();

By the end of this section, you should have a good understanding of thread interruption and joining, and how to use them to control the termination and completion of threads. You should also know how to handle exceptions in multithreaded programs, and how to use the UncaughtExceptionHandler interface to deal with uncaught exceptions. In the next section, you will learn how to synchronize threads in Java, and how to use various techniques to avoid race conditions, deadlocks, and memory consistency errors.

4. Synchronizing Threads in Java

What is synchronization and why is it important in multithreaded programs? In this section, you will learn how to synchronize threads in Java using various techniques, such as the synchronized keyword, the lock and condition interfaces, and the atomic and concurrent packages. You will also understand the benefits and challenges of synchronization, and how to avoid common problems, such as race conditions, deadlocks, and memory consistency errors.

Synchronization is the process of coordinating the access and modification of shared data or resources by multiple threads. Synchronization ensures that only one thread can access or modify a shared data or resource at a time, and that the changes made by one thread are visible to other threads. Synchronization can improve the correctness and consistency of multithreaded programs, especially when they involve tasks that are dependent (such as updating a shared counter) or mutually exclusive (such as printing to a shared printer).

However, synchronization also introduces some overhead and complexity, such as locking, waiting, and signaling. Locking is the mechanism of acquiring and releasing the access or ownership of a shared data or resource. Waiting is the state of a thread that is blocked until a certain condition is met, such as the release of a lock or the notification of another thread. Signaling is the mechanism of notifying or waking up a waiting thread when a condition is met. These mechanisms can affect the performance and responsiveness of multithreaded programs, and may also cause problems, such as starvation, livelock, or priority inversion. These are situations where a thread is prevented from running or completing its task due to the interference of other threads or the scheduling algorithm.

Java provides several ways to synchronize threads, each with its own advantages and disadvantages. Here are some of the most common techniques:

  • The synchronized keyword: This is the simplest and most widely used technique to synchronize threads in Java. You can use the synchronized keyword to create a critical section, which is a block of code that can only be executed by one thread at a time. You can also use the synchronized keyword to create a synchronized method, which is a method that can only be invoked by one thread at a time on the same object. The synchronized keyword uses the intrinsic lock or the monitor lock of the object to implement the locking and unlocking mechanism.
  • The lock and condition interfaces: These are more advanced and flexible techniques to synchronize threads in Java. You can use the Lock interface to create a lock object, which is an object that can be used to acquire and release the access or ownership of a shared data or resource. You can also use the Condition interface to create a condition object, which is an object that can be used to wait and signal on a specific condition. The lock and condition interfaces provide more features and control than the synchronized keyword, such as reentrant locks, read-write locks, or fairness policies.
  • The atomic and concurrent packages: These are high-level and specialized techniques to synchronize threads in Java. You can use the java.util.concurrent.atomic package to create and use atomic variables, which are variables that can be updated atomically, without using locks or synchronization. You can also use the java.util.concurrent package to create and use concurrent collections, which are collections that can be accessed and modified concurrently, without causing inconsistency or corruption. The atomic and concurrent packages provide better performance and scalability than the synchronized keyword or the lock and condition interfaces, but they are also more limited and specific.

In the following sections, you will learn how to use each of these techniques in more detail, with examples and explanations. You will also learn how to avoid common problems and pitfalls of synchronization, and how to choose the best technique for your needs.

4.1. The Synchronized Keyword

One of the most common and simple ways to synchronize threads in Java is to use the synchronized keyword. The synchronized keyword can be applied to a method or a block of code to ensure that only one thread can access it at a time. This prevents race conditions, which are situations where the outcome of a program depends on the unpredictable order or timing of thread execution.

The synchronized keyword works by using a monitor, which is an internal mechanism that controls the access to a shared resource. A monitor can be associated with any object in Java, and each object has only one monitor. When a thread enters a synchronized method or block, it acquires the monitor of the object that is used as the lock. The lock is the object that is specified in the synchronized statement, or the current object (this) if no object is specified. When a thread acquires the lock, it prevents any other thread from entering any synchronized method or block that uses the same lock. When the thread exits the synchronized method or block, it releases the lock, allowing another thread to acquire it.

Here is an example of how to use the synchronized keyword to synchronize a method:

// Define a class that has a shared resource
class Counter {
    // Declare a private variable to store the count
    private int count = 0;

    // Define a synchronized method to increment the count
    public synchronized void increment() {
        // Increment the count by one
        count++;
    }

    // Define a getter method to return the count
    public int getCount() {
        return count;
    }
}

// Create and start two threads that use the same Counter object
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        counter.increment();
    }
});
Thread thread2 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        counter.increment();
    }
});
thread1.start();
thread2.start();

// Wait for the threads to finish and print the final count
thread1.join();
thread2.join();
System.out.println(counter.getCount());

In this example, the Counter class has a private variable count that is shared by multiple threads. The increment() method is synchronized, which means that only one thread can access it at a time. The lock object is the current Counter object (this), which is the same for both threads. This ensures that the count variable is updated correctly and consistently, without any race conditions. The final output of the program is 2000, which is the expected result.

Here is another example of how to use the synchronized keyword to synchronize a block of code:

// Define a class that has two shared resources
class Printer {
    // Declare two private variables to store the messages
    private String message1 = "Hello";
    private String message2 = "World";

    // Define a method to print the messages
    public void print() {
        // Synchronize a block of code using the message1 object as the lock
        synchronized (message1) {
            // Print the message1
            System.out.print(message1 + " ");
            // Simulate some delay
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // Synchronize another block of code using the message2 object as the lock
        synchronized (message2) {
            // Print the message2
            System.out.println(message2);
        }
    }
}

// Create and start two threads that use the same Printer object
Printer printer = new Printer();
Thread thread1 = new Thread(() -> {
    printer.print();
});
Thread thread2 = new Thread(() -> {
    printer.print();
});
thread1.start();
thread2.start();

In this example, the Printer class has two private variables message1 and message2 that are shared by multiple threads. The print() method has two synchronized blocks of code, each using a different lock object. The first block uses the message1 object as the lock, and the second block uses the message2 object as the lock. This means that only one thread can print the message1 at a time, and only one thread can print the message2 at a time. However, two threads can print the message1 and the message2 simultaneously, since they use different locks. The final output of the program is Hello World printed twice, but the order may vary depending on the thread scheduling.

The synchronized keyword is a simple and effective way to synchronize threads in Java, but it also has some limitations and drawbacks. Some of them are:

  • The synchronized keyword does not provide any flexibility or control over the lock acquisition and release. You cannot specify a timeout or a condition for acquiring or releasing the lock, or perform any other actions before or after the lock operation.
  • The synchronized keyword does not allow multiple threads to access a shared resource in a read-only mode. If a thread only needs to read the value of a shared variable, it does not need to acquire the lock, since it does not modify the variable. However, the synchronized keyword does not distinguish between read and write operations, and blocks all threads except the one that holds the lock.
  • The synchronized keyword can cause deadlocks, which are situations where two or more threads are waiting for each other to release the locks they hold, and none of them can proceed. Deadlocks can occur when threads acquire multiple locks in different orders, creating a circular dependency. For example, if thread1 acquires the lock of object A and then tries to acquire the lock of object B, while thread2 acquires the lock of object B and then tries to acquire the lock of object A, both threads will be stuck waiting for each other.

To overcome these limitations and drawbacks, you can use other synchronization mechanisms in Java, such as the Lock and Condition interfaces, which provide more flexibility and control over the lock operations. You will learn how to use them in the next section.

4.2. The Lock and Condition Interfaces

Another way to synchronize threads in Java is to use the Lock and Condition interfaces, which provide more flexibility and control over the lock operations than the synchronized keyword. The Lock interface represents a lock that can be acquired and released by threads, and the Condition interface represents a condition that can be waited for and signaled by threads. You can use these interfaces to implement more complex and fine-grained synchronization scenarios, such as read-write locks, reentrant locks, fair locks, and bounded buffers.

The Lock interface has several methods that allow you to acquire and release the lock, such as lock(), unlock(), tryLock(), and lockInterruptibly(). These methods provide more options and features than the synchronized keyword, such as:

  • You can specify a timeout for acquiring the lock, which can prevent thread starvation and deadlocks.
  • You can interrupt a thread that is waiting for the lock, which can improve the responsiveness and robustness of your program.
  • You can check if the lock is available or held by the current thread, which can help you avoid unnecessary or redundant lock operations.

The Condition interface has several methods that allow you to wait for and signal a condition, such as await(), signal(), signalAll(), and awaitUntil(). These methods provide more options and features than the wait(), notify(), and notifyAll() methods of the Object class, such as:

  • You can associate multiple conditions with a single lock, which can reduce the number of locks and threads needed for your program.
  • You can specify a timeout or a deadline for waiting for a condition, which can prevent thread starvation and deadlocks.
  • You can interrupt a thread that is waiting for a condition, which can improve the responsiveness and robustness of your program.

To use the Lock and Condition interfaces, you need to create an instance of a class that implements them. Java provides several built-in classes that implement these interfaces, such as ReentrantLock, ReentrantReadWriteLock, StampedLock, and Condition. You can also create your own custom classes that implement these interfaces, if you need to customize their behavior or functionality.

Here is an example of how to use the Lock and Condition interfaces to implement a bounded buffer, which is a data structure that can store a fixed number of items and can be accessed by multiple threads. A bounded buffer has two operations: put() and take(), which add and remove items from the buffer, respectively. A thread that calls put() must wait until the buffer is not full, and a thread that calls take() must wait until the buffer is not empty. To synchronize these operations, you can use a lock and two conditions: notFull and notEmpty.

// Import the Lock and Condition classes
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

// Define a class that represents a bounded buffer
class BoundedBuffer {
    // Declare a private array to store the items
    private Object[] items;
    // Declare a private variable to store the size of the buffer
    private int size;
    // Declare a private variable to store the index of the next item to put
    private int putIndex;
    // Declare a private variable to store the index of the next item to take
    private int takeIndex;
    // Declare a private variable to store the number of items in the buffer
    private int count;
    // Declare a private lock to synchronize the operations
    private Lock lock;
    // Declare two private conditions to wait for and signal the operations
    private Condition notFull;
    private Condition notEmpty;

    // Define a constructor that takes the size of the buffer as a parameter
    public BoundedBuffer(int size) {
        // Initialize the array with the given size
        items = new Object[size];
        // Initialize the size variable with the given size
        this.size = size;
        // Initialize the putIndex, takeIndex, and count variables with zero
        putIndex = 0;
        takeIndex = 0;
        count = 0;
        // Initialize the lock with a new ReentrantLock object
        lock = new ReentrantLock();
        // Initialize the conditions with the lock's newCondition() method
        notFull = lock.newCondition();
        notEmpty = lock.newCondition();
    }

    // Define a method to put an item into the buffer
    public void put(Object item) throws InterruptedException {
        // Acquire the lock using the lock() method
        lock.lock();
        try {
            // While the buffer is full, wait for the notFull condition using the await() method
            while (count == size) {
                notFull.await();
            }
            // Put the item into the array at the putIndex
            items[putIndex] = item;
            // Increment the putIndex by one, wrapping around if necessary
            putIndex = (putIndex + 1) % size;
            // Increment the count by one
            count++;
            // Signal the notEmpty condition using the signal() method
            notEmpty.signal();
        } finally {
            // Release the lock using the unlock() method
            lock.unlock();
        }
    }

    // Define a method to take an item from the buffer
    public Object take() throws InterruptedException {
        // Acquire the lock using the lock() method
        lock.lock();
        try {
            // While the buffer is empty, wait for the notEmpty condition using the await() method
            while (count == 0) {
                notEmpty.await();
            }
            // Take the item from the array at the takeIndex
            Object item = items[takeIndex];
            // Increment the takeIndex by one, wrapping around if necessary
            takeIndex = (takeIndex + 1) % size;
            // Decrement the count by one
            count--;
            // Signal the notFull condition using the signal() method
            notFull.signal();
            // Return the item
            return item;
        } finally {
            // Release the lock using the unlock() method
            lock.unlock();
        }
    }
}

// Create and start some threads that use the same BoundedBuffer object
BoundedBuffer buffer = new BoundedBuffer(10);
Thread producer = new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        try {
            buffer.put(i);
            System.out.println("Producer put " + i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
});
Thread consumer = new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        try {
            int item = (int) buffer.take();
            System.out.println("Consumer took " + item);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
});
producer.start();
consumer.start();

In this example, the BoundedBuffer class has a private array items that is shared by multiple threads. The put() and take() methods are synchronized using a lock and two conditions: notFull and notEmpty. The lock object is a ReentrantLock object, which is a class that implements the Lock interface and allows a thread to acquire the same lock multiple times. The condition objects are created using the lock's newCondition() method, which returns a new Condition object that is bound to the lock. The put() method waits for the notFull condition before putting an item into the buffer, and signals the notEmpty condition after putting the item. The take() method waits for the notEmpty condition before taking an item from the buffer, and signals the notFull condition after taking the item. The final output of the program is a sequence of messages that show the producer and consumer threads putting and taking items from the buffer, respectively.

The Lock and Condition interfaces are powerful and flexible tools to synchronize threads in Java, but they also require more care and attention than the synchronized keyword. Some of the best practices and recommendations for using these interfaces are:

  • Always acquire and release the lock in a try-finally block, to ensure that the lock is released even if an exception occurs.
  • Always check the condition in a while loop, to avoid spurious wakeups and ensure that the condition is still true after waiting.
  • Always signal the condition after modifying the state of the shared resource, to notify the waiting threads that the condition may be true.
  • Use the tryLock(), lockInterruptibly(), and await() methods with a timeout parameter, to prevent thread starvation and deadlocks.
  • Use the ReentrantReadWriteLock class to implement a read-write lock, which allows multiple threads to access a shared resource in a read-only mode

    4.3. The Atomic and Concurrent Packages

    In this section, you will learn how to use the atomic and concurrent packages in Java to perform thread-safe operations on shared variables and collections. You will also learn the benefits and limitations of these packages, and how to choose the best one for your needs.

    The atomic package provides classes that support atomic operations on primitive types and references. Atomic operations are operations that are performed as a single unit of work, without interference from other threads. For example, incrementing a variable by one is an atomic operation, since it involves reading, adding, and writing the value in one step. However, incrementing a variable by two is not an atomic operation, since it involves two steps: reading and adding one, and then reading and adding another one. In a multithreaded environment, another thread may modify the variable between these two steps, resulting in a wrong value.

    To avoid this problem, you can use the classes in the atomic package, such as AtomicInteger, AtomicLong, AtomicBoolean, or AtomicReference. These classes provide methods that perform atomic operations on the underlying value, such as get(), set(), incrementAndGet(), decrementAndGet(), compareAndSet(), or getAndSet(). These methods use low-level synchronization mechanisms, such as compare-and-swap, to ensure that the value is updated atomically and consistently across all threads.

    Here is an example of how to use the AtomicInteger class to increment a shared variable atomically:

    // Create an AtomicInteger with an initial value of 0
    AtomicInteger counter = new AtomicInteger(0);
    
    // Define a task that increments the counter by one 100 times
    Runnable task = () -> {
        for (int i = 0; i < 100; i++) {
            // Increment the counter atomically and get the new value
            int value = counter.incrementAndGet();
            // Print the value
            System.out.println(Thread.currentThread().getName() + ": " + value);
        }
    };
    
    // Create and start two threads with the same task
    Thread thread1 = new Thread(task);
    Thread thread2 = new Thread(task);
    thread1.start();
    thread2.start();
    

    The concurrent package provides classes that support concurrent operations on collections and other data structures. Concurrent operations are operations that are performed by multiple threads concurrently, without compromising the integrity and consistency of the data. For example, adding or removing elements from a collection is a concurrent operation, since it involves modifying the structure and content of the collection. However, iterating over a collection is not a concurrent operation, since it involves accessing the elements of the collection without changing them.

    To avoid problems such as ConcurrentModificationException or NullPointerException, you can use the classes in the concurrent package, such as ConcurrentHashMap, ConcurrentLinkedQueue, CopyOnWriteArrayList, or ConcurrentSkipListSet. These classes provide methods that perform concurrent operations on the underlying data structure, such as put(), remove(), offer(), poll(), add(), or contains(). These methods use high-level synchronization mechanisms, such as locking, copying, or lazy initialization, to ensure that the data structure is updated concurrently and consistently across all threads.

    Here is an example of how to use the ConcurrentHashMap class to store and retrieve key-value pairs concurrently:

    // Create a ConcurrentHashMap with an initial capacity of 16
    ConcurrentHashMap map = new ConcurrentHashMap<>(16);
    
    // Define a task that puts some key-value pairs into the map
    Runnable task1 = () -> {
        // Put some key-value pairs into the map
        map.put("one", 1);
        map.put("two", 2);
        map.put("three", 3);
        map.put("four", 4);
    };
    
    // Define another task that gets some values from the map
    Runnable task2 = () -> {
        // Get some values from the map
        Integer one = map.get("one");
        Integer two = map.get("two");
        Integer three = map.get("three");
        Integer four = map.get("four");
        // Print the values
        System.out.println(Thread.currentThread().getName() + ": " + one + ", " + two + ", " + three + ", " + four);
    };
    
    // Create and start two threads with different tasks
    Thread thread1 = new Thread(task1);
    Thread thread2 = new Thread(task2);
    thread1.start();
    thread2.start();
    

    Both packages can help you perform thread-safe operations on shared variables and collections, but they have some differences that you should consider. Here are some of the benefits and limitations of each package:

    • The atomic package
      • Benefits:
        • It provides high-performance and low-overhead atomic operations on primitive types and references.
        • It supports a wide range of atomic operations, such as arithmetic, logical, bitwise, and compare-and-swap.
        • It allows you to create custom atomic classes using the AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, or AtomicReferenceFieldUpdater classes.
      • Limitations:
        • It does not provide atomic operations on collections or other data structures.
        • It does not support complex or compound operations, such as adding two atomic variables or checking the range of an atomic variable.
        • It does not guarantee the order or visibility of atomic operations across all threads, unless you use the volatile keyword or the getAcquire() and setRelease() methods.
    • The concurrent package
      • Benefits:
        • It provides high-concurrency and high-scalability concurrent operations on collections and other data structures.
        • It supports a wide range of concurrent data structures, such as maps, queues, lists, sets, and arrays.
        • It allows you to create custom concurrent classes using the AbstractQueuedSynchronizer, StampedLock, or Striped64 classes.
      • Limitations:
        • It does not provide concurrent operations on primitive types or references, unless you use the ConcurrentHashMap or the ConcurrentSkipListMap classes.
        • It does not support all the operations that the standard collections support, such as sorting, searching, or shuffling.
        • It does not guarantee the consistency or accuracy of the size, length, or elements of the concurrent data structures, unless you use the size(), toArray(), or snapshot() methods.

    In general, you should use the atomic package when you need to perform simple and fast atomic operations on primitive types or references, and you should use the concurrent package when you need to perform complex and scalable concurrent operations on collections or other data structures. However, you may also combine the two packages to achieve the best results for your needs.

    Now that you know how to use the atomic and concurrent packages in Java, you may wonder how to use executors to create and manage thread pools. In the next section, you will learn how to use the executor and executor service interfaces, and the thread pool executor and scheduled thread pool executor classes.

    5. Using Executors in Java

    In this section, you will learn how to use the executor and executor service interfaces, and the thread pool executor and scheduled thread pool executor classes in Java to create and manage thread pools. You will also learn the benefits and limitations of these interfaces and classes, and how to choose the best one for your needs.

    An executor is an object that executes tasks, such as runnables or callables, in a separate thread of execution. An executor service is a subinterface of the executor interface that provides additional methods to manage the lifecycle and termination of the executor and its tasks. A thread pool executor is a class that implements the executor service interface and creates a pool of threads to execute tasks. A scheduled thread pool executor is a subclass of the thread pool executor that can also schedule tasks to run after a delay or at a fixed rate or interval.

    Using executors in Java can simplify the creation and management of threads, and improve the performance and scalability of your program. Here are some of the advantages of using executors:

    • You can avoid the overhead of creating and destroying threads for each task, by reusing the existing threads in the pool.
    • You can control the number and characteristics of the threads in the pool, such as their name, priority, or daemon status.
    • You can submit multiple tasks to the executor and get the results asynchronously, using the Future or CompletionService classes.
    • You can cancel, interrupt, or monitor the progress and status of the tasks, using the Future or FutureTask classes.
    • You can handle the exceptions thrown by the tasks, using the UncaughtExceptionHandler or the afterExecute() method.
    • You can shut down the executor gracefully or forcefully, using the shutdown() or shutdownNow() methods.

    However, using executors in Java also has some challenges and risks, such as:

    • You may encounter problems such as deadlocks, starvation, or livelocks, if the tasks depend on each other or on external resources.
    • You may experience performance degradation or memory leaks, if the tasks are too long-running, too frequent, or too large.
    • You may lose some tasks or results, if the executor is shut down abruptly or the program terminates unexpectedly.

    To avoid these problems, you need to use proper synchronization and coordination mechanisms, such as locks, conditions, latches, barriers, or semaphores, and follow some best practices, such as:

    • Use a fixed-size thread pool, unless you have a variable or unpredictable number of tasks.
    • Use a cached thread pool, only if you have short-lived or low-priority tasks.
    • Use a single thread executor, only if you need to execute tasks sequentially or in a specific order.
    • Use a scheduled thread pool executor, only if you need to execute tasks periodically or with a delay.
    • Use a custom thread factory, if you need to set the name, priority, or daemon status of the threads.
    • Use a custom rejected execution handler, if you need to handle the tasks that are rejected by the executor.
    • Use the invokeAll() or invokeAny() methods, if you need to wait for the completion or result of a collection of tasks.
    • Use the awaitTermination() method, if you need to wait for the executor to shut down completely.

    Here is an example of how to use the thread pool executor class to create and submit tasks to a fixed-size thread pool:

    // Import the necessary classes
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Future;
    
    // Define a task that prints a message and returns a value
    class MyTask implements Callable {
        private String message;
        private Integer value;
    
        // Constructor with parameters
        public MyTask(String message, Integer value) {
            this.message = message;
            this.value = value;
        }
    
        // Implement the call() method
        public Integer call() {
            // Print the message
            System.out.println(Thread.currentThread().getName() + ": " + message);
            // Return the value
            return value;
        }
    }
    
    // Create a fixed-size thread pool with four threads
    ExecutorService executor = Executors.newFixedThreadPool(4);
    
    // Create and submit four tasks to the executor
    Future future1 = executor.submit(new MyTask("Hello", 1));
    Future future2 = executor.submit(new MyTask("World", 2));
    Future future3 = executor.submit(new MyTask("Java", 3));
    Future future4 = executor.submit(new MyTask("Multithreading", 4));
    
    // Get the results from the futures
    Integer result1 = future1.get();
    Integer result2 = future2.get();
    Integer result3 = future3.get();
    Integer result4 = future4.get();
    
    // Print the results
    System.out.println("Result1: " + result1);
    System.out.println("Result2: " + result2);
    System.out.println("Result3: " + result3);
    System.out.println("Result4: " + result4);
    
    // Shut down the executor
    executor.shutdown();
    

    Now that you know how to use executors in Java, you have completed this tutorial on Java multithreading. You have learned how to create and manage multiple threads in Java using threads, runnables, and executors, and how to synchronize threads using various techniques. You have also learned how to use the atomic and concurrent packages to perform thread-safe operations on shared variables and collections. You have gained a solid foundation of Java multithreading, and you can apply your knowledge to create more efficient and responsive programs.

    5.1. The Executor and ExecutorService Interfaces

    In this section, you will learn how to use the executor and executor service interfaces in Java to create and manage thread pools. You will also learn the difference between these two interfaces, and how to choose the best one for your needs.

    An executor is an object that executes tasks, such as runnables or callables, in a separate thread of execution. An executor service is a subinterface of the executor interface that provides additional methods to manage the lifecycle and termination of the executor and its tasks. A thread pool is a set of threads that are created and maintained by an executor or an executor service, and are used to execute tasks.

    The executor interface defines a single method, execute(Runnable command), that takes a runnable object as a parameter and executes it in a thread. The executor interface does not specify how the thread is created, managed, or terminated, or how the tasks are queued, scheduled, or prioritized. These details are left to the implementation classes, such as the ThreadPoolExecutor or the ScheduledThreadPoolExecutor classes.

    Here is an example of how to use the executor interface to execute a task:

    // Import the necessary classes
    import java.util.concurrent.Executor;
    
    // Define a task that prints a message
    class MyTask implements Runnable {
        private String message;
    
        // Constructor with parameters
        public MyTask(String message) {
            this.message = message;
        }
    
        // Implement the run() method
        public void run() {
            // Print the message
            System.out.println(Thread.currentThread().getName() + ": " + message);
        }
    }
    
    // Create an executor object
    Executor executor = ...; // Some implementation class
    
    // Create and execute a task using the executor
    MyTask task = new MyTask("Hello");
    executor.execute(task);
    

    The executor service interface extends the executor interface and adds several methods, such as submit(Callable task), shutdown(), shutdownNow(), isShutdown(), isTerminated(), or awaitTermination(long timeout, TimeUnit unit). These methods allow you to submit tasks that return a result, to shut down the executor service and its threads, to check the status of the executor service and its tasks, and to wait for the completion of the tasks.

    Here is an example of how to use the executor service interface to submit a task and get its result:

    // Import the necessary classes
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Future;
    
    // Define a task that returns a value
    class MyTask implements Callable {
        private Integer value;
    
        // Constructor with parameters
        public MyTask(Integer value) {
            this.value = value;
        }
    
        // Implement the call() method
        public Integer call() {
            // Return the value
            return value;
        }
    }
    
    // Create an executor service object
    ExecutorService executorService = ...; // Some implementation class
    
    // Create and submit a task using the executor service
    MyTask task = new MyTask(1);
    Future future = executorService.submit(task);
    
    // Get the result from the future
    Integer result = future.get();
    
    // Print the result
    System.out.println("Result: " + result);
    
    // Shut down the executor service
    executorService.shutdown();
    

    Both interfaces can help you create and manage thread pools in Java, but they have some differences that you should consider. Here are some of the advantages and disadvantages of each interface:

    • The executor interface
      • Advantages:
        • It provides a simple and consistent way to execute tasks in a thread.
        • It decouples the task creation from the thread creation and management.
        • It allows you to use different implementation classes for different scenarios.
      • Disadvantages:
        • It does not provide any methods to manage the lifecycle and termination of the executor and its tasks.
        • It does not provide any methods to submit tasks that return a result or to handle the exceptions thrown by the tasks.
        • It does not provide any methods to control the size, configuration, or performance of the thread pool.
    • The executor service interface
      • Advantages:
        • It provides several methods to manage the lifecycle and termination of the executor service and its tasks.
        • It provides several methods to submit tasks that return a result or to handle the exceptions thrown by the tasks.
        • It provides several methods to control the size, configuration, or performance of the thread pool.
      • Disadvantages:
        • It adds some complexity and overhead to the executor interface.
        • It requires you to shut down the executor service explicitly, otherwise it may prevent the program from terminating.
        • It may not support all the features or options that the implementation classes provide.

    In general, you should use the executor service interface when you need to manage the lifecycle and termination of the executor service and its tasks, or when you need to submit tasks that return a result or handle the exceptions thrown by the tasks. However, you may also use the executor interface when you need a simple and consistent way to execute tasks in a thread, or when you want to use different implementation classes for different scenarios.

    Now that you know how to use the executor and executor service interfaces in Java, you may wonder how to use the thread pool executor and scheduled thread pool executor classes to create and configure thread pools. In the next section, you will learn how to use these classes and their constructors, methods, and parameters.

    5.2. The ThreadPoolExecutor and ScheduledThreadPoolExecutor Classes

    In the previous section, you learned how to use the Executor and ExecutorService interfaces to create and manage thread pools in Java. In this section, you will learn how to use two concrete implementations of these interfaces: the ThreadPoolExecutor and the ScheduledThreadPoolExecutor classes. You will also learn how to customize and monitor the behavior and performance of these classes.

    The ThreadPoolExecutor class is a subclass of the AbstractExecutorService class that implements the ExecutorService interface. It provides a flexible and powerful way to create and manage a thread pool with various parameters and options. You can specify the core and maximum number of threads, the keep-alive time, the work queue, the thread factory, and the rejected execution handler. You can also adjust the pool size dynamically, shut down the pool gracefully, and execute tasks with or without results.

    Here is an example of how to create and use a ThreadPoolExecutor:

    // Import the necessary classes
    import java.util.concurrent.*;
    
    // Define a task class that implements Callable
    class MyTask implements Callable {
        // Define a name for the task
        private String name;
    
        // Define a constructor that takes a name as a parameter
        public MyTask(String name) {
            this.name = name;
        }
    
        // Implement the call() method
        public String call() throws Exception {
            // Print a message
            System.out.println("Executing task: " + name);
            // Simulate some work
            Thread.sleep(1000);
            // Return a result
            return "Hello from " + name;
        }
    }
    
    // Create a ThreadPoolExecutor with 2 core threads, 4 maximum threads, 10 seconds keep-alive time, a LinkedBlockingQueue as the work queue, and the default thread factory and rejected execution handler
    ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 4, 10, TimeUnit.SECONDS, new LinkedBlockingQueue());
    
    // Submit 4 tasks to the executor and store the futures in a list
    List> futures = new ArrayList<>();
    for (int i = 1; i <= 4; i++) {
        futures.add(executor.submit(new MyTask("Task" + i)));
    }
    
    // Iterate over the futures and print the results
    for (Future future : futures) {
        try {
            System.out.println(future.get());
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
    
    // Shut down the executor
    executor.shutdown();
    

    The ScheduledThreadPoolExecutor class is a subclass of the ThreadPoolExecutor class that implements the ScheduledExecutorService interface. It provides a way to create and manage a thread pool that can schedule tasks to run after a delay or periodically. You can specify the core number of threads, the keep-alive time, the thread factory, and the rejected execution handler. You can also schedule tasks with or without results, and cancel or check the status of the scheduled tasks.

    Here is an example of how to create and use a ScheduledThreadPoolExecutor:

    // Import the necessary classes
    import java.util.concurrent.*;
    
    // Define a task class that implements Runnable
    class MyTask implements Runnable {
        // Define a name for the task
        private String name;
    
        // Define a constructor that takes a name as a parameter
        public MyTask(String name) {
            this.name = name;
        }
    
        // Implement the run() method
        public void run() {
            // Print a message
            System.out.println("Running task: " + name);
        }
    }
    
    // Create a ScheduledThreadPoolExecutor with 2 core threads, 10 seconds keep-alive time, and the default thread factory and rejected execution handler
    ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(2);
    
    // Schedule a task to run after 5 seconds and store the future
    Future future = executor.schedule(new MyTask("Task1"), 5, TimeUnit.SECONDS);
    
    // Schedule a task to run every 3 seconds with an initial delay of 1 second and store the scheduled future
    ScheduledFuture scheduledFuture = executor.scheduleAtFixedRate(new MyTask("Task2"), 1, 3, TimeUnit.SECONDS);
    
    // Wait for 10 seconds
    try {
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    
    // Cancel the scheduled task
    scheduledFuture.cancel(true);
    
    // Shut down the executor
    executor.shutdown();
    

    Both the ThreadPoolExecutor and the ScheduledThreadPoolExecutor classes provide methods to monitor and control the state and performance of the thread pool, such as getPoolSize(), getActiveCount(), getCompletedTaskCount(), getTaskCount(), prestartAllCoreThreads(), allowCoreThreadTimeOut(), and setCorePoolSize(). You can use these methods to fine-tune the behavior and performance of the thread pool according to your needs.

    By using the ThreadPoolExecutor and the ScheduledThreadPoolExecutor classes, you can create and manage thread pools in Java with more flexibility and functionality. In the next section, you will learn how to use the Future and Callable interfaces to execute tasks with results and handle exceptions.

    5.3. The Future and Callable Interfaces

    In the previous sections, you learned how to create and manage thread pools in Java using the Executor, ExecutorService, ThreadPoolExecutor, and ScheduledThreadPoolExecutor interfaces and classes. However, these interfaces and classes only allow you to execute tasks that implement the Runnable interface, which means that they do not return any result or throw any exception. How can you execute tasks that return a result or throw an exception in a thread pool? In this section, you will learn how to use the Future and Callable interfaces to achieve this.

    The Future interface is a generic interface that represents the result of an asynchronous computation. It provides methods to check if the computation is complete, to get the result, to cancel the computation, and to handle exceptions. You can use the Future interface to store the result of a task that is submitted to an ExecutorService, and to retrieve it later when it is available.

    The Callable interface is a functional interface that represents a task that can return a result and throw an exception. It has a single method, call(), that returns a generic type and throws an Exception. You can use the Callable interface to define a task that can return a result or throw an exception, and submit it to an ExecutorService.

    Here is an example of how to use the Future and Callable interfaces to execute a task that returns a result and throws an exception in a thread pool:

    // Import the necessary classes
    import java.util.concurrent.*;
    
    // Define a task class that implements Callable
    class MyTask implements Callable {
        // Define a number for the task
        private int number;
    
        // Define a constructor that takes a number as a parameter
        public MyTask(int number) {
            this.number = number;
        }
    
        // Implement the call() method
        public Integer call() throws Exception {
            // Check if the number is negative
            if (number < 0) {
                // Throw an exception
                throw new IllegalArgumentException("Negative number");
            }
            // Calculate the factorial of the number
            int factorial = 1;
            for (int i = 1; i <= number; i++) {
                factorial *= i;
            }
            // Return the result
            return factorial;
        }
    }
    
    // Create an ExecutorService with a fixed thread pool of 2 threads
    ExecutorService executor = Executors.newFixedThreadPool(2);
    
    // Submit two tasks to the executor and store the futures
    Future future1 = executor.submit(new MyTask(5));
    Future future2 = executor.submit(new MyTask(-1));
    
    // Try to get the results from the futures
    try {
        // Print the result of the first task
        System.out.println("Factorial of 5: " + future1.get());
        // Print the result of the second task
        System.out.println("Factorial of -1: " + future2.get());
    } catch (InterruptedException | ExecutionException e) {
        // Print the exception
        System.out.println("Exception: " + e.getMessage());
    }
    
    // Shut down the executor
    executor.shutdown();
    

    By using the Future and Callable interfaces, you can execute tasks that return results or throw exceptions in a thread pool, and handle them accordingly. This gives you more flexibility and functionality when working with multithreading in Java.

    This concludes the tutorial on Java multithreading. You have learned how to create and manage multiple threads in Java using threads, runnables, and executors, and how to synchronize and coordinate them using various techniques. You have also learned how to execute tasks with results and handle exceptions using the Future and Callable interfaces. You can use these concepts and skills to improve the performance and responsiveness of your Java applications, and to solve complex problems that require concurrency and parallelism.

Leave a Reply

Your email address will not be published. Required fields are marked *