Chapter | Multithreading Part-2 |
---|---|
Author | Ayush Shrivastava |
Hub | Java Backend Development: Zero to Hero |
Part-1 | https://masteringbackend.com/posts/introduction-to-multithreading?ref=homepage |
In part one, we explored the basics of Java multithreading, including threads, processes, and how to create threads using Thread
and Runnable.
In this second part, we’ll go deeper and cover essential concepts like thread control methods, synchronization, inter-thread communication, daemon threads, and Java concurrency utilities.
Thread Control
Java provides built-in methods to control thread execution.
Key Methods:
sleep():
Pauses the current thread for a given time.join():
Waits for another thread to finish.yield():
Suggests that the current thread should pause for others to execute.
Example:
package ayshriv;
public class MasteringBackend {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 1; i <= 5; i++) {
System.out.println("Thread 1: " + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t1.join(); // Waits for t1 to finish
System.out.println("Main thread finished after t1");
}
}
In this above code, we used sleep()
to pause execution inside the loop and join()
to wait for t1
to finish before moving on. This ensures proper sequence control.
Output
Thread 1: 1
Thread 1: 2
Thread 1: 3
Thread 1: 4
Thread 1: 5
Main thread finished after t1
Thread Synchronization
When multiple threads access shared data, synchronization is used to avoid race conditions.
Example Without Synchronization:
package ayshriv;
class Counter {
int count = 0;
public void increment() {
count++;
}
}
public class MasteringBackend {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) counter.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + counter.count);
}
}
In this above code, the final count may be less than 2000 due to race conditions because both threads are accessing and modifying count
without synchronization.
Output
Count: 196
Example With Synchronization:
package ayshriv;
class SyncCounter {
int count = 0;
public synchronized void increment() {
count++;
}
}
public class MasteringBackend {
public static void main(String[] args) throws InterruptedException {
SyncCounter counter = new SyncCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) counter.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Synchronized Count: " + counter.count);
}
}
In this above code, we used the synchronized
keyword to ensure only one thread can access increment()
at a time, preventing data inconsistency.
Output
Synchronized Count: 2000
Inter-Thread Communication
Java provides
wait(),
notify(),
and notifyAll()
to allow threads to communicate and work together.
Example: Producer-Consumer
package ayshriv;
class SharedResource {
private int data;
private boolean hasData = false;
public synchronized void produce(int value) throws InterruptedException {
while (hasData) wait();
data = value;
System.out.println("Produced: " + data);
hasData = true;
notify();
}
public synchronized void consume() throws InterruptedException {
while (!hasData) wait();
System.out.println("Consumed: " + data);
hasData = false;
notify();
}
}
public class MasteringBackend {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Thread producer = new Thread(() -> {
try {
resource.produce(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread consumer = new Thread(() -> {
try {
resource.consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
producer.start();
consumer.start();
}
}
In this above code, the producer thread creates data, and the consumer thread waits for data to be available. This communication is controlled using wait()
and notify().
Output
Produced: 10
Consumed: 10
Daemon Threads
Daemon threads run in the background and do not prevent the JVM from exiting.
Example:
package ayshriv;
public class MasteringBackend {
public static void main(String[] args) {
Thread daemon = new Thread(() -> {
while (true) {
System.out.println("Background task running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
}
});
daemon.setDaemon(true);
daemon.start();
System.out.println("Main thread finished");
}
}
In this above code, the daemon thread runs in the background, and once the main thread ends, the JVM exits, stopping the daemon thread too.
Output
Main thread finished
Background task running...
Avoiding Deadlock
Deadlock happens when two or more threads are waiting for each other to release resources.
Example of Deadlock:
java
CopyEdit
package ayshriv;
public class MasteringBackend {
public static void main(String[] args) {
String lock1 = "LockA";
String lock2 = "LockB";
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1 locked LockA");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock2) {
System.out.println("Thread 1 locked LockB");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2 locked LockB");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock1) {
System.out.println("Thread 2 locked LockA");
}
}
});
t1.start();
t2.start();
}
}
In this above code, both threads hold one lock and wait for the other, causing a deadlock. To avoid it, use a consistent locking order.
Output
Thread 1 locked LockA
Thread 2 locked LockB
Java Concurrency Utilities
Java offers high-level concurrency tools in the java.util.concurrent
package.
Using ExecutorService
java
CopyEdit
package ayshriv;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MasteringBackend {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> System.out.println("Task 1 running"));
executor.submit(() -> System.out.println("Task 2 running"));
executor.shutdown(); // Don't forget to shut it down
}
}
In this above code, we used ExecutorService
to manage a thread pool, making thread creation and execution more efficient in large applications.