1. Introduction to Java
2. Reference Types and Semantics
3. Method Specifications and Testing
4. Loop Invariants
5. Analyzing Complexity
6. Recursion
7. Sorting Algorithms
8. Classes and Encapsulation
9. Interfaces and Polymorphism
10. Inheritance
11. Additional Java Features
12. Collections and Generics
13. Linked Data
14. Iterating over Data Structures
15. Stacks and Queues
16. Trees and their Iterators
17. Binary Search Trees
18. Heaps and Priority Queues
19. Sets and Maps
20. Hashing
21. Graphs
22. Graph Traversals
23. Shortest Paths
24. Graphical User Interfaces
25. Event-Driven Programming
26. Concurrency
27. Synchronization
27. Synchronization

27. Synchronization

In the previous lecture, we introduced concurrency, a powerful tool to split the execution of our code across multiple threads. Many recent advancements in computer hardware have involved increasing their parallelism by adding more CPU (or GPU) cores to the machines, and writing concurrent code enables us to take full advantage of this hardware. However, concurrency comes with a new set of challenges. Our code no longer executes as a single sequence of instructions; instead, it executes as a web of threads, interwoven in some non-deterministic way. Race conditions can arise from this non-determinism, leading our code to have different behaviors each time it executes. Since we no longer know the exact execution path that our code will take, tools such as invariants become harder to manage, and our unit testing does not provide a surefire way to assert the correctness of our code.

This presents us with a dilemma. We have seen that concurrency can greatly improve the performance of our code, especially in systems with many CPU cores. However, we may not be willing to realize these performance gains if they come at the expense of the reliability of our code (for example, if our code is controlling some medical device or airplane navigation system, where an errant calculation can have deadly consequences). Fortunately, there are tools that let us contend with race conditions while still enabling us to achieve concurrency. In today’s lecture, we’ll introduce synchronization and see some basic ways that we can incorporate it into our concurrent Java code.

Definition: Synchronization

Synchronization is the process of coordinating the execution of multiple threads, particularly their access and modification of shared state, within a piece of concurrent code.

Revisiting Race Conditions

At the end of the previous lecture, we considered the following simple multi-threaded application.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static class SharedInt {
  int x = 0;
}

public static void increment(SharedInt i) {
  int xCurrent = i.x;
  i.x = xCurrent + 1;
}

