Conquering Concurrency: Demystifying Java Multithreading with Real-World Examples

Sachin Lakshan
Javarevisited
Published in
6 min readFeb 15, 2024

Java concurrency can seem daunting, often introduced with boring “SharedCounter” examples. But its true power lies in tackling real-world scenarios where multiple tasks interweave seamlessly. This post breaks free from the ordinary, exploring thread safety and synchronization through engaging real world examples: a shared bank account and a bustling car park. So, buckle up and get ready to experience multithreading in action!

Understanding Multithreading in Java:

Before we dive into the practical examples, let’s briefly revisit the theoretical aspects of multithreading in Java.

  • Threads: Lightweight units of execution within a process, allowing concurrent operations.
  • Shared Resource: Data (like the shared bank account) accessed by multiple threads.
  • Mutual exclusion: mutual exclusion refers to a mechanism that ensures only one thread can access a shared resource at a time. This is crucial to prevent race conditions, which occur when multiple threads attempt to modify the same data simultaneously, potentially leading to unpredictable and incorrect results.
  • Thread Safety: Guaranteeing correctness and data integrity in multithreaded environments.
  • Synchronization: Coordinating thread access to shared resource using mechanisms like synchronized methods, locks, and semaphores.
  • Wait()/Notify(): Thread communication mechanisms. wait() suspends a thread until notified, while notify() wakes up a waiting thread.

Scenario 1: Shared Bank Account (Thread Safety Essentials)

Imagine a dynamic duo — a career-minded wife and a house-based husband sharing a bank account. Both use their laptops (threads) to make independent transactions. Using appropriate synchronization, we can ensure their financial activities occur smoothly without compromising data integrity.

Understanding the Challenge:

  • Concurrent deposits and withdrawals could lead to incorrect balances.
  • We need to guarantee thread-safe operations to avoid partial updates.
import java.util.concurrent.locks.ReentrantLock;

public class SharedBankAccount {

private double balance;
private final ReentrantLock lock = new ReentrantLock();

public void deposit(double amount) {
lock.lock();
try {
balance += amount;
System.out.println("Deposit: " + amount + ", New balance: " + balance);
} finally {
lock.unlock();
}
}

public void withdraw(double amount) {
lock.lock();
try {
if (balance >= amount) {
balance -= amount;
System.out.println("Withdrawal: " + amount + ", New balance: " + balance);
} else {
System.out.println("Insufficient funds");
}
} finally {
lock.unlock();
}
}

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

Thread wifeThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
account.deposit(100);
}
});

Thread husbandThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
account.withdraw(50);
}
});

wifeThread.start();
husbandThread.start();
}
}

In this implementation:

  • The ReentrantLock named lock is used to ensure mutual exclusion for both the deposit and withdraw methods. The lock is acquired before the critical sections of the methods and released afterward.
  • The ReentrantLock provides finer-grained control over synchronization, allowing the release of the lock even in case of exceptions.

If you prefer to use synchronized methods for achieving thread synchronization, you can modify the above code as follows:

public class SharedBankAccount {

private double balance;

public synchronized void deposit(double amount) {
balance += amount;
System.out.println("Deposit: " + amount + ", New balance: " + balance);
}

public synchronized void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
System.out.println("Withdrawal: " + amount + ", New balance: " + balance);
} else {
System.out.println("Insufficient funds");
}
}

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

Thread wifeThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
account.deposit(100);
}
});

Thread husbandThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
account.withdraw(50);
}
});

wifeThread.start();
husbandThread.start();
}
}

The choice between ReentrantLock and synchronized methods depends on the specific requirements of your application. For simpler cases, where the built-in synchronization provided by synchronized methods is sufficient, it might be a more straightforward choice. If you need more advanced features and flexibility, then using ReentrantLock could be a better option.

Scenario 2: Car Park Control (Wait()/Notify() in Action)

Imagine a bustling car park with limited spaces. Incoming cars (arrivals) compete for spots, while parked cars (departures) free up space. We need a system to coordinate arrivals and departures efficiently.

