When looking at the source code of Nacos, I found that the mechanism of “double-checked locking” is used in many places, which is a very good practice case. This article uses a case study to examine the use and benefits of double-checked locking in order to take your code to a higher level.

At the same time, the evolution of double-checked locking is explained based on the singleton pattern.

Double-checked locks in Nacos

In the InstancesChangeNotifier class of Nacos, there is a method like this:

private final Map<String, ConcurrentHashSet<EventListener>> listenerMap = new ConcurrentHashMap<String, ConcurrentHashSet<EventListener>>();

private final Object lock = new Object();

public void registerListener(String groupName, String serviceName, String clusters, EventListener listener) {
    String key = ServiceInfo.getKey(NamingUtils.getGroupedName(serviceName, groupName), clusters);
    ConcurrentHashSet<EventListener> eventListeners = listenerMap.get(key);
    if (eventListeners == null) {
        synchronized (lock) {
            eventListeners = listenerMap.get(key);
            if (eventListeners == null) {
                eventListeners = new ConcurrentHashSet<EventListener>();
                listenerMap.put(key, eventListeners);
            }
        }
    }
    eventListeners.add(listener);
}
Copy the code

The main function of this method is to register listener events. The registered events are stored in the member variable listenerMap. The data structure of listenerMap is a Map whose key is String and value is ConcurrentHashSet. In other words, one key for one set.

For this data structure, in the case of multithreading, Nacos processing process is as follows:

  • Get value from key;
  • Check whether value is null;
  • If the value is not null, the value is added to the Set.
  • If null, a ConcurrentHashSet needs to be created. In multithreading, it is possible to create more than one, so lock is used.
  • Lock an Object with synchronized.
  • Get the value in the lock again, and if it is still null, create it.
  • Perform the following operations.

The above procedure is called “double-checked locking” because it makes two judgments before and after locking. The purpose of using locks is to avoid creating multiple ConcurrenthashSets.

The example in Nacos is a little more complex, and here is the evolution of double-checked locks in singleton mode.

An unlocked singleton

Here’s a direct demonstration of the slacker implementation of the singleton pattern:

public class Singleton { private static Singleton instance; private Singleton() { } public Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }}Copy the code

This is the simplest singleton pattern that works well with a single thread. However, the obvious problem with multithreading is that multiple instances can be created.

Take two threads for example:

As you can see, when two threads are executing simultaneously, it is possible to create multiple instances, which clearly does not qualify for singletons.

Lock the singleton

In view of the above code problems, it is intuitive to think of the locking process. The implementation code is as follows:

public class Singleton { private static Singleton instance; private Singleton() { } public synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }}Copy the code

The only difference from the first example is the addition of the synchronized keyword to the method. At this point, when multiple threads enter the method, the lock needs to be acquired before it can be executed.

Adding the synchronized keyword to the method seems like a perfect solution to the multithreading problem, but it brings with it a performance problem.

We know that there is an additional performance overhead associated with using locks. For the singleton pattern above, locks are required only for the first creation (to prevent multiple instances being created), but not for queries.

If a lock is applied to a method, the performance cost of the lock is incurred with each query.

Double-checked lock

For the above problem, there is a double-checked lock, as shown in the following example:

public class Singleton { private static Singleton instance; private Singleton() { } public Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; }}Copy the code

First, narrow the scope of the lock within the method;

Second, check whether the lock is null before creating it. If it is not null, the lock has been instantiated and is returned directly.

Third, if it is null, lock it and check whether it is null again. Why judge again? After one thread determines null, the other thread may have created the object, so after locking, you need to check again, really null, then create the object.

The improvements not only ensure thread security, but also avoid the performance penalty caused by locking. Is that the end of the question? No, keep reading.

JVM instruction reorder

In some JVMS, the compiler rearranges instructions for performance reasons. In the above code, new Singleton() is not an atomic operation and may be rearranged by the compiler.

Creating an object can be abstracted into three steps:

memory = allocate(); //1: allocate the object's memory space ctorInstance(memory); //2: initialize object instance = memory; //3: set instance to the newly allocated memory addressCopy the code

In the above operations, Operation 2 depends on operation 1, but operation 3 does not depend on operation 2. Therefore, the JVM can be optimized for instruction reordering, which may occur in the following order of execution:

memory = allocate(); //1: allocate the memory space for the object instance = memory; //3: instance refers to the newly allocated memory address, and the object has not initialized ctorInstance(memory); //2: initializes the objectCopy the code

After the instructions are rearranged, the assignment operation for operation 3 is placed first, and A problem arises when thread A completes the step assignment operation but has not yet performed the object initialization. At this point, thread B comes in, finds that Instance already has a value (actually uninitialized), and returns the corresponding value. Then, when the program uses this uninitialized value, an error occurs.

To address this problem, you can add the volatile keyword to instance so that the memory barrier is inserted before and after the instance read and write operations to avoid reordering.

Finally, the singleton pattern is implemented as follows:

public class Singleton { private static volatile Singleton instance; private Singleton() { } public Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; }}Copy the code

At this point, a complete singleton pattern is implemented. At this point, are you wondering why double-checked locks in Nacos don’t use the volatile keyword?

The answer is simple: the singleton pattern above causes singleton instances to be used if instructions are rearranged. So, looking at the Nacos code, since creating ConcurrentHashSet does not affect the query, it is the listenerMap.put method that affects the query, and ConcurrentHashSet itself is thread-safe. Therefore, there are no thread-safety issues and no need to use the volatile keyword.

summary

One of the most interesting places to read the source code is to see a lot of classical knowledge in practice, if you can think deeply, expand, will get unexpected harvest.

To review the main points of this article:

  • Read the Nacos source code to discover the use of double-checked locks;
  • Using singleton mode without lock, multiple objects are created.
  • Method is locked, resulting in performance degradation;
  • Local code locking, double judgment, both to meet the thread safety, and to meet the performance requirements;
  • Special case of singleton mode: the creation of an object is divided into multiple steps, which may cause instruction rearrangement. Volatile is used to avoid instruction rearrangement.

Finally, if you want to learn more about dry goods like this, watch out for continuous output.

Author of SpringBoot Tech Insider, loves to delve into technology and write about it.

Official account: “Program New Horizon”, the official account of the blogger, welcome to follow ~

Technical exchange: please contact the blogger wechat id: Zhuan2quan