Beauty of Concurrent Programming - Analysis of Notification and Waiting Principles (wait, notify, notifyAll)

Keywords: Programming Java jvm

The producer-consumer model is a classic case for us to learn multithreaded knowledge. A typical producer-consumer model is as follows:

 public void produce() {
        synchronized (this) {
            while (mBuf.isFull()) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            mBuf.add();
            notifyAll();
        }
    }

    public void consume() {
        synchronized (this) {
            while (mBuf.isEmpty()) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            mBuf.remove();
            notifyAll();
        }

    }

This code easily raises two questions:

One is why the wait() method is outside the while loop rather than if judgment, and the other is why the notifyAll() method is used at the end, is notify() OK?

When answering the second question, many people take it for granted that notify() wakes up a thread and notifyAll() wakes up all threads, but what about waking up?

Whether it's notify() or notifyAll(), only one thread will eventually get the lock, so what's the difference?

In fact, this is a scheduling problem for internal locks of objects. To answer these two questions, first of all, we need to understand the model of object locks in java. JVM maintains two collections for an object using internal locks (synchronized), Entry Set and Wait Set, and others translate them into lock pools and wait pools, meaning basically the same.

For Entry Set: If thread A already holds an object lock, it can only enter the Entry Set and be in the BLOCKED state of the thread if other threads want to acquire the object lock at this time.

For Wait Set: If thread A calls the wait() method, thread A releases the lock on the object, enters the Wait Set, and is in the WAITING state of the thread.

Also note that there are two prerequisites for a thread B to acquire an object lock:

The first is that the object lock has been released (e.g., the previous thread A that held the lock executed the synchronized code block or called the wait() method, etc.);

Second, thread B is already in the RUNNABLE state.

So under what conditions can threads in both collections become RUNNABLE?

For threads in an Entry Set: When an object lock is released, the JVM wakes up a thread in an Entry Set whose state changes from BLOCKED to RUNNABLE.

For threads in a Wait Set: When the object's notify() method is called, the JVM wakes up a thread in the Wait Set, and the state of the thread changes from WAITING to RUNNABLE.

Or when the notifyAll() method is called, all threads in the Wait Set will be turned into RUNNABLE state.All wakened threads in the Wait Set are transferred to the Entry Set.

Then, whenever an object's lock is released, all the threads in the RUNNABLE state compete to acquire the object's lock, and there will eventually be one (which depends on the JVM implementation, the first in the queue?Random one?)The lock on the object is actually acquired, and other threads that failed to compete continue to wait for the next chance in the Entry Set.

With these points of knowledge as the basis, the above two questions can be explained clearly.

First, let's look at the first issue, when we call the wait() method, we're sure it's because the current method doesn't meet the criteria we specified, so the thread executing this method needs to wait until the other thread changes the criteria and notifies us.

So why put the wait() method in a loop instead of an if judgment?

The answer is obvious, because the wait() thread never knows where other threads will be notify(), so it must make a second judgment when it is awakened, preempted, locked, and exited from the wait() method to decide whether it satisfies the condition to execute down or wait() again.

Just like in this example, if there is only one producer thread and one consumer thread, you can actually replace while with if, because the behavior of thread scheduling is predictable by the developer, the producer thread can only be awakened by the consumer thread, and vice versa, so the conditions are always met when awakened and the program will not fail.However, this situation is very simple in multi-threaded situation, more commonly in multi-threaded production and multi-threaded consumption, then it is very likely that the producer is awakened by another producer or the consumer is awakened by another consumer, in which case if will inevitably be similar to the situation of over-production or over-consumption, such as IndexOutOfBounAn exception to dsException.

So all java books suggest that developers always put wait() inside a loop statement.

Then let's look at the second question, since both notify() and notifyAll() end up with only one thread getting the lock, what's the difference between waking one and waking multiple?

Take the time to look at this scenario of two producers and two consumers, if we use notify() instead of notifyAll() in our code.

Assuming consumer thread 1 gets the lock and judges that buffer is empty, wait() releases the lock;

Consumer 2 then gets the lock, and the buffer is also empty, wait(), which means there are two threads in the Wait Set at this time;

Then producer 1 gets the lock, produces, buffers full, notify(), then consumer 1 may be awakened, but there is another thread producer 2 in the Entry Set waiting for the lock and eventually grabbing the lock, but because the buffer is full at this time, it has to wait();

Then consumer 1 gets the lock, consumer, notify(); then there is a problem, when producer 2 and consumer 2 are in the Wait Set, buffer is empty, if wake up producer 2, nothing is wrong; but if wake up consumer 2, because buffer is empty, it will wait() again, which is embarrassing, in case producer 1 has quit production and no other threads are competing for the lock.The legendary deadlock happens when producer 2 and consumer 2 wait for each other in the Wait Set.

But if you replace notify() with notifyAll() in the example above, this won't happen again, because each time notifyAll() causes other waiting threads to enter the Entry Set from the Wait Set, giving them the opportunity to acquire locks.

So much said, one sentence explains why we should try to use notifyAll() because notify() can easily lead to deadlocks.Of course, notifyAll is not necessarily all good. After all, waking up all the threads in a Wait Set at once can be a huge overhead. If you can handle your thread scheduling, notify() is also good.

Flowchart analysis

Complete Code

import java.util.ArrayList;
import java.util.List;

public class Something {
    private Buffer mBuf = new Buffer();

    public void produce() {
        synchronized (this) {
            while (mBuf.isFull()) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            mBuf.add();
            notifyAll();
        }
    }

    public void consume() {
        synchronized (this) {
            while (mBuf.isEmpty()) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            mBuf.remove();
            notifyAll();
        }
    }

    private class Buffer {
        private static final int MAX_CAPACITY = 1;
        private List innerList = new ArrayList<>(MAX_CAPACITY);

        void add() {
            if (isFull()) {
                throw new IndexOutOfBoundsException();
            } else {
                innerList.add(new Object());
            }
            System.out.println(Thread.currentThread().toString() + " add");

        }

        void remove() {
            if (isEmpty()) {
                throw new IndexOutOfBoundsException();
            } else {
                innerList.remove(MAX_CAPACITY - 1);
            }
            System.out.println(Thread.currentThread().toString() + " remove");
        }

        boolean isEmpty() {
            return innerList.isEmpty();
        }

        boolean isFull() {
            return innerList.size() == MAX_CAPACITY;
        }
    }

    public static void main(String[] args) {
        Something sth = new Something();
        Runnable runProduce = new Runnable() {
            int count = 4;

            @Override
            public void run() {
                while (count-- > 0) {
                    sth.produce();
                }
            }
        };
        Runnable runConsume = new Runnable() {
            int count = 4;

            @Override
            public void run() {
                while (count-- > 0) {
                    sth.consume();
                }
            }
        };
        for (int i = 0; i < 2; i++) {
            new Thread(runConsume).start();
        }
        for (int i = 0; i < 2; i++) {
            new Thread(runProduce).start();
        }
    }
}

The chestnuts above are used in the right way and the output is as follows

Thread[Thread-2,5,main] add
Thread[Thread-1,5,main] remove
Thread[Thread-3,5,main] add
Thread[Thread-0,5,main] remove
Thread[Thread-3,5,main] add
Thread[Thread-0,5,main] remove
Thread[Thread-2,5,main] add
Thread[Thread-1,5,main] remove

Process finished with exit code 0

If you change while to if, the result is as follows, the program may produce runtime exceptions:

Thread[Thread-2,5,main] add
Thread[Thread-1,5,main] remove
Thread[Thread-3,5,main] add
Thread[Thread-1,5,main] remove
Thread[Thread-3,5,main] add
Thread[Thread-1,5,main] remove
Exception in thread "Thread-0" Exception in thread "Thread-2" java.lang.IndexOutOfBoundsException
    at Something$Buffer.add(Something.java:42)
    at Something.produce(Something.java:16)
    at Something$1.run(Something.java:76)
    at java.lang.Thread.run(Thread.java:748)
java.lang.IndexOutOfBoundsException
    at Something$Buffer.remove(Something.java:52)
    at Something.consume(Something.java:30)
    at Something$2.run(Something.java:86)
    at java.lang.Thread.run(Thread.java:748)

Process finished with exit code 0

    If you change notifyAll to notify, the result is as follows: Deadlock, the program did not exit normally:

    Thread[Thread-2,5,main] add
    Thread[Thread-0,5,main] remove
    Thread[Thread-3,5,main] add

    Posted by alan543 on Mon, 03 Jun 2019 18:55:09 -0700