Understanding the Challenge:

  • Uncontrolled arrivals and departures can lead to overfilled or empty parking spaces.
  • We need to signal threads waiting for space when slots become available and vice versa.
public class CarParkControl {
private int availableSpaces;

public CarParkControl(int totalSpaces) {
this.availableSpaces = totalSpaces;
}

public synchronized void carArrives() {
while (availableSpaces == 0) {
try {
wait(); // wait for a parking space to become available
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
availableSpaces--;
System.out.println("Car parked. Available spaces: " + availableSpaces);

if(availableSpaces == 4){
notify(); // notify the departure thread that a parking space is now occupied by a car.
}

}

public synchronized void carDeparts() {
while (availableSpaces == 5) {
try {
wait(); // wait for a car to park
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

availableSpaces++;
System.out.println("Car departed. Available spaces: " + availableSpaces);

if(availableSpaces == 1){
notify(); // notify the arrival thread that a space is available
}

}
}
public class CarParkSimulation {
public static void main(String[] args) {
// Create an instance of CarParkControl with 5 total parking spaces
CarParkControl carParkControl = new CarParkControl(5);

// ARRIVALS thread
Thread arrivalsThread = new Thread(() -> {
while (true) {
carParkControl.carArrives();
try {
// Simulate random arrival times
Thread.sleep((long) (Math.random() * 1000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});

// DEPARTURES thread
Thread departuresThread = new Thread(() -> {
while (true) {
carParkControl.carDeparts();
try {
// Simulate random departure times
Thread.sleep((long) (Math.random() * 1000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});

// Start both threads
arrivalsThread.start();
departuresThread.start();
}
}

In this implementation:

  • CarParkControl class: Manages the availability of parking spaces in the car park using synchronized methods to ensure thread safety. Similarly, it uses wait() and notify() methods to allow cars to wait for available spaces and to notify waiting threads when spaces become available or unavailable.
  • CarParkSimulation class: Creates two threads representing car arrivals and departures that continuously interact with an instance of CarParkControl. Additionally, this class simulates random arrival and departure times for cars, demonstrating the concurrent nature of the car park simulation.

If you want, you can create separate classes for ARRIVALS and DEPARTURES by implementing the Runnable interface. However, in the provided code, lambda expressions are used to achieve a more concise and readable representation of the thread logic. Here's how you could structure separate classes using the Runnable interface:

public class Arrivals implements Runnable {
private final CarParkControl carParkControl;

public Arrivals(CarParkControl carParkControl) {
this.carParkControl = carParkControl;
}

@Override
public void run() {
while (true) {
carParkControl.carArrives();
try {
// Simulate random arrival times
Thread.sleep((long) (Math.random() * 1000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}

public class Departures implements Runnable {
private final CarParkControl carParkControl;

public Departures(CarParkControl carParkControl) {
this.carParkControl = carParkControl;
}

@Override
public void run() {
while (true) {
carParkControl.carDeparts();
try {
// Simulate random departure times
Thread.sleep((long) (Math.random() * 1000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}

public class CarParkSimulation {
public static void main(String[] args) {
// Create an instance of CarParkControl with 5 total parking spaces
CarParkControl carParkControl = new CarParkControl(5);

// ARRIVALS thread
Thread arrivalsThread = new Thread(new Arrivals(carParkControl));

// DEPARTURES thread
Thread departuresThread = new Thread(new Departures(carParkControl));

// Start both threads
arrivalsThread.start();
departuresThread.start();
}
}

Beyond the Code: Embracing the Power of Concurrency

As you’ve seen, Java concurrency goes beyond contrived shared counter examples. By understanding thread safety, synchronization, and communication mechanisms like wait()/notify(), you can craft elegant and robust solutions for real-world problems like synchronized bank accounts and bustling car parks. Remember, concurrency isn’t a magic trick; it’s a powerful tool that requires careful planning and understanding. Embrace the challenge, experiment with different scenarios, and unlock the true potential of multithreaded programming in Java!

--

--

Sachin Lakshan
Javarevisited

Software Engineer | BEng(Hons) Software Engineering - University of Westminster | Full Stack Developer | Data Science & Machine Learning Enthusiast