public static void main(String[] args) throws InterruptedException {
  SharedInt s = new SharedInt(); // shared mutable variable

  Thread t1 = new Thread(() -> increment(s));
  Thread t2 = new Thread(() -> increment(s));

  t1.start();
  t2.start();
  
  t1.join();
  t2.join();

  System.out.println("Final value of s.x: " + s.x);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static class SharedInt {
  int x = 0;
}

public static void increment(SharedInt i) {
  int xCurrent = i.x;
  i.x = xCurrent + 1;
}

public static void main(String[] args) throws InterruptedException {
  SharedInt s = new SharedInt(); // shared mutable variable

  Thread t1 = new Thread(() -> increment(s));
  Thread t2 = new Thread(() -> increment(s));

  t1.start();
  t2.start();
  
  t1.join();
  t2.join();

  System.out.println("Final value of s.x: " + s.x);
}

In this application, we create two threads that each have access to the same object, the SharedInt s. When they execute, both of these threads add 1 to s. While this will often result in s having value 2 once both threads have finished executing, we saw that there are some ways to interleave the instructions executed on these threads such that s will have final value 1. This is a race condition.

Let’s dig a little deeper into what went wrong here. In the “bad” execution path, one of the threads will update the value of the SharedInt between the times when the other thread reads its value (writing it to its xCurrent) and writes its incremented value back. Because of this, we cannot assert the following fact about the execution of the increment() method:

The value stored in i.x will be incremented by 1 during the execution of line 7.

While we could make such an assertion in a single-threaded application (where we would be guaranteed that xCurrent would hold the value of i.x at the start of the execution of line 7), the presence of a second thread writing to the SharedInt messes things up. We lose the guarantee that an object’s state remains the same from the time when we last accessed it and the time we are ready to modify it. This highlights the conditions that are necessary for a race condition to occur.

For a race condition to occur, we must have multiple threads attempting to write to the same variable in their shared memory during the same window of time.

To eliminate the possibility of the race condition, we need only address one of these things.

The only other thing we can do is refine the time windows where each thread is allowed to modify the shared object. We’d like a way to say:

Between the time that the value of i.x is read on line 6 and re-assigned on line 7, no other thread should reassign this field.

In other words, we’d like our thread to gain exclusive control of the SharedInt object during this time window so it can guarantee to carry out its increment operation successfully. We say that this block of code is its critical section.

Definition: Critical Section

A critical section is a contiguous sequence of operations involving one or more shared variables that must be sequentially executed on a single thread (without concurrent modification of these shared variables by other threads) to ensure correctness.

Next, we’ll see a mechanism, a mutual exclusion lock (or mutex) that Java provides to allow us to specify these critical sections.

Mutexes and Synchronized Blocks

In our example, we want to make sure that lines 6 and 7 of the increment() method are executed by one of the threads without another thread messing with the state of the SharedInt. In other words, we’d like our thread to be able to “lock down” access to the SharedInt while it is executing these lines. We can achieve this using the synchronized keyword as follows:

1
2
3
4
5
6
public static void increment(SharedInt i) {
  synchronized(i) {
    int xCurrent = i.x;
    i.x = xCurrent + 1;
  }
}
1
2
3
4
5
6
public static void increment(SharedInt i) {
  synchronized(i) {
    int xCurrent = i.x;
    i.x = xCurrent + 1;
  }
}

In Java, any object can serve as a lock (or, more precisely, a mutual exclusion lock shortened to a mutex).

Definition: Lock/Mutex

A lock or mutex is an object that helps to ensure that only one thread is accessing a piece of shared state at any given time.

You can imagine that a synchronized block of code is placed into a padlocked box. A thread is only allowed to execute the code within this box once it is able to obtain the key to unlock the box. The object that the block is synchronized on – in this example, the SharedInt i – is the owner of the only key for the lock. When the thread reaches the synchronized block, it asks the object for the key. As long as the key hasn’t been given to someone else, the object will hand it over so the thread can open the box and execute its code. At the end of the synchronized block, the thread will lock the box back up and return the key to the object.

What does this gain us? Here, the main insight is that there is exactly one key. When the first thread borrowed the key from the object, it gained exclusive control of the synchronized block. When the other thread (also executing the increment() method) reaches the synchronized block, it will also ask the SharedInt for the key, but it will be out of luck. The SharedInt only had one key to lend, and it has already lent it to the first thread. The second thread must wait for the first thread to return the key to the SharedInt before it can obtain the key and enter the synchronized block. In this way, the first thread (and similarly the second thread) can guarantee that the value of the SharedInt won’t change while it is executing the synchronized block, and the race condition has been removed: The value of the SharedInt will always be 2 since both increment() operations will have the intended effect of increasing the value of the SharedInt by 1.

By “locking” (i.e., placing inside of a synchronized block) the critical section of the increment() method, we turn it into an atomic operation.

Definition: Atomic Operation

An atomic operation on a piece of shared state is an instruction or sequence of instructions accessing/modifying that state that is guaranteed to be executed without preemption from another thread.

Within these atomic operations, the code executes as if there is only a single thread, and (assuming the rest of the code is disciplined with its synchronization; see the next section) we can get back many of the guarantees in invariants that we are used to from earlier in the course.

Remark:

Any object in Java can serve as a lock. Often, when we want to ensure mutual exclusion during modifications to a specific object in the shared state, we'll use that object as the lock. When we need to guard some primitive value in the shared state (e.g., the field of a shared object), we might need to use a separate object as the lock. If we wish to enclose the entire body of an instance method within a synchronized block, we can add the synchronized keyword to the method declaration, which guards the method on its target object, this.

Disciplined Synchronization

We’ve just said that adding synchronized blocks to our code encloses it in boxes that can enforce mutual exclusion. This is a great synchronization tool, but it requires us to be disciplined programmers. It is our responsibility to build these boxes in the first place. Code running within a synchronized block is not guaranteed exclusive access by Java. Rather, it provides the guarantee that Java won’t hand out the key (belonging to the object the block is synchronized on) to any other thread that asks for it. To see where things go wrong, let’s consider the following example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class PartialSynchronizationDemo {
  public static class SharedInt {
    int x = 0;
  }

  public static void increment(SharedInt shared) {
    synchronized(shared) {
      int currentX = shared.x;
      shared.x = currentX + 1;
    }
  }

  public static void decrement(SharedInt shared) {
    int currentX = shared.x;
    shared.x = currentX - 1;
  }

  public static void main(String[] args) {
    SharedInt shared = new SharedInt(); // shared mutable variable

    ArrayList<Thread> threads = new ArrayList<>();
    for (int i=0; i<10; i++) {
        threads.add(new Thread(() -> increment(shared)));
        threads.add(new Thread(() -> decrement(shared)));
    }

    for (Thread t : threads) {
        t.start();
    }

    for (Thread t : threads) {
        t.join();
    }

    System.out.println("Final value of shared.x: " + shared.x);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class PartialSynchronizationDemo {
  public static class SharedInt {
    int x = 0;
  }

  public static void increment(SharedInt shared) {
    synchronized(shared) {
      int currentX = shared.x;
      shared.x = currentX + 1;
    }
  }

  public static void decrement(SharedInt shared) {
    int currentX = shared.x;
    shared.x = currentX - 1;
  }

  public static void main(String[] args) {
    SharedInt shared = new SharedInt(); // shared mutable variable

    ArrayList<Thread> threads = new ArrayList<>();
    for (int i=0; i<10; i++) {
        threads.add(new Thread(() -> increment(shared)));
        threads.add(new Thread(() -> decrement(shared)));
    }

    for (Thread t : threads) {
        t.start();
    }

    for (Thread t : threads) {
        t.join();
    }

    System.out.println("Final value of shared.x: " + shared.x);
  }
}

This is very similar to our previous demo, except now we have 10 threads that will each increment the SharedInt once and 10 threads that will each decrement the SharedInt once. If things work as we intend, the increments and decrements will exactly cancel out, and our SharedInt will have a final value of 0. However, this does not happen. When we run the code (or, more accurately, when we run an enhanced version in the lecture release code with some random sleeping to encourage race conditions), we obtain output:

Final value of shared.x: 6

While our increment() method uses synchronization to guard its modifications of the SharedInt, the decrement() method does not. It doesn’t place its critical section into a locked box, meaning it does not need to ask the SharedInt for a key. It just goes ahead and directly modifies the shared state, potentially in violation of the exclusive access that the increment() method went through the trouble to establish. Just a single method, decrement(), being undisciplined about its synchronization was enough to reintroduce a race condition into the code. This emphasizes that:

Synchronization is achieved through programmer discipline and careful specifications. It cannot be automatically enforced (e.g., by the Java compiler).

If you download the lecture code, you’ll see that IntelliJ will add a warning to the synchronized block in our increment() method: “Synchronization on method parameter ‘shared’”. This warning points out that synchronization is prone to bugs and warrants careful review. If you truly want your code to be synchronized, you need to make sure that every modification to a shared variable is done within a synchronized block.

Another common issue is choosing the incorrect objects on which to synchronize. Remember, synchronization will only work when all of the synchronized blocks guarding a piece of shared state need to wait their turn for the same key. In the following example, the incrementing threads synchronize on the mutex incrementLock and the decrementing threads synchronize on the mutex decrementLock. These different mutexes don’t resolve the race conditions between incrementing and decrementing threads. We need to use a single mutex, for example, the SharedInt object itself, to ensure proper synchronization.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class DifferentLocksDemo {
  public static class SharedInt {
    int x = 0;
  }

  public static void increment(SharedInt shared) {
    shared.x++;
  }

  public static void decrement(SharedInt shared) {
    shared.x--;
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt shared = new SharedInt(); // shared mutable variable

    ArrayList<Thread> threads = new ArrayList<>();
    Object incrementLock = new Object();
    Object decrementLock = new Object();

    for (int i=0; i<10; i++) {
      threads.add(new Thread(() -> { synchronized(incrementLock) { increment(shared); }}));
      threads.add(new Thread(() -> { synchronized(decrementLock) { decrement(shared); }}));
    }

    for (Thread t : threads) {
      t.start();
    }

    for (Thread t : threads) {
      t.join();
    }

    System.out.println("Final value of shared.x: " + shared.x);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class DifferentLocksDemo {
  public static class SharedInt {
    int x = 0;
  }

  public static void increment(SharedInt shared) {
    shared.x++;
  }

  public static void decrement(SharedInt shared) {
    shared.x--;
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt shared = new SharedInt(); // shared mutable variable

    ArrayList<Thread> threads = new ArrayList<>();
    Object incrementLock = new Object();
    Object decrementLock = new Object();

    for (int i=0; i<10; i++) {
      threads.add(new Thread(() -> { synchronized(incrementLock) { increment(shared); }}));
      threads.add(new Thread(() -> { synchronized(decrementLock) { decrement(shared); }}));
    }

    for (Thread t : threads) {
      t.start();
    }

    for (Thread t : threads) {
      t.join();
    }

    System.out.println("Final value of shared.x: " + shared.x);
  }
}

Multiple Shared Resources

Sometimes, the critical section of a method may require the modification of multiple shared variables. To correctly synchronize these modifications, we may need to guard the critical section on multiple locks (one per shared variable). For example, consider the following (overly-simplified) code that simulates the state of pans in a restaurant. There are two types of threads. The “chef” threads grab a clean pan and use it, rendering it dirty. The “dishwasher” threads grab a dirty pan and clean it. Our code will track the state of all of the pans over the course of execution. We’ve set up the simulation to ensure that the chefs will never run out of clean pans and the dishwashers will never run out of dirty pans.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class DeadlockDemo {

  public static class SharedInt {
    int x;

    public SharedInt(int init) {
      x = init;
    }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {}
  }

  public static void usePan(SharedInt numClean, SharedInt numDirty, int chefId) {
    synchronized (numClean) {
      System.out.printf("Chef %d grabs a clean pan and starts cooking.\n", chefId);
      numClean.x--;
      sleep((int)(Math.random()*100));

      synchronized (numDirty) {
        System.out.printf("Chef %d is done cooking.\n", chefId);
        numDirty.x++;
      }
    }
  }

  public static void cleanPan(SharedInt numClean, SharedInt numDirty, int dishwasherId) {
    synchronized (numDirty) {
      System.out.printf("Dishwasher %d starts cleaning a dirty pan.\n", dishwasherId);
      numDirty.x--;
      sleep((int)(Math.random()*100));

      synchronized (numClean) {
        System.out.printf("Dishwasher %d is done cleaning.\n", dishwasherId);
        numClean.x++;
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt numClean = new SharedInt(10);
    SharedInt numDirty = new SharedInt(10);

    System.out.printf("Kitchen opens with %d clean pans and %d dirty pans.", numClean.x, numDirty.x);

    ArrayList<Thread> threads = new ArrayList<>();
    for (int i=0; i<10; i++) {
      final int fi = i;
      threads.add(new Thread(() -> usePan(numClean, numDirty, fi))); // "chef" thread
      threads.add(new Thread(() -> cleanPan(numClean, numDirty, fi))); // "dishwasher" thread
    }

    for (Thread t : threads) {
      t.start();
    }

    for (Thread t : threads) {
      t.join();
    }

    System.out.println("Simulation Complete");
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class DeadlockDemo {

  public static class SharedInt {
    int x;

    public SharedInt(int init) {
      x = init;
    }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {}
  }

  public static void usePan(SharedInt numClean, SharedInt numDirty, int chefId) {
    synchronized (numClean) {
      System.out.printf("Chef %d grabs a clean pan and starts cooking.\n", chefId);
      numClean.x--;
      sleep((int)(Math.random()*100));

      synchronized (numDirty) {
        System.out.printf("Chef %d is done cooking.\n", chefId);
        numDirty.x++;
      }
    }
  }

  public static void cleanPan(SharedInt numClean, SharedInt numDirty, int dishwasherId) {
    synchronized (numDirty) {
      System.out.printf("Dishwasher %d starts cleaning a dirty pan.\n", dishwasherId);
      numDirty.x--;
      sleep((int)(Math.random()*100));

      synchronized (numClean) {
        System.out.printf("Dishwasher %d is done cleaning.\n", dishwasherId);
        numClean.x++;
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt numClean = new SharedInt(10);
    SharedInt numDirty = new SharedInt(10);

    System.out.printf("Kitchen opens with %d clean pans and %d dirty pans.", numClean.x, numDirty.x);

    ArrayList<Thread> threads = new ArrayList<>();
    for (int i=0; i<10; i++) {
      final int fi = i;
      threads.add(new Thread(() -> usePan(numClean, numDirty, fi))); // "chef" thread
      threads.add(new Thread(() -> cleanPan(numClean, numDirty, fi))); // "dishwasher" thread
    }

    for (Thread t : threads) {
      t.start();
    }

    for (Thread t : threads) {
      t.join();
    }

    System.out.println("Simulation Complete");
  }
}

When we try to execute this code, it prints the following:

Kitchen opens with 10 clean pans and 10 dirty pans.
Chef 0 grabs a clean pan and starts cooking.
Dishwasher 0 starts cleaning a dirty pan.

Then, the code freezes up and makes no more progress. What has happened?

The issue here is with the nested synchronized blocks. Each of our “chef” threads immediately requests the key for the numClean variable, and they hold onto this key throughout the rest of the method body. In particular, they are holding this key when they request the numDirty key. If this key is unavailable, they will pause execution, still holding onto the numClean key. The opposite is true of the “dishwasher” threads. They start by grabbing the numDirty key, and they hold onto it as they are requesting (or perhaps waiting for) the numClean key.

This can lead to a bad situation called a deadlock, where a “chef” thread is holding the numClean key and waiting for the numDirty key at the same time that a “dishwasher” thread is holding the numDirty key and waiting for the numClean key. No progress can be made because neither thread is willing (or even able) to give up the key that the other thread needs to make progress (and relinquish control of the other key).

Definition: Deadlock

In a deadlock, multiple threads are unable to make progress since they are all stuck waiting to acquire a mutex that another thread controls.

A lot of things needed to happen for this deadlock to arise in the first place:

Eliminating just one of these conditions is enough to eliminate the possibility of deadlock. We provide three examples of how to fix the previous demo below.

guard multiple pieces of shared state with a common mutex

Here, we introduce a new object lock to synchronize both numClean and numDirty. This synchronization is safe. It eliminates the possibility of deadlock because no thread can be holding a lock while it waits for a second lock. However, this code does not take advantage of concurrency. The entire body of each thread's code is within the synchronized block, so only one thread can make progress at a time. Our code degenerates to a sequential execution.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class DeadlockSolution1 {

  public static class SharedInt {
    int x;

    public SharedInt(int init) {
      x = init;
    }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {}
  }

  public static void usePan(Object lock, SharedInt numClean, SharedInt numDirty, int chefId) {
    synchronized (lock) {
      System.out.printf("Chef %d grabs a clean pan and starts cooking.\n", chefId);
      numClean.x--;
      sleep((int)(Math.random()*100));

      System.out.printf("Chef %d is done cooking.\n", chefId);
      numDirty.x++;
    }
  }

  public static void cleanPan(Object lock, SharedInt numClean, SharedInt numDirty, int dishwasherId) {
    synchronized (lock) {
      System.out.printf("Dishwasher %d starts cleaning a dirty pan.\n", dishwasherId);
      numDirty.x--;
      sleep((int)(Math.random()*100));

      System.out.printf("Dishwasher %d is done cleaning.\n", dishwasherId);
      numClean.x++;
    }
  }

    public static void main(String[] args) throws InterruptedException {
      SharedInt numClean = new SharedInt(10);
      SharedInt numDirty = new SharedInt(10);

      Object lock = new Object();

      System.out.printf("Kitchen opens with %d clean pans and %d dirty pans.\n", numClean.x, numDirty.x);

      ArrayList<Thread> threads = new ArrayList<>();
      for (int i=0; i<10; i++) {
        final int fi = i;
        threads.add(new Thread(() -> usePan(lock, numClean, numDirty, fi))); // "chef" thread
        threads.add(new Thread(() -> cleanPan(lock, numClean, numDirty, fi))); // "dishwasher" thread
      }

      for (Thread t : threads) {
        t.start();
      }

      for (Thread t : threads) {
        t.join();
      }

      System.out.println("Simulation Complete");
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class DeadlockSolution1 {

  public static class SharedInt {
    int x;

    public SharedInt(int init) {
      x = init;
    }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {}
  }

  public static void usePan(Object lock, SharedInt numClean, SharedInt numDirty, int chefId) {
    synchronized (lock) {
      System.out.printf("Chef %d grabs a clean pan and starts cooking.\n", chefId);
      numClean.x--;
      sleep((int)(Math.random()*100));

      System.out.printf("Chef %d is done cooking.\n", chefId);
      numDirty.x++;
    }
  }

  public static void cleanPan(Object lock, SharedInt numClean, SharedInt numDirty, int dishwasherId) {
    synchronized (lock) {
      System.out.printf("Dishwasher %d starts cleaning a dirty pan.\n", dishwasherId);
      numDirty.x--;
      sleep((int)(Math.random()*100));

      System.out.printf("Dishwasher %d is done cleaning.\n", dishwasherId);
      numClean.x++;
    }
  }

    public static void main(String[] args) throws InterruptedException {
      SharedInt numClean = new SharedInt(10);
      SharedInt numDirty = new SharedInt(10);

      Object lock = new Object();

      System.out.printf("Kitchen opens with %d clean pans and %d dirty pans.\n", numClean.x, numDirty.x);

      ArrayList<Thread> threads = new ArrayList<>();
      for (int i=0; i<10; i++) {
        final int fi = i;
        threads.add(new Thread(() -> usePan(lock, numClean, numDirty, fi))); // "chef" thread
        threads.add(new Thread(() -> cleanPan(lock, numClean, numDirty, fi))); // "dishwasher" thread
      }

      for (Thread t : threads) {
        t.start();
      }

      for (Thread t : threads) {
        t.join();
      }

      System.out.println("Simulation Complete");
    }
}

standardize the synchronization order

Here, we use nested synchronization blocks on numClean and numDirty, but we ensure that the numClean lock is always grabbed before the numDirty lock. This breaks the "cyclic structure" of the lock acquisition order. Since the numClean synchronized block is always outside of the numDirty synchronized block, it can never be the case that a thread holds the numDirty key while waiting for the numClean key. Thus, a deadlock cannot arise. This is good, but overall this code is no more performant than the previous solution. In effect, the large numClean synchronized blocks again reduce our code to a sequential execution.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class DeadlockSolution2 {

  public static class SharedInt {
    int x;

    public SharedInt(int init) {
      x = init;
    }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {}
  }

  public static void usePan(SharedInt numClean, SharedInt numDirty, int chefId) {
    synchronized (numClean) {
      System.out.printf("Chef %d grabs a clean pan and starts cooking.\n", chefId);
      numClean.x--;
      sleep((int)(Math.random()*100));

      synchronized (numDirty) {
        System.out.printf("Chef %d is done cooking.\n", chefId);
        numDirty.x++;
      }
    }
  }

  public static void cleanPan(SharedInt numClean, SharedInt numDirty, int dishwasherId) {
    synchronized (numClean) {
      synchronized (numDirty) {
        System.out.printf("Dishwasher %d starts cleaning a dirty pan.\n", dishwasherId);
        numDirty.x--;
        sleep((int) (Math.random() * 100));

        System.out.printf("Dishwasher %d is done cleaning.\n", dishwasherId);
        numClean.x++;
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt numClean = new SharedInt(10);
    SharedInt numDirty = new SharedInt(10);

    System.out.printf("Kitchen opens with %d clean pans and %d dirty pans.\n", numClean.x, numDirty.x);

    ArrayList<Thread> threads = new ArrayList<>();
    for (int i=0; i<10; i++) {
      final int fi = i;
      threads.add(new Thread(() -> usePan(numClean, numDirty, fi))); // "chef" thread
      threads.add(new Thread(() -> cleanPan(numClean, numDirty, fi))); // "dishwasher" thread
    }

    for (Thread t : threads) {
      t.start();
    }

    for (Thread t : threads) {
      t.join();
    }

    System.out.println("Simulation Complete");
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class DeadlockSolution2 {

  public static class SharedInt {
    int x;

    public SharedInt(int init) {
      x = init;
    }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {}
  }

  public static void usePan(SharedInt numClean, SharedInt numDirty, int chefId) {
    synchronized (numClean) {
      System.out.printf("Chef %d grabs a clean pan and starts cooking.\n", chefId);
      numClean.x--;
      sleep((int)(Math.random()*100));

      synchronized (numDirty) {
        System.out.printf("Chef %d is done cooking.\n", chefId);
        numDirty.x++;
      }
    }
  }

  public static void cleanPan(SharedInt numClean, SharedInt numDirty, int dishwasherId) {
    synchronized (numClean) {
      synchronized (numDirty) {
        System.out.printf("Dishwasher %d starts cleaning a dirty pan.\n", dishwasherId);
        numDirty.x--;
        sleep((int) (Math.random() * 100));

        System.out.printf("Dishwasher %d is done cleaning.\n", dishwasherId);
        numClean.x++;
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt numClean = new SharedInt(10);
    SharedInt numDirty = new SharedInt(10);

    System.out.printf("Kitchen opens with %d clean pans and %d dirty pans.\n", numClean.x, numDirty.x);

    ArrayList<Thread> threads = new ArrayList<>();
    for (int i=0; i<10; i++) {
      final int fi = i;
      threads.add(new Thread(() -> usePan(numClean, numDirty, fi))); // "chef" thread
      threads.add(new Thread(() -> cleanPan(numClean, numDirty, fi))); // "dishwasher" thread
    }

    for (Thread t : threads) {
      t.start();
    }

    for (Thread t : threads) {
      t.join();
    }

    System.out.println("Simulation Complete");
  }
}

Smaller critical sections

In this example, the best solution is to remove the nested synchronized blocks altogether. We can separately update numClean within a block synchronized on numClean and update numDirty within a block synchronized on numDirty. Since no thread will ever attempt to hold multiple keys at the same time, a deadlock cannot occur. In addition, having short critical sections (with the long sleep() calls outside of them) allows multiple threads to make progress at the same time, enabling true concurrency.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class DeadlockSolution3 {

  public static class SharedInt {
    int x;

    public SharedInt(int init) {
      x = init;
    }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {}
  }

  public static void usePan(SharedInt numClean, SharedInt numDirty, int chefId) {
    synchronized (numClean) {
      System.out.printf("Chef %d grabs a clean pan and starts cooking.\n", chefId);
      numClean.x--;
    }
    
    sleep((int)(Math.random()*100));

    synchronized (numDirty) {
      System.out.printf("Chef %d is done cooking.\n", chefId);
      numDirty.x++;
    }
  }

  public static void cleanPan(SharedInt numClean, SharedInt numDirty, int dishwasherId) {

    synchronized (numDirty) {
      System.out.printf("Dishwasher %d starts cleaning a dirty pan.\n", dishwasherId);
      numDirty.x--;
    }

    sleep((int)(Math.random()*100));

    synchronized (numClean) {
      System.out.printf("Dishwasher %d is done cleaning.\n", dishwasherId);
      numClean.x++;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt numClean = new SharedInt(10);
    SharedInt numDirty = new SharedInt(10);

    System.out.printf("Kitchen opens with %d clean pans and %d dirty pans.\n", numClean.x, numDirty.x);

    ArrayList<Thread> threads = new ArrayList<>();
    for (int i=0; i<10; i++) {
      final int fi = i;
      threads.add(new Thread(() -> usePan(numClean, numDirty, fi))); // "chef" thread
      threads.add(new Thread(() -> cleanPan(numClean, numDirty, fi))); // "dishwasher" thread
    }

    for (Thread t : threads) {
      t.start();
    }

    for (Thread t : threads) {
      t.join();
    }

    System.out.println("Simulation Complete");
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class DeadlockSolution3 {

  public static class SharedInt {
    int x;

    public SharedInt(int init) {
      x = init;
    }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {}
  }

  public static void usePan(SharedInt numClean, SharedInt numDirty, int chefId) {
    synchronized (numClean) {
      System.out.printf("Chef %d grabs a clean pan and starts cooking.\n", chefId);
      numClean.x--;
    }
    
    sleep((int)(Math.random()*100));

    synchronized (numDirty) {
      System.out.printf("Chef %d is done cooking.\n", chefId);
      numDirty.x++;
    }
  }

  public static void cleanPan(SharedInt numClean, SharedInt numDirty, int dishwasherId) {

    synchronized (numDirty) {
      System.out.printf("Dishwasher %d starts cleaning a dirty pan.\n", dishwasherId);
      numDirty.x--;
    }

    sleep((int)(Math.random()*100));

    synchronized (numClean) {
      System.out.printf("Dishwasher %d is done cleaning.\n", dishwasherId);
      numClean.x++;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt numClean = new SharedInt(10);
    SharedInt numDirty = new SharedInt(10);

    System.out.printf("Kitchen opens with %d clean pans and %d dirty pans.\n", numClean.x, numDirty.x);

    ArrayList<Thread> threads = new ArrayList<>();
    for (int i=0; i<10; i++) {
      final int fi = i;
      threads.add(new Thread(() -> usePan(numClean, numDirty, fi))); // "chef" thread
      threads.add(new Thread(() -> cleanPan(numClean, numDirty, fi))); // "dishwasher" thread
    }

    for (Thread t : threads) {
      t.start();
    }

    for (Thread t : threads) {
      t.join();
    }

    System.out.println("Simulation Complete");
  }
}

Condition Variables

To conclude our discussion of synchronization, we’ll dive a bit deeper into the idea of coordinating work across threads. So far today, we’ve only considered threads whose work was largely independent. While our threads needed to synchronize to control access to their shared state, it did not actually matter in which order they performed their actions. In practice, this is often not the case. One thread may rely on another thread to make some progress before it can continue its work. In this case, we’ll need a way to temporarily suspend the execution of a thread until some desired condition is met. We’ve already seen a few similar behaviors:

Now, we’ll discuss a fourth mechanism. The wait() methods of the Object class, including the variants that accept time durations as parameters, are used to pause the execution on a thread until it is notified that it can resume. This method, in conjunction with the mutexes that we have already introduced, enables us to express richer conditions to synchronize the work across multiple threads.

Writing Code with Condition Variables

To build on our earlier example, let’s again imagine that we have a “chef” thread that dirties pans and a “dishwasher” thread that cleans pans. This time, we’ll have a single thread of each type that will interact with multiple pans over the course of its execution.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class KitchenSimulation {
  public static class SharedInt {
    int x;
    public SharedInt(int init) { x = init; }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {
    }
  }

  public static void simulateChef(SharedInt numClean, SharedInt numDirty) {
    for (int i = 0; i < 3; i++) {
      synchronized (numClean) {
        System.out.println("The chef grabs a clean pan and starts cooking.");
        numClean.x--;
        System.out.printf("There are %d clean pans remaining.\n", numClean.x);
      }
      sleep((int) (Math.random() * 100));
      synchronized (numDirty) {
        System.out.println("The chef is done cooking.");
        numDirty.x++;
        System.out.printf("There are now %d dirty pans.\n", numDirty.x);
      }
      sleep((int) (Math.random() * 100));
    }
  }

  public static void simulateDishwasher(SharedInt numClean, SharedInt numDirty) {
    for (int i = 0; i < 3; i++) {
      synchronized (numDirty) {
        System.out.println("The dishwasher grabs a dirty pan and starts cleaning.");
        numDirty.x--;
        System.out.printf("There are %d dirty pans remaining.\n", numDirty.x);
      }
      sleep((int) (Math.random() * 100));
      synchronized (numClean) {
        System.out.println("The dishwasher is done cleaning.");
        numClean.x++;
        System.out.printf("There are now %d clean pans.\n", numClean.x);
      }
      sleep((int) (Math.random() * 100));
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt numClean = new SharedInt(2);
    SharedInt numDirty = new SharedInt(0);

    System.out.printf("The kitchen opens with %d clean pans and %d dirty pans.\n", numClean.x, numDirty.x);

    Thread chefThread = new Thread(() -> simulateChef(numClean, numDirty));
    Thread dishwasherThread = new Thread(() -> simulateDishwasher(numClean, numDirty));

    chefThread.start();
    dishwasherThread.start();

    chefThread.join();
    dishwasherThread.join();

    System.out.println("Simulation Complete");
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class KitchenSimulation {
  public static class SharedInt {
    int x;
    public SharedInt(int init) { x = init; }
  }

  /** Put thread to sleep for the specified number of `millis`econds. */
  public static void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException ignored) {
    }
  }

  public static void simulateChef(SharedInt numClean, SharedInt numDirty) {
    for (int i = 0; i < 3; i++) {
      synchronized (numClean) {
        System.out.println("The chef grabs a clean pan and starts cooking.");
        numClean.x--;
        System.out.printf("There are %d clean pans remaining.\n", numClean.x);
      }
      sleep((int) (Math.random() * 100));
      synchronized (numDirty) {
        System.out.println("The chef is done cooking.");
        numDirty.x++;
        System.out.printf("There are now %d dirty pans.\n", numDirty.x);
      }
      sleep((int) (Math.random() * 100));
    }
  }

  public static void simulateDishwasher(SharedInt numClean, SharedInt numDirty) {
    for (int i = 0; i < 3; i++) {
      synchronized (numDirty) {
        System.out.println("The dishwasher grabs a dirty pan and starts cleaning.");
        numDirty.x--;
        System.out.printf("There are %d dirty pans remaining.\n", numDirty.x);
      }
      sleep((int) (Math.random() * 100));
      synchronized (numClean) {
        System.out.println("The dishwasher is done cleaning.");
        numClean.x++;
        System.out.printf("There are now %d clean pans.\n", numClean.x);
      }
      sleep((int) (Math.random() * 100));
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SharedInt numClean = new SharedInt(2);
    SharedInt numDirty = new SharedInt(0);

    System.out.printf("The kitchen opens with %d clean pans and %d dirty pans.\n", numClean.x, numDirty.x);

    Thread chefThread = new Thread(() -> simulateChef(numClean, numDirty));
    Thread dishwasherThread = new Thread(() -> simulateDishwasher(numClean, numDirty));

    chefThread.start();
    dishwasherThread.start();

    chefThread.join();
    dishwasherThread.join();

    System.out.println("Simulation Complete");
  }
}

Take a minute to read through this code. What can go wrong in this simulation?

What can go wrong?

Over the course of the execution, the chef needs to use more pans than there are in the kitchen. If they cook at a faster rate than the dishwasher cleans, they may, at some point, reach for a clean pan that isn't there. Similarly, there are no dirty pans at the start of the simulation, so the dishwasher will need to wait for one before they can start their work. As written, there is no mechanism to enforce this waiting, so it's possible that the pan count variables can become negative, a physical impossibility.
The kitchen opens with 2 clean pans and 0 dirty pans.
The chef grabs a clean pan and starts cooking.
The dishwasher grabs a dirty pan and starts cleaning.
There are -1 dirty pans remaining.
There are 1 clean pans remaining.
The chef is done cooking.
There are now 0 dirty pans.
The dishwasher is done cleaning.
There are now 2 clean pans.
The dishwasher grabs a dirty pan and starts cleaning.
There are -1 dirty pans remaining.
The dishwasher is done cleaning.
There are now 3 clean pans.
The chef grabs a clean pan and starts cooking.
There are 2 clean pans remaining.
The dishwasher grabs a dirty pan and starts cleaning.
There are -2 dirty pans remaining.
The dishwasher is done cleaning.
There are now 3 clean pans.
The chef is done cooking.
There are now -1 dirty pans.
The chef grabs a clean pan and starts cooking.
There are 2 clean pans remaining.
The chef is done cooking.
There are now 0 dirty pans.
Simulation Complete

To address this, we’ll need to use condition variables (i.e., Object.wait() calls set up in the format that we’ll describe below). When the “chef” thread enters the block synchronized on numClean, it should first check whether there is a clean pan. If there is, it can proceed as normal. If there is not, however, it must relinquish control of the mutex so another thread (the “dishwasher” thread) can make another clean pan available.

The Object.wait() method serves this purpose. When a thread calls the wait() method on an object while holding the mutex for that object, Java:

  1. Releases the mutex (i.e., takes back the key from the running thread).
  2. Pauses the execution of the thread by placing it in a “wait set”.

Once the mutex is free, another thread that hits a synchronized block can acquire it. To get out of the “wait set”, our paused thread must be notified that the state of the shared resource has changed. To do this, we should make a call to notifyAll(), another Object method, at the end of any synchronized block that modifies that shared variable’s state. This has the effect of moving all paused threads in the “wait set” back in line to re-acquire the mutex and proceed with their execution.

Remark:

You might notice that Object also has a notify() method that has the effect of notifying a single thread in the "wait set" that it can re-acquire the lock. We have no guarantee of which thread gets notified, and this can cause issues if we are not careful. It is safer to always notifyAll(), even if this wakes threads up from the "wait set" unnecessarily.

Let’s put these ideas into practice. We’ll start with the “chef” thread’s method, simulateChef(). When we acquire the numClean lock, we must check whether numClean.x == 0. If it is, there are no clean pans that the chef can use; they must wait for the dishwasher to catch up, so we call numClean.wait() to release the lock.

simulateChef()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for (int i = 0; i < 3; i++) {
  synchronized (numClean) {
    if (numClean.x == 0) {
        numClean.wait();
    }
    System.out.println("The chef grabs a clean pan and starts cooking.");
    numClean.x--;
    System.out.printf("There are %d clean pans remaining.\n", numClean.x);
  }
  // ...
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for (int i = 0; i < 3; i++) {
  synchronized (numClean) {
    if (numClean.x == 0) {
        numClean.wait();
    }
    System.out.println("The chef grabs a clean pan and starts cooking.");
    numClean.x--;
    System.out.printf("There are %d clean pans remaining.\n", numClean.x);
  }
  // ...
}

When the execution of the simulateChef() method resumes after this wait() call, it is because the thread has been notified of a change to numClean. However, we can’t be certain that this change actually left numClean.x > 0; we can imagine the possibility of another “chef” thread on the “wait set” beating us to re-acquire the mutex and dirtying the pan before we have an opportunity to resume our execution. This means that we actually need to re-check the condition.

When setting up a condition variable, always call wait() within a while-loop guarded by the condition. This ensures that the condition will be met as soon as we exit the loop.

simulateChef()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for (int i = 0; i < 3; i++) {
  synchronized (numClean) {
    while (numClean.x == 0) {
        numClean.wait();
    }
    System.out.println("The chef grabs a clean pan and starts cooking.");
    numClean.x--;
    System.out.printf("There are %d clean pans remaining.\n", numClean.x);
  }
  // ...
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for (int i = 0; i < 3; i++) {
  synchronized (numClean) {
    while (numClean.x == 0) {
        numClean.wait();
    }
    System.out.println("The chef grabs a clean pan and starts cooking.");
    numClean.x--;
    System.out.printf("There are %d clean pans remaining.\n", numClean.x);
  }
  // ...
}

In this revised code, we are guaranteed that numClean.x != 0 (in fact, it will be greater than 0) when we exit the while loop, so the decrement operation that follows is “safe”. The start of our simulateDishwasher() method follows a similar pattern:

simulateDishwasher()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for (int i = 0; i < 3; i++) {
  synchronized (numDirty) {
    while (numDirty.x == 0) {
        numDirty.wait();
    }
    System.out.println("The dishwasher grabs a dirty pan and starts cleaning.");
    numDirty.x--;
    System.out.printf("There are %d dirty pans remaining.\n", numDirty.x);
  }
  // ...
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for (int i = 0; i < 3; i++) {
  synchronized (numDirty) {
    while (numDirty.x == 0) {
        numDirty.wait();
    }
    System.out.println("The dishwasher grabs a dirty pan and starts cleaning.");
    numDirty.x--;
    System.out.printf("There are %d dirty pans remaining.\n", numDirty.x);
  }
  // ...
}

We are not done, though. We must be careful to ensure that these threads can return from the “wait set” by notifying them appropriately. In particular, we should call notifyAll() immediately after modifying the value of either SharedInt. In our simulateChef() method, we write:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public static void simulateChef(SharedInt numClean, SharedInt numDirty) throws InterruptedException {
  for (int i = 0; i < 3; i++) {
    synchronized (numClean) {
      while (numClean.x == 0) {
        numClean.wait();
      }
      System.out.println("The chef grabs a clean pan and starts cooking.");
      numClean.x--;
      numClean.notifyAll();
      System.out.printf("There are %d clean pans remaining.\n", numClean.x);
    }
    sleep((int) (Math.random() * 100));
    synchronized (numDirty) {
      System.out.println("The chef is done cooking.");
      numDirty.x++;
      numDirty.notifyAll();
      System.out.printf("There are now %d dirty pans.\n", numDirty.x);
    }
    sleep((int) (Math.random() * 100));
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public static void simulateChef(SharedInt numClean, SharedInt numDirty) throws InterruptedException {
  for (int i = 0; i < 3; i++) {
    synchronized (numClean) {
      while (numClean.x == 0) {
        numClean.wait();
      }
      System.out.println("The chef grabs a clean pan and starts cooking.");
      numClean.x--;
      numClean.notifyAll();
      System.out.printf("There are %d clean pans remaining.\n", numClean.x);
    }
    sleep((int) (Math.random() * 100));
    synchronized (numDirty) {
      System.out.println("The chef is done cooking.");
      numDirty.x++;
      numDirty.notifyAll();
      System.out.printf("There are now %d dirty pans.\n", numDirty.x);
    }
    sleep((int) (Math.random() * 100));
  }
}

This code ensures that the “chef” thread will wake up the “dishwasher” thread when a pan is available for them to clean. Writing similar code in the “dishwasher” thread will ensure that the “chef” thread is woken up whenever they have a clean pan that they can cook in.

The full simulation with proper synchronization is provided with the lecture release code. When we execute this code, we see that it corrects the issue that we identified above.

The kitchen opens with 2 clean pans and 0 dirty pans.
The chef grabs a clean pan and starts cooking.
There are 1 clean pans remaining.
The chef is done cooking.
There are now 1 dirty pans.
The dishwasher grabs a dirty pan and starts cleaning.
There are 0 dirty pans remaining.
The dishwasher is done cleaning.
There are now 2 clean pans.
The chef grabs a clean pan and starts cooking.
There are 1 clean pans remaining.
The chef is done cooking.
There are now 1 dirty pans.
The dishwasher grabs a dirty pan and starts cleaning.
There are 0 dirty pans remaining.
The chef grabs a clean pan and starts cooking.
There are 0 clean pans remaining.
The dishwasher is done cleaning.
There are now 1 clean pans.
The chef is done cooking.
There are now 1 dirty pans.
The dishwasher grabs a dirty pan and starts cleaning.
There are 0 dirty pans remaining.
The dishwasher is done cleaning.
There are now 2 clean pans.
Simulation Complete

As we already saw earlier today, we must exercise good discipline when we are writing concurrent code to ensure that our synchronization is done appropriately. Only once the synchronizing, waiting, and notifying are all done properly will it be guaranteed that our concurrent code will execute correctly, avoiding both deadlock and race conditions.

The Monitor Pattern

In many cases, we might like an entire class that we are writing to be “thread safe”, meaning multiple threads can call methods on objects from the class (both accessors and mutators) without the possibility of race conditions. To achieve this, we can coarsely synchronize all of the public instance methods of the class on their target’s mutex. By doing this, at most one thread can execute the code within the class at a time. If we are ever in a situation where we need another thread to make progress before we can resume execution of the instance method, we can use a condition variable (i.e., call this.wait()). This approach to synchronizing a class is referred to as the monitor pattern.

Definition: Monitor Pattern

In the monitor pattern, the bodies of all of a class's public instance methods are guarded by its object's mutex. This ensures thread safety within the class.

While the monitor pattern guarantees thread safety, it also likely introduces inefficiencies. Locking down the complete state of an object is a very heavy-handed measure. We may instead be able to lock down one or two fields at a time, which can enable multiple threads to simultaneously interact with a class. As is typically the case with synchronization, we must trade off the performance of our code (enabled by fine-grained, sophisticated synchronization) with the overhead of maintainability and careful discipline that are required to carry out synchronization in a safe and principled way.

While this is the end of our discussion of concurrency and synchronization in CS 2110, you’ll go on to discuss it more in later courses such as CS 3410 and CS 4410 since it has grown to play an ever more important role in designing performant modern computer systems.

Main Takeaways:

  • To enter a synchronized block, a thread must obtain the unique mutex belonging to an object. This ensures that it is the only thread running code synchronized by that object.
  • synchronized blocks only work under good programmer discipline. They do not automatically prevent other threads from accessing shared state; they only enforce mutual exclusion among threads that are properly synchronizing.
  • When threads must acquire simultaneous access to multiple resources, the possibility of deadlock arises. Deadlock can be broken by having all threads acquire resources in a consistent order.
  • In a condition variable, a thread voluntarily surrenders access to a mutex to allow another thread to make progress toward some desired condition. This is done with the wait() method, which places the thread in the mutex's "wait set". A notifyAll() call from another thread wakes up the thread and allows it to reacquire the mutex.

Exercises

Exercise 27.1: Check Your Understanding
(a)

Recall Exercise 26.1.c, where we defined two threads that share an instance s of the class Shared, defined as follows:

1
2
3
public class Shared {
  public int x = 2;
}
1
2
3
public class Shared {
  public int x = 2;
}
The initial value of s.x is 2. The threads execute the following statements concurrently:
Thread 1 Thread 2
s.x = s.x + 1;
synchronized (s) {
  s.x = s.x + 2;
}
Later, we examine s in a way that guarantees that any updates made by both threads will be observed. What are the possible values of s.x?
Check Answer
(b)

Consider the Counter class implemented below:

1
2
3
4
5
6
7
8
9
public class Counter {
  private int c;

  /** Increment the counter by 1 and return its new value. */
  public int increment() {
    c += 1;
    return c;
  }
}
1
2
3
4
5
6
7
8
9
public class Counter {
  private int c;

  /** Increment the counter by 1 and return its new value. */
  public int increment() {
    c += 1;
    return c;
  }
}
Can Counter be used safely by multiple threads? If not, how would you make it thread-safe?
Check Answer
(c)

Two processes both need resources F1 and F2 to do their work. Only one process can control a resource at a time. If a process attempts to acquire a resource that is already controlled by another process, it will wait. The instructions followed by the processes are shown below.

Process 1 Process 2
  1. Acquire F1
  2. Use F1
  3. Acquire F2
  4. Use F1 and F2
  5. Release F1
  6. Use F2
  7. Release F2
  1. Acquire F2
  2. Acquire F1
  3. Use F1 and F2
  4. Release F1
  5. Use F2
  6. Release F2
Can a deadlock occur if these two processes are executed concurrently? If so, how would you fix it?
Check Answer
(d)
A thread in Java is considered to block if the thread is paused and cannot make progress. In which situations may a Thread block? Let t be a thread that is accessible in the main thread.
Check Answer
Exercise 27.2: Visualizing Synchronization
Throughout the lecture, we’ve talked about a "wait set". Here, we make that idea precise by introducing a state machine. This state machine lets us visualize the state of any thread that interacts with a given mutex. The boxes represent states that multiple threads may occupy, the circle represents the thread currently holding the lock, and the arrows show the possible transitions between these states.

Suppose we have a kitchen simulation initialized with 2 chef threads, 1 dishwasher thread, 0 clean plates, and 6 dirty plates. For each of the following parts, determine the state of each thread after the specified thread releases the lock. Each subproblem continues from the state produced in the previous one, and if there are multiple possible outcomes, list all valid resulting states.
(a)
The first chef thread obtains the lock.
(b)
The second chef thread obtains the lock.
(c)
The dishwasher thread obtains the lock.
Exercise 27.3: Spin Locks
With synchronization, we were able to make the KitchenSimulation class thread safe by waiting on a specific event to occur. As an alternative without synchronization, a student proposes to use a spin lock. A spin lock can be implemented as a while-loop without a loop body. The student reasons that this would intuitively mimic the idea of an Object.wait() call as it should prevent a method from continuing until a condition is reached. Suppose the simulateChef() is edited to use spin locks.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static void simulateChef(SharedInt numClean, SharedInt numDirty) throws InterruptedException {
  for (int i = 0; i < 3; i++) {
    while (numClean.x == 0) {
      /* Wait until there are clean pans. */
    }
    System.out.println("The chef grabs a clean pan and starts cooking.");
    numClean.x--;
    System.out.printf("There are %d clean pans remaining.\n", numClean.x);
    sleep((int) (Math.random() * 100));
    System.out.println("The chef is done cooking.");
    numDirty.x++;
    System.out.printf("There are now %d dirty pans.\n", numDirty.x);
    sleep((int) (Math.random() * 100));
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static void simulateChef(SharedInt numClean, SharedInt numDirty) throws InterruptedException {
  for (int i = 0; i < 3; i++) {
    while (numClean.x == 0) {
      /* Wait until there are clean pans. */
    }
    System.out.println("The chef grabs a clean pan and starts cooking.");
    numClean.x--;
    System.out.printf("There are %d clean pans remaining.\n", numClean.x);
    sleep((int) (Math.random() * 100));
    System.out.println("The chef is done cooking.");
    numDirty.x++;
    System.out.printf("There are now %d dirty pans.\n", numDirty.x);
    sleep((int) (Math.random() * 100));
  }
}
Explain why this is not an effective alternative.
Exercise 27.4: Synchronization Errors
For each of the following attempts to make a thread-safe class with synchronization, identify the error. If possible, identify an interleaving of instructions that results in a deadlock.
(a)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class Main {
  private static Object lock = new Object();

  public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
      synchronized (lock) {
        System.out.println("hello!");
      }
    });
    t.start();
    synchronized (lock) {
        t.join();
    }
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class Main {
  private static Object lock = new Object();

  public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
      synchronized (lock) {
        System.out.println("hello!");
      }
    });
    t.start();
    synchronized (lock) {
        t.join();
    }
  }
}
(b)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Nest {
  private int eggs = 0;

  public void lay() throws InterruptedException {
    while (eggs >= 5) {
      wait();
    }
    eggs += 3;
  }

  public void collect() throws InterruptedException {
    while (eggs == 0) {
      wait();
    }
    eggs -= 2;
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Nest {
  private int eggs = 0;

  public void lay() throws InterruptedException {
    while (eggs >= 5) {
      wait();
    }
    eggs += 3;
  }

  public void collect() throws InterruptedException {
    while (eggs == 0) {
      wait();
    }
    eggs -= 2;
  }
}
Exercise 27.5: Too Many Cooks in the Kitchen
(a)
Expand the chef and dishwasher simulation as presented in the lecture to multiple chefs. What, if anything, would you have to change to the existing implementation?
(b)
Suppose the kitchen now has a budget for spoons. Chefs now require both a spoon and a pan to cook. Dishwashers can wash any single item (spoon or pan) at a time in any order. Redesign the simulation to add spoons.
Exercise 27.6: Barrier
A barrier is a synchronization primitive to facilitate managing threads by blocking the execution of a thread until a certain number of threads have reached a checkpoint. For instance, suppose we have multiple threads representing TAs grading an exam. A barrier would be used to enforce that all TAs must finish grading their assigned portion of question 1 before any TA can move onto grading question 2.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class Barrier {
  /** 
   * Number of threads that are required to open the barrier, i.e. if 
   * `waitingThreads >= numThreads`, the barrier should be open.
   * Requires `numThreads >= 0`. Guarded by `this`.
   */
  private int numThreads;

  /** 
   * Number of threads currently waiting at this barrier. 
   * Requires `waitingThreads >= 0`. Guarded by `this`.
   */
  private int waitingThreads;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class Barrier {
  /** 
   * Number of threads that are required to open the barrier, i.e. if 
   * `waitingThreads >= numThreads`, the barrier should be open.
   * Requires `numThreads >= 0`. Guarded by `this`.
   */
  private int numThreads;

  /** 
   * Number of threads currently waiting at this barrier. 
   * Requires `waitingThreads >= 0`. Guarded by `this`.
   */
  private int waitingThreads;
}
(a)

Define the constructor of Barrier. Does the constructor need synchronization?

1
2
3
4
5
/**
  * Create a new barrier that is closed until `numThreads` threads have
  * called `await()`.
  */
public Barrier(int numThreads) { ... }
1
2
3
4
5
/**
  * Create a new barrier that is closed until `numThreads` threads have
  * called `await()`.
  */
public Barrier(int numThreads) { ... }
(b)

Implement Barrier.await(). Does this require synchronization?

1
2
3
4
5
/**
  * Block until `numThreads` threads have invoked `await()` on this
  * barrier.
  */
public void await() { ... }
1
2
3
4
5
/**
  * Block until `numThreads` threads have invoked `await()` on this
  * barrier.
  */
public void await() { ... }
(c)
In a main() method, spawn three threads that modify a SharedInt s. Each thread should first add one to s, then use a barrier (with numThreads = 3) to await(), and finally multiply s by 2. What is the expected result of s.x after this method is executed? Without using barriers, what are the possible values of s.x?
Exercise 27.7: Semaphore
A semaphore is another synchronization primitive that authorizes access to only a certain number of threads at any given time. For example, you may manage a parking garage of 100 spots with a semaphore.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/** A counting semaphore. */
public class Semaphore {
  /** Current number of available slots. Requires `slots > 0`. */
  private int slots;

  /** Creates a semaphore with the initial number of slots. Requires `slots > 0`. */
  public Semaphore(int slots) {
    this.slots = slots;
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/** A counting semaphore. */
public class Semaphore {
  /** Current number of available slots. Requires `slots > 0`. */
  private int slots;

  /** Creates a semaphore with the initial number of slots. Requires `slots > 0`. */
  public Semaphore(int slots) {
    this.slots = slots;
  }
}
(a)

Implement Semaphore.acquire().

1
2
/** Acquires a slot, blocking if necessary until one is available. */
public synchronized void acquire() throws InterruptedException { ... }
1
2
/** Acquires a slot, blocking if necessary until one is available. */
public synchronized void acquire() throws InterruptedException { ... }
(b)

Implement Semaphore.release().

1
2
/** Releases a slot. */
public synchronized void release() throws InterruptedException { ... }
1
2
/** Releases a slot. */
public synchronized void release() throws InterruptedException { ... }
(c)
Suppose we create a semaphore with one slot: new Semaphore(1). This is often called a binary semaphore. Does this behave like anything we’ve already studied?
Exercise 27.8: Producer Consumer Problem
A bounded queue is an ADT that fixes the capacity of a queue. A ring buffer is a data structure that implements a bounded queue that is backed by an array and modular arithmetic.
(a)
Create and implement a RingBuffer class that supports enqueue()ing and dequeue()ing elements into this bounded queue. Here we are giving you the liberty to define your class. You may want to consider the following questions. What are the class invariants of your data structure? If a client attempts to add an element to a full queue, should that be handled via preconditions or exceptions?

In a produce-consumer problem, you have multiple threads attempting to add and remove elements from a bounded queue. For instance, consider a fast food restaurant setting where the queue is a rack of fries, consumers are customers, and producers are chefs. Let's define a ProducerConsumer utility class to solve this problem.
1
2
3
4
5
6
7
8
/** A thread-safe class to interact with a bounded queue. */
public class ProducerConsumer<T> {
  private BoundedQueue<T> queue;

  public ProducerConsumer(BoundedQueue<T> queue) {
    this.queue = queue;
  }
}
1
2
3
4
5
6
7
8
/** A thread-safe class to interact with a bounded queue. */
public class ProducerConsumer<T> {
  private BoundedQueue<T> queue;

  public ProducerConsumer(BoundedQueue<T> queue) {
    this.queue = queue;
  }
}
(b)

Implement ProducerConsumer.produce(). This method should block when attempting to add to a full queue.

1
2
/** Adds `elem` to the queue. */
public synchronized void produce(T elem) throws InterruptedException { ... }
1
2
/** Adds `elem` to the queue. */
public synchronized void produce(T elem) throws InterruptedException { ... }
(c)

Implement ProducerConsumer.consume(). This method should block when attempting to remove from an empty queue.

1
2
/** Removes `elem` from the queue. */
public synchronized void consume(T elem) throws InterruptedException { ... }
1
2
/** Removes `elem` from the queue. */
public synchronized void consume(T elem) throws InterruptedException { ... }
(d)
Use a ProducerConsumer to simulate this restaurant setting in a main() method.
(e)
Instead of creating this auxiliary class, we could have just made RingBuffer thread-safe. What are the trade-offs between each option?
Exercise 27.9: Readers Writers Problem
Readers and writers offer another common concurrency problem. Suppose you have some shared resource, such as a text file. At any given time, a file may support an unlimited number of readers or at most one writer. Since readers aren't modifying the file, they can safely read its contents concurrently without needing to lock each other out. However, multiple writers run into the issue of overwriting each other, so we may only allow one writer at once. Design a ReadersWritersLock class to allow thread-safe reads and writes to some text file.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/** A thread-safe utility class to allow multiple readers or one writer. */
public class ReadersWritersLock {
  /** 
   * Number of threads currently reading.
   * Requires `numReading >= 0` and `numReading == 0` if `isWriting == true`.
   */
  private int numReading = 0;

  /**
   * Whether a thread is currently writing.
   * Requires `isWriting == false` if `numReading > 0`.
   */
  private boolean isWriting = false;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/** A thread-safe utility class to allow multiple readers or one writer. */
public class ReadersWritersLock {
  /** 
   * Number of threads currently reading.
   * Requires `numReading >= 0` and `numReading == 0` if `isWriting == true`.
   */
  private int numReading = 0;

  /**
   * Whether a thread is currently writing.
   * Requires `isWriting == false` if `numReading > 0`.
   */
  private boolean isWriting = false;
}
(a)

Implement the locking and unlocking mechanism for readers.

1
2
3
4
5
6
7
8
/** Called by a reader before reading. */
public synchronized void startRead() throws InterruptedException { ... }

/** 
 * Called by a reader after reading. 
 * Requires that `startRead()` was called before by the same thread. 
 */
public synchronized void endRead() throws InterruptedException { ... }
1
2
3
4
5
6
7
8
/** Called by a reader before reading. */
public synchronized void startRead() throws InterruptedException { ... }

/** 
 * Called by a reader after reading. 
 * Requires that `startRead()` was called before by the same thread. 
 */
public synchronized void endRead() throws InterruptedException { ... }
(b)

Implement the locking and unlocking mechanism for writers.

1
2
3
4
5
6
7
8
/** Called by a writer before writing. */
public synchronized void startWrite() throws InterruptedException { ... }

/** 
 * Called by a writer after writing. 
 * Requires that `startWrite()` was called before by the same thread. 
 */
public synchronized void endWrite() throws InterruptedException { ... }
1
2
3
4
5
6
7
8
/** Called by a writer before writing. */
public synchronized void startWrite() throws InterruptedException { ... }

/** 
 * Called by a writer after writing. 
 * Requires that `startWrite()` was called before by the same thread. 
 */
public synchronized void endWrite() throws InterruptedException { ... }
(c)
A thread is starved if it never gets a chance to proceed despite being ready to run. Does your implementation lead to the starvation of any threads?
Exercise 27.10: Traffic Circle
A traffic circle, or roundabout, is a circular road that lets vehicles travel counterclockwise. Our roundabouts will be modeled as 4 segments of road, each of which can contain a car. Cars enter the circle, go around the island, and exit their desired road. We implement a Car as follows:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class Car implements Runnable {
  /** A roundabout with 4 segments. */
  private static Roundabout r = new Roundabout();

  private static Random rand = new Random();

  /** A counting semaphore with 3 slots. */
  private static Semaphore s = new Semaphore(3);

  /** A car's unique identifier. Requires `id` is unique among all instances. */
  private int id;

  /** The starting segment. Requires `start != end`. */
  private int start;

  /** The ending segment. */
  private int end;

  /** 
   * Creates a new car and the route traveled through a roundabout. 
   * Requires `id` is unique among all instances.
   */
  public Car(int id) {
    start = rand.nextInt(4);
    end = (start + 1 + rand.nextInt(3)) % 4;
    this.id = id;
  }

  /**
    * The car begins at its start location, enters the roundabout, travels
    * around until it reaches its end location, then exits.
    */
  @Override
  public void run() {
    try {
      s.acquire();
      int currentPosition = start;
      while (currentPosition != end) {
        r.enterSegment(id, currentPosition);
        currentPosition = (currentPosition + 1) % 4;
      }
      r.remove(id);
      System.out.println();
    } catch (InterruptedException e) { /* ignore */ }

    s.release();
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class Car implements Runnable {
  /** A roundabout with 4 segments. */
  private static Roundabout r = new Roundabout();

  private static Random rand = new Random();

  /** A counting semaphore with 3 slots. */
  private static Semaphore s = new Semaphore(3);

  /** A car's unique identifier. Requires `id` is unique among all instances. */
  private int id;

  /** The starting segment. Requires `start != end`. */
  private int start;

  /** The ending segment. */
  private int end;

  /** 
   * Creates a new car and the route traveled through a roundabout. 
   * Requires `id` is unique among all instances.
   */
  public Car(int id) {
    start = rand.nextInt(4);
    end = (start + 1 + rand.nextInt(3)) % 4;
    this.id = id;
  }

  /**
    * The car begins at its start location, enters the roundabout, travels
    * around until it reaches its end location, then exits.
    */
  @Override
  public void run() {
    try {
      s.acquire();
      int currentPosition = start;
      while (currentPosition != end) {
        r.enterSegment(id, currentPosition);
        currentPosition = (currentPosition + 1) % 4;
      }
      r.remove(id);
      System.out.println();
    } catch (InterruptedException e) { /* ignore */ }

    s.release();
  }
}
(a)
Write a main() method in the Car class that spawns 50 new Threads simulating Cars using a roundabout. Note that Car implements Runnable.
(b)
To prevent race conditions, we use a Semaphore (see Exercise 27.7). Why do we initialize the semaphore with 3 slots if we are simulating a roundabout with 4 segments?
Study this partial implementation of Roundabout, which makes use of bidirectional maps.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/** A thread-safe implementation of a traffic roundabout. */
public class Roundabout {
  /** 
   * Maps each car's ID to the segment it currently occupies. 
   * Must be consistent with `segs2cars`.
   */
  private Map<Integer, Integer> cars2segs;

  /** 
   * Maps each occupied segment to the ID of the car currently in that segment. 
   * Must be consistent with `cars2segs`.
   */
  private Map<Integer, Integer> segs2cars;

  /** Constructs an empty roundabout with no cars and no occupied segments. */
  public Roundabout() {
    cars2segs = new HashMap<>();
    segs2cars = new HashMap<>();
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/** A thread-safe implementation of a traffic roundabout. */
public class Roundabout {
  /** 
   * Maps each car's ID to the segment it currently occupies. 
   * Must be consistent with `segs2cars`.
   */
  private Map<Integer, Integer> cars2segs;

  /** 
   * Maps each occupied segment to the ID of the car currently in that segment. 
   * Must be consistent with `cars2segs`.
   */
  private Map<Integer, Integer> segs2cars;

  /** Constructs an empty roundabout with no cars and no occupied segments. */
  public Roundabout() {
    cars2segs = new HashMap<>();
    segs2cars = new HashMap<>();
  }
}
(c)

Implement enterSegment(). Consider when you should wait() and notifyAll(). Also, ensure that the class invariants are held.

1
2
3
4
5
/**
 * Moves a car into the specified segment of the roundabout, waiting until
 * that segment becomes free.
 */
public synchronized void enterSegment(int carId, int location) throws InterruptedException { ... }
1
2
3
4
5
/**
 * Moves a car into the specified segment of the roundabout, waiting until
 * that segment becomes free.
 */
public synchronized void enterSegment(int carId, int location) throws InterruptedException { ... }
(d)

Implement remove().

1
2
/** Removes a car from the roundabout entirely. */
public synchronized void remove(int carId) throws InterruptedException { ... }
1
2
/** Removes a car from the roundabout entirely. */
public synchronized void remove(int carId) throws InterruptedException { ... }