Parallel programming in Java (3/4): Let's cook (Locks)
Intro
This article is depending on this course on LinkedIn Learning
This is part 3 out of 4, to scratch the surface of parallel programming in Java, and how we can make use of threads using Java 11+
Part 1: Parallel programming in Java (1/4): In the kitchen (Threading)
Part 2: Parallel programming in Java (2/4): Ordering the ingredients (Data Race)
In each article, we will have a real-life example to align with the concepts, then we will elaborate on the code.
Let's cook
Chef(A) and Chef(B) are now together in the kitchen, they have fulfilled all the ingredients and now they are ready to cook.
Reentrant Lock
Chef(A) needs to keep the pot away from anyone who can add any ingredients to the cooking while adding the tomatoes. Chef(A) has put the cover on it, then put it in the wardrobe, then closed this with a lock. This prevents Chef(B) from using the pot at all unless Chef(A) removes these three locks in the same order (unlock the lock, take it out of the wardrobe and take the cover off) then Chef(B) can use the pot
Read-Write Lock
our Chefs are finalizing the cooking, but they need to check the ingredients once more, Chef(A) is holding the pot and checking, and Chef(B) also is holding the pot and checking. Suddenly Chef(A) remembered that garlic is missing from the ingredients. Chef(A) tried to add the garlic, but Chef(B) was holding the pot. Chef(A) waited until Chef(B) released the pot, then started adding the garlic. Chef(B) wanted to check on the ingredients one more time, but couldn't because Chef(A) has put the pot in the wardrobe to add the garlic. After Chef(A) finished adding the garlic, the pot is now on the table and Chef(B) can check the ingredients again.
Try-Lock
While Chef(A) was keeping the pot in the wardrobe, Chef(B) wanted to add the chopped carrot to the pot. Chef(B) cut some carrots and then checked whether the pot is available, but the pot is unavailable now, so Chef(B) continued chopping some more carrots and checked back the pot. The pot is now available, so Chef(B) took the pot inside the wardrobe, put the carrot, and put it back on the table available for both of them.
In the code
In Java, we have three types of reentrant lock, the basic reentrant lock, the read reentrant lock, and the write reentrant lock.
This lock can be used multiple times, you can lock as many as you want, but do not forget to unlock each time you lock
Reentrant Lock
For a case where each Chef needs to add carrot and tomato while cooking, also for each carrot added, we need to add an additional tomato to keep it saucy.
import java.util.concurrent.locks.ReentrantLock;
// Tomato lock and Carrot lock is the same lock
// Just distinguish their occurance by names
class Cooking extends Thread {
static int carrotCount, tomatoCount = 0;
static ReentrantLock locker = new ReentrantLock();
private void addTomato() {
locker.lock(); // Tomato lock is locked
// If this method is called from inside (addCarrot) method
// This shall print (2) as now Carrot and Tomato locks are locked
// Other than that it shall print (1)
System.out.println(locker.getHoldCount());
tomatoCount++;
locker.unlock(); // Tomato lock is unlocked
}
private void addCarrot() {
locker.lock(); // Carrot lock is locked
carrotCount++;
addTomato();
locker.unlock(); // Carrot lock is unlocked
}
public void run() {
for (int i = 0; i < 1_000_000; i++) {
addTomato();
addCarrot();
}
}
}
public class ReentrantLockExample {
public static void main(String[] args) throws InterruptedException {
// We will make two threads to change the class variable (static variable)
Thread chefA = new Cooking();
Thread chefB = new Cooking();
chefA.start();
chefB.start();
chefA.join();
chefB.join();
System.out.println("The final needed tomato count is: " + Cooking.tomatoCount);
System.out.println("The final needed carrot count is: " + Cooking.carrotCount);
}
}
Read-Write Lock
In this specific class, we are having two types of locks, read lock and write lock. Read lock is allowing other read locks to share the same resource with, while write lock is not allowing any other lock to share with, and the resource shall be free before locking it with the write lock.
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock;
class Chef extends Thread {
private static int garlicCount = 0;
private boolean writer = false;
private final static ReentrantReadWriteLock locker = new ReentrantReadWriteLock();
private final static ReadLock readLocker = locker.readLock();
private final static WriteLock writeLocker = locker.writeLock();
Chef(boolean writer){this.writer = writer;}
public void run() {
if (writer) {
writeLocker.lock();
garlicCount++;
System.out.println(this.getName() + " Updated garlic count to: " + garlicCount);
writeLocker.unlock();
} else {
readLocker.lock();
System.out.println("Total current read counts is: " + locker.getReadLockCount());
readLocker.unlock();
}
}
}
public class ReentrantReadWriteLockExample {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
new Chef(false).start();
}
for (int i = 0; i < 5; i++) {
new Chef(true).start();
}
}
}
Try-Lock
The try-lock allows the thread to do other things if the resource is possessed by another lock, as this method returns a boolean, we can check on it to reduce the running time.
The following code is running for almost 6 seconds
package locks;
import java.util.concurrent.locks.ReentrantLock;
class CarrotAdder extends Thread {
private int choppedCarrots = 0;
private static int carrotsInPot = 0;
private final static ReentrantLock pot = new ReentrantLock();
public void run() {
while (carrotsInPot < 20) {
if (choppedCarrots > 0) {
try {
pot.lock();
carrotsInPot += choppedCarrots;
System.out.println(this.getName() + " has added " + choppedCarrots + " carrots.");
choppedCarrots = 0;
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
pot.unlock();
}
} else {
try {
Thread.sleep(100);
choppedCarrots++;
System.out.println(this.getName() + " has chopped one more carrot.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public class TryLockExample {
public static void main(String[] args) throws InterruptedException {
Thread chefA = new CarrotAdder();
Thread chefB = new CarrotAdder();
long start = System.currentTimeMillis();
chefA.start();
chefB.start();
chefA.join();
chefB.join();
long finish = System.currentTimeMillis();
System.out.println("Elapsed time: " + (float) (finish - start) / 1000 + " seconds.");
}
}
By making a very small change in the run method as follows, we can almost run it in around 2 seconds
public void run() {
while (carrotsInPot < 20) {
if (choppedCarrots > 0 && pot.tryLock()) { // Added tryLock here
try {
// Removed the lock from here as it's locked above
carrotsInPot += choppedCarrots;
System.out.println(this.getName() + " has added " + choppedCarrots + " carrots.");
choppedCarrots = 0;
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
pot.unlock();
}
} else {
try {
Thread.sleep(100);
choppedCarrots++;
System.out.println(this.getName() + " has chopped one more carrot.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
Conclusion
Although working with multiple threads in parallel may seem a bit disturbing or not safe, knowing the basics and how to correctly manipulate them will give you a huge advantage while using them.
You can check the code here on GitHub in the package (locks)