Threads
Threads are a fundamental part of the Java concurrency model, allowing multiple tasks to run simultaneously within a single program. By utilizing threads, we can create more responsive and efficient applications, particularly in scenarios where tasks can be performed in parallel. Java provides built-in support for multithreading through the Thread
class or the Runnable
interface (with Thread
being used under the hood).
To create a thread, we extend the Thread
class and override its run()
method, which contains the code to be executed by the thread. Once the thread is created, it can be started using the start()
method. The join()
method ensures the program waits until the chosen thread completes execution before proceeding further. It is useful because some resources may be necessary for the rest of the program. Threads can be also put to sleep using the Thread.sleep(milliseconds)
method, allowing for controlled pauses in execution.
Java also provides higher-level concurrency utilities in the java.util.concurrent
package, which includes features like thread pools, futures, and concurrent collections, making it easier to manage complex multithreaded applications.
class Task1 extends Thread {
@Override
public void run() {
for (int i = 1; i <= 10000; i++) {
if (i == 10000)
System.out.println("Task1: Counted to " + i);
}
}
}
class Task2 extends Thread {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 10000; i++)
sum += i;
System.out.println("Task2: Sum = " + sum);
}
}
public class Main {
public static void main(String[] args) {
// Creating threads
Task1 task1 = new Task1();
Task2 task2 = new Task2();
// Starting threads
task1.start();
task2.start();
try {
// Waiting for both threads to finish
task1.join();
task2.join();
}
catch (InterruptedException e) { // if another thread interrupts the current thread
System.out.println("Main thread interrupted.");
}
System.out.println("Both tasks completed.");
}
}
Of course, using join()
is not necessary. We do not use it if we want to start two separate threads that have absolutely nothing to do with each other. However, if we deleted these methods from the example above, the "Both tasks completed." string would display weirdly (check it in your editor) because the compiler would not wait for both threads to display their results before printing it.
// Creating a thread using a lambda statement
int n = 20;
Thread th = new Thread(() -> {
System.out.println("Square of " + n + " is: " + square(n)); // you have to define the square() method
});
th.start();
A race condition occurs when multiple threads access shared resources without proper synchronization, leading to unpredictable behavior. A deadlock happens when two or more threads are waiting indefinitely for resources held by each other, preventing further execution. In contrast, a livelock occurs when threads keep changing state in response to each other but make no real progress, as they continuously attempt to resolve the conflict without success. While race conditions lead to incorrect results, deadlocks halt execution, and livelocks keep the system active but unproductive.
Threads can be synchronized to prevent concurrent access to shared resources, ensuring data consistency. Synchronization is applied using the synchronized
keyword. It ensures that only one thread accesses shared resources at a time. Synchronization ensures data consistency but can reduce performance if overused.
In the example below, synchronized
prevents multiple threads from modifying the shared_resource
variable simultaneously, which could lead to inconsistencies due to race conditions. Without the "lock," threads can interfere with each other while accessing the shared resource, causing the final result to be unpredictable. For example, two threads might read the same value of shared_resource
, increment it, and write it back, effectively skipping one increment. This may result in a final value being lower than expected. When a thread enters the synchronized(Main.class)
block, it first tries to acquire the lock. If another thread already holds the lock, the current thread will wait until the lock is released. Inside the locked block, the shared resource is modified. Once the other thread finishes modifying shared_resource
, the lock is automatically released.
public class Main {
private static int shared_resource = 0;
public static void main(String[] args) {
// Creating multiple threads
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(Main::increment); // the :: operator symbolizes affiliation to the class
threads[i].start();
}
// Waiting for all threads to finish
for (int i = 0; i < 10; i++) {
try {
threads[i].join();
}
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // setting the interrupted status of the current thread
}
}
System.out.println("Final value of shared_resource: " + shared_resource);
}
public static void increment() {
for (int i = 0; i < 1000; i++) {
// Ensuring only one thread modifies the shared resource at a time (acquiring the lock before accessing the data) - synchronization
synchronized(Main.class) {
shared_resource++;
}
}
}
}
this::function
VS v -> function(v)
this::function
is a method reference that directly refers to an existing instance method, making the code more concise and readable. v -> function(v)
is a lambda expression that creates an inline function calling the method, offering more flexibility to add extra logic if needed. Although slightly different, these essentially work the same.
Thread pools
A thread pool is a collection of worker threads that efficiently execute multiple tasks in parallel, reusing threads to avoid the overhead of thread creation.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3); // creating a thread pool with 3 worker threads
// Submitting 5 tasks to the thread pool
for (int i = 1; i <= 5; i++)
executor.submit(new Task(i)); // executing the task with argument "i"
executor.shutdown(); // shutting down the executor to stop accepting new tasks
}
}
class Task implements Runnable { // class to execute inside the thread pool (it can extend Thread instead of implementing Runnable)
private final int taskNum;
public Task(int taskNum){this.taskNum = taskNum;}
@Override
public void run() {
System.out.println("Task " + taskNum + " running in " + Thread.currentThread().getName());
}
}
Semaphore
Unlike a lock, a semaphore allows a specified number of threads to access a resource at the same time. It ensures mutual exclusion based on the defined limit. When setting the semaphore limit to 2, exactly two threads can access the resource simultaneously. You can check online for implementation details.