Effective Java: Prefer Concurrency Utilities Over wait and notify

Kyle Carter - Nov 19 '21 - - Dev Community

At the core of each Object in the Java language there are three methods, wait, notify, and notifyAll. These methods allow you low-level concurrency control options. Up until Java 5, this was the go-to option for facilitating concurrency control. However, since the release of Java 5 (in 2004) there are now higher-level tools that can be used that are much easier and less error-prone. This being the case, in new code, we should be using exclusively these provided concurrency utilities.

The java.util.concurrent provides three different kinds of utilities. The first is the Executor framework discussed in a previous item, concurrent collections implementations, and synchronizers.

The concurrent collections provided by the Java language are high-performance concurrent implementation of the common collection interfaces (List, Queue, and `Map). These collections bring their own concurrency controls and thus don't need (and shouldn't be) externally synchronized. At times these collections provide lock-free implementations of functions and the fact that the user of the collection doesn't need to know how the implementation works is great.

Because these concurrent collections sometimes need to do multiple actions (because they are state-dependent) atomically they provide functions to facilitate this. These methods can be extremely useful so in Java 8 many of these functions were provided on the main Collection interfaces. One example of this is putIfAbsent(key, value). We could use this functionality to develop a Strin.intern implementation.

`
private static final ConcurrentMap map = new ConcurrentHashMap<>();

public static String intern(String s) {
String previousValue = map.putIfAbsent(s, s);
return previousValue == null ? s : previousValue;
}
`

This is pretty efficient and concise but we can make it even more performant but using the knowledge that get operations are optimized in ConcurrentHashMap and write the following:


public static String intern(String s) {
String result = map.get(s);
if (result == null) {
result = map.putIfAbsent(s, s);
if (result == null) {
result = s;
}
}
return result;
}

This new implementation is actually faster than the built-in intern function (although it doesn't take into account some of the memory management jobs that the built-in intern function must perform).

These built-in concurrent capable collections effectively obsolete the built-in synchronizing collection operations (Collections.synchronizedMap etc). Simply replacing one of these usages with its concurrent counterpart is likely a great performance benefit. These concurrent collections are even used internally by the concurrent package to facilitate its work like the BlockingQueue's usage in ThreadPoolExecutor as discussed in the previous item.

Another type of concurrent utility provided by the java.util.concurrent package is synchronizers. These are objects that allow one thread to wait on another thread. Although this is a fairly basic concept it can be used powerfully. The most common synchronizers are the CountdownLatch and the Semaphore but there are also more advanced synchronizers like the CyclicBarrier, Exchanger, and Phaser.

Let's look a little more into CountdownLatch. This class serves as a single-use barrier that allows threads to wait on one another before proceeding. The class takes an int in its constructor of the number of times its countdown method must be invoked before it unblocks the threads waiting on it. Using this class let's build a simple timer function that has all threads initialize, wait until all are ready, start processing, and then stop and determine how long the process took.

`
public static long time(Executor executor, int concurrency, Runnable action) {
CountDownLatch ready = new CountDownLatch(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);

for(int i=0; i executor.execute(() -> {
ready.countDown();
try {
start.await();
action.run()
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countdown();
}
});
}

ready.await();
long startNanos = System.nanoTime();
start.countDown();
done.await();
return System.nanoTime() - startNanos();
}
`

In this example we use three different CountdownLatchs which can get a little muddy but it does keep things pretty separated. We have the ready latch that each thread checks in with and the main thread is waiting on. Once all threads check in the main thread starts the timer and triggers the start latch which all the worker threads have been waiting on. All the worker threads do their work and then check-in when finished via the done latch. The main thread is waiting on the done latch to open and then marks the finished processing time.

Let's consider a few other things about this example. What would happen if we passed in a concurrency count that didn't match the number of threads? If it was too few we would end up prematurely starting our time and finishing our timer. If it was higher than our thread count then we would be deadlocked in what is known as a thread starvation deadlock. You will also notice that we catch the InterruptedException. By convention whenever this exception type is caught we should call Thread.currentThread().interrupt() to signal to the owner of the thread that the thread has been interrupted and allow it to handle that in whatever way seems fit. Finally, you will notice the usage of System.nanoTime() vs something like System.currentTimeMillis(). This is because it is more accurate and because it is unaffected by the system's real-time clock. It is also of note that, unless the Runnable represents a significant amount of work, this function won't return very interesting work. This is because even System.nanonTime() is not accurate enough for microbenchmarking. It is for this reason that tools like JMH exist for this specific purpose.

This only begins to cover the utilities provided by the concurrent utilities built into the core language. Feel free to dig deeper into these utilities.

Even though there are better methods out there than using wait and notify directly we may need to maintain code that does use these functions. The wait method is, as the name suggests, used to make a thread wait for some condition. It must be invoked in a synchronized region that locks the on the method it is invoked. The colloquial usage looks like this:


synchronized(obj) {
while(<condition does not hold) {
obj.wait();
}
// perform action now that condition holds.
}

Things of note, we need to always call wait within a loop checking that the condition we are waiting for is true. If the condition we are waiting for is true and the notify or notifyAll method is called before the wait method is called there is no guarantee the thread will ever wake up. By putting the check in a loop we ensure safety. If the thread moves past the wait before the condition holds we lose the protection of our invariant. There are several ways that a thread can be woken up when the condition it is waiting for is not true.

  • Another thread could have also been notified and taken the lock.
  • Another thread could have invoked notify incorrectly.
  • The invoking thread could have triggered notify too early before it was actually ready.
  • In rare circumstances, a waiting thread can be woken up even without a notify call.

One topic that comes up when discussing wait and notify is whether to use notify or notifyAll. As a reminder notify wakes up one waiting thread and notifyAll wakes up all waiting threads. Waking up all waiting threads is a safe, conservative choice. It will always guarantee you will wake up all threads that need to be awakened. You actually may be waking up more threads than you need to, but if you are properly checking on condition before proceeding after waiting, these additional threads that were woken up will simply go back to waiting. Using only notify could lead to a bit of an optimization but in the long run, it's not likely worth it.

Simply put, wait and notify rarely need to be used in new code. By using modern concurrent utilities we can have much simpler code, safer code, and likely more performant. If we do find ourselves maintaining code that does use wait and notify we should be careful we are using the functionality correctly. Always check before proceeding, loop if the condition isn't met, and prefer notifyAll over notify.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .