Using a Well-Built Lock From Your Toolbox: ReenterantLocks
Although you'll see synchronized
and wait
/notify
in legacy code, it is no longer recommended to use those primitives in concurrent applications.
As you've seen, using synchronized as well as wait and notify can be tedious with lines of code. Worse, it's easy to get wrong since you are not just synchronizing on a monitor, but also signaling with wait/notify and using a conditional check, as we did with readyToPrint
above.
Fortunately, Java's java.util.concurrent
package provides a far simpler lock in the form of a unique Lock interface guaranteed to JustWork. ™ We'll investigate the ReenterantLock implementation, which is one of the most common to use.
Lock provides a number of methods, but the important ones are:
Method | Description |
| Similar to synchronized, this suspends any other threads calling lock.lock() on the same lock. |
| Similar to exiting a synchronized block or method. |
| This returns an instance of a java.util.concurrent.Condition class, which we can use in place of wait/notify. |
The last of those methods returns a condition object which you can use to suspend and resume threads, as you would otherwise do with wait/notify. Condition's equivalent methods are await and signal. The same WordPrinter using ReenterantLock looks like this:
public class WordPrinterWithLocks {
ReentrantLock lock = new ReentrantLock();
Condition readyToPrint = lock.newCondition();
// Resources used for computation
String wordToPrint;
// Waits for a wordToPrint to be set
public void printWord() {
try {
lock.lock();
readyToPrint.await();
System.out.println("The word is " + wordToPrint);
} catch (InterruptedException e) {
} finally {
lock.unlock();
}
}
// Sets a wordToPrint
public void setWordToPrint(String wordToPrint) {
try {
lock.lock();
this.wordToPrint = wordToPrint;
readyToPrint.signal();
} finally {
lock.unlock();
}
}
}
Can you see how this is similar to the previous example? The main differences are:
Line 2: Create a ReenterantLock instance.
Line 3: From ReenterantLock, create a new condition to await on.
Line 11: Rather than synchronizing, call lock.lock(), which forces the thread to suspend if it's not the first to call lock().
Line 12: Rather than calling wait, call condition:await.
Line 16: When calling
lock.lock()
, place thelock.unlock()
in a finally block, to ensure that lock is always released.Lines 26: Calling Condition::signal allows await to proceed.
You'll see that the structure is similar; however, the logic involved in creating our gate has been simplified.
Try It Out for Yourself!
First, paste the above class into JShell, which is where we'll run it.
Step 1: Create an instance of the class WordPrinterWithLocks
:
jshell> WordPrinterWithLocks wordPrinter = new WordPrinterWithLocks()
wordPrinter ==> WordPrinterWithLocks@51521cc1
Step 2: Next, create a thread that waits for a word to print and then prints it.
jshell> Thread printerThread = new Thread(()->wordPrinter.printWord())
printerThread ==> Thread[Thread-11,5,main]
Use a lambda to call printWord
.
Step 3: Start the thread and check that it's gone into a waiting state.
jshell> printerThread.start()
jshell> printerThread.getState()
$68 ==> WAITING
Step 4: Now create a thread to set your word.
jshell> Thread setWordThread = new Thread(()->wordPrinter.setWordToPrint("Mars!"))
setWordThread ==> Thread[Thread-12,5,main]
Step 5: Start that thread and unblock the printerThread. Let's see if it prints out your word.
jshell> setWordThread.start()
The word is Mars!
What's special about ReenterantLock?
As each ReenterantLock
is an object with an interface that you can mock, you can also test your concurrent code using Mockito to write tests, which tries different orders of lock and unlock invocations. For instance, you might test that the above code handles locking correctly, with the following:
@ExtendWith(MockitoExtension.class)
@RunWith(JUnitPlatform.class)
public class WordPrinterWithLocksTest {
@Mock
ReenterantLock lock;
@Mock
Condition condition;
@InjectMocks
WordPrinterWithLocks underTest = new WordPrinterWithLocks();
@Test
public void itShouldLockAndUnlockWhenPrintingAWord() {
// Return mocked condition from lock
when(lock.newCondition()).thenReturn(condition);
// TODO use getters/setters for better encapsulation
underTest.wordToPrint = "Test"
underTest.printWord();
verify(lock, times(1)).lock();
verify(lock, times(1)).unlock();
}
}
All this does is make sure that your method locks and unlocks as many times as expected.
Lines 4 to 8: Use Mockito to create mocks of the lock and its associated condition.
Line 16: Use Mockito.when to ensure that lock.newCondition() returns the mocked condition.
Line 19: Kick off code that uses the lock by calling underTest.printWord().
Line 21 to 22: Verify that the mocks were used.
Using locks here also allows you to run your tests in a single-threaded fashion as each unlock()
and lock()
continues executing without waiting on other threads. Remember that single-threaded execution is the simplest form of concurrency; that's all we're testing here.
Like synchronized blocks and methods, ReenterantLocks allow a thread that already has a lock to call lock()
multiple times in multiple methods. The reason you'd do this is so that you can then make each method that needs a particular lock to manage its own locking and unlocking behavior. It is not until each lock has been unlocked that the thread releases its critical section.
Specifically, you should have called unlock()
as many times as lock()
. It's a little like walking into a building and then walking through a series of doors, each of which you lock behind you. If you want to get back out, you won't be released from the building until you've unlocked each door you went through!
Updating the Planet File Analyzer to Use ReentrantLocks: Practice!
Let's perform a simple locking scheme that only allows one thread at a time to update the sum and sample count used in calculating the average.
Checkout the branch p2-c1-reentrantlocks:
git checkout p2-c1-reentrantlocks
Watch how we used ReentrantLocks here:
Let's break down the way ThreadSafePlanetSampler
used ReentrantLock:
Step 1: Allow a ReentrantLock to be passed to your class so your dependencies are not hidden in your class.
public class ThreadSafePlanetSampler {
...
private ReentrantLock lock;
public ThreadSafePlanetSampler(ReentrantLock lock){
this.lock = lock;
}
...
}
Step 2: Claim a lock before updating your sample size and sum of temperatures:
public void addSample(Double temperature){
try {
lock.lock();
sampleSize++;
temperatureTotal += temperature;
} finally {
lock.unlock();
}
}
Line 3: Declare a lock in your try block before updating the sampleSize and temperatureTotal variables. These are normal variables and not atomics.
Line 7:Release the lock in the finally block to ensure that the lock is always released.
Running the Benchmark!
See if you can run the benchmarks. Compare your results with those shown in the video.
./gradlew runBenchmarks
You'll notice that the implementation on this branch is almost as slow as a single-process version. The reason for this is that the process blocks all other threads for every line of the file, making it practically single-process. Designing an algorithm that minimizes the use of locks is always more performant than one which is forced to lock excessively.
You could improve the performance of this program by summing and totaling all samples before combining them in a shared mutable.
Let's Recap!
RenentrantLock is a class that has the methods
lock()
andunlock()
.You can share a RenentrantLock instance across threads and even lock it.
You should
lock()
before a critical section andunlock()
after it. This will only allow a thread at a time to run that code.
You can call
lock()
in each method which needs to ensure that code is run by one thread.Once a thread acquires a ReentrantLock, it may repeatedly call
lock()
in multiple methods without blocking itself.To release the lock to other threads, it must call
unlock()
as many times.
In the next chapter, we'll look at how to restrict access to sections of our code using semaphores!