• 12 hours
  • Hard

Free online content available in this course.

course.header.alt.is_video

course.header.alt.is_certifying

Got it!

Last updated on 11/8/22

Coordinating Between Threads Using Reentrant Locks

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

lock.lock()

Similar to synchronized, this suspends any other threads calling lock.lock() on the same lock.

lock.unlock()

Similar to exiting a synchronized block or method.

lock.condition()

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 the lock.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()  and  unlock().

    • You can share a RenentrantLock instance across threads and even lock it.

    • You should  lock()  before a critical section and  unlock()  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! 

Example of certificate of achievement
Example of certificate of achievement