Detailed Explanation of Java Double Check Lock Singleton Mode

  • 2021-07-09 08:21:22
  • OfStack

What is DCL

DCL (Double-checked locking) is designed to support delayed loading, when an object is not instantiated until it is really needed:


class SomeClass {
 private Resource resource = null;
 public Resource getResource() {
 if (resource == null)
  resource = new Resource();
 return resource;
 }
}

Why do you need to delay initialization? It is possible that creating an object is an expensive operation, and sometimes it may not be called at all in a known run, in which case creating an unwanted object can be avoided. Delayed initialization can make the program start faster. But in a multithreaded environment, it may be initialized twice, so you need to declare the getResource () method as synchronized. Unfortunately, the synchronized method is about 100 times slower than the non-synchronized method. The original intention of delaying initialization is to improve efficiency. However, after adding synchronized, the startup speed is improved, but the execution speed is greatly reduced, which does not seem to be a good deal. DCL looks the best:


class SomeClass {
 private Resource resource = null;
 public Resource getResource() {
 if (resource == null) {
  synchronized(this) {
  if (resource == null) 
   resource = new Resource();
  }
 }
 return resource;
 }
}

Initialization is delayed and race conditions are avoided. It seems like a clever optimization-but it doesn't guarantee it will work properly. In order to improve the performance of computer system, compilers, processors and caches will reorder program instructions and data, while object initialization operation is not an atomic operation (it may be reordered); Therefore, it is possible that one thread is in the process of constructing an object, and another thread checks to see that the reference of resource is non-null. Object is unsecurely published (escaped).

According to the memory model of Java, the semantics of synchronized is not only mutual exclusion on the same signal (mutex), but also the synchronization of data interaction between threads and main memory, which ensures a predictable uniformity view of memory under multiprocessor and multithread. Acquiring or releasing a lock triggers a memory barrier (memory barrier)--forcing thread local memory and main memory to synchronize. When a thread exits an synchronized block, a write barrier (write barrier) is triggered-all variable values modified in this synchronization block must be flushed to main memory before releasing the lock; Similarly, when entering an synchronized block, the read barrier (read barrier) is triggered once-the local memory is disabled, and the values of all variables to be referenced in this synchronization block must be retrieved from main memory. Proper use of synchronization ensures that one thread can see the results of the other in a predictable way, and the operation of the synchronization block by the thread is as if it were atomic. The meaning of "correct use" is that it must be synchronized on the same lock.

How did DCL fail

After understanding JMM, let's take a look at how DCL failed. DCL relies on an asynchronous resource field, which looks harmless, but it is not. Suppose the thread A enters synchronized block and is executing resource = new Resource (); The thread B enters getResource (). Considering the impact of object initialization on memory: allocate memory for new objects; Call the construction method to initialize the member variables of the object; Assign a reference to the newly created object to the resource field of SomeClass. Thread B, however, does not enter synchronized block, but may see the above memory operations in a different order than thread A performs. What B might see is the following sequence (instruction reordering): allocating memory, assigning object references to resource fields of SomeClass, and calling constructors. When the memory has been allocated and the A thread sets the value of resource field of SomeClass, the thread B enters the check and finds that resource is not null, skipping synchronized block and returning an object that has not been constructed! Obviously, the result is neither expected nor desired.

The following code is an enhanced version of an attempt to fix DCL, but unfortunately it still doesn't work properly.


// (Still) Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo {
 private Helper helper = null;
 public Helper getHelper() {
 if (helper == null) {
  Helper h;
  synchronized (this) {
  h = helper;
  if (h == null)
   synchronized (this) {
   h = new Helper();
   } // release inner synchronization lock
  helper = h;
  }
 }
 return helper;
 }
 // other functions and members...
}

This code puts the construction of Helper object in an internal synchronization block, and uses a local variable h to receive the reference after initialization first. Intuition is that when this internal synchronization block exits, it should trigger a memory barrier, which can prevent reordering the two operations of initializing Helper object and assigning value to helper field of Foo. Unfortunately, the intuition is completely wrong and the synchronization rules are not understood correctly. For the monitorexit rule (that is, release synchronization), the monitor must perform the pre-monitorexit action before it is released. However, it is not specified that the operation after monitorexit cannot be performed before the monitor is released. The compiler puts the assignment statement helper = h; It is perfectly legal to move to the internal sync block, in which case we go back to the past. Many processors provide instructions for executing this one-way memory barrier. Changing semantics requires releasing locks to be a complete memory barrier, which will have performance loss. However, even if there is a complete memory barrier at initialization, there is no guarantee. On some systems, it is necessary to ensure that threads can see that the value of the property field of helper is non-null. Because a processor has its own local cache copy, some processors read the old value of the local cache copy before executing the cache 1 instruction, even if other processors use the memory barrier to force the latest value to be written to main memory.

There are three sources of reordering (reorder): compiler, processor and memory system. Java, which promises "write-once, run-anywhere concurrent applications in Java", accepts processor and memory system reordering for optimization, so there is no perfect solution for DCL singleton mode, so programming under multi-threading should be extremely careful. The following discusses the implementation of the singleton pattern in a multithreaded environment.

Implementation of Single Instance in Multithreaded Environment

First, synchronization method (synchronized)

Advantages: It can work normally in all cases and delay initialization;

Disadvantages: Synchronization seriously consumes performance, because synchronization is only needed on the first instantiation.

Not recommended. In most cases, there is no need to delay initialization, so it is better to use urgent instantiation (eager initialization)


// Correct multithreaded version
class Foo {
 private Helper helper = null;
 public synchronized Helper getHelper() {
 if (helper == null)
  helper = new Helper();
 return helper;
 }
 // other functions and members...
}

Second, use IODH (Initialization On Demand Holder)

Using static block to initialize, define a private static class to initialize as follows, or initialize directly in static block code, which can ensure that the object is invisible to all threads before being correctly constructed.


class Foo {
 private static class HelperSingleton {
 public static Helper singleton = new Helper();
 }
 public Helper getHelper() {
 return HelperSingleton.singleton;
 }
 // other functions and members...
}

Third, urgent instantiation (eager initialization)


class Foo {
 public static final Helper singleton = new Helper();
 // other functions and members...
}
class Foo {
 private static final Helper singleton = new Helper();
 public Helper getHelper() {
 return singleton;
 }
 // other functions and members...
}

Fourth, enumerate singletons


public enum SingletonClass {
 INSTANCE;
 // other functions...
}

The above four methods can ensure normal operation in all cases

The fifth type is only valid for 32-bit basic type values

Vulnerability: Invalid for 64-bit long and double and reference objects because assignment operations for 64-bit primitive types are not atomic. The utilization scenarios are limited.


// Lazy initialization 32-bit primitives
// Thread-safe if computeHashCode is idempotent
class Foo {
 private int cachedHashCode = 0;
 public int hashCode() {
 int h = cachedHashCode;
 if (h == 0) {
  h = computeHashCode();
  cachedHashCode = h;
 }
 return h;
 }
 // other functions and members...
}

Sixth, DCL plus volatile semantics

The old memory model (prior to the release of JDK 1.5) expires and can only be used after JDK 1.5.

In addition, the secondary method is not recommended. Every time the thread of the multi-core processor writes the volatile field, the working memory will be refreshed to the main memory in time, and the data will be obtained from the main memory every time it is read. Because it is necessary to exchange data with the main memory, the frequent reading and writing of volatile will occupy the data bus resources.


// Works with acquire/release semantics for volatile
// Broken under current semantics for volatile
class Foo {
 private volatile Helper helper = null;
 public Helper getHelper() {
 Helper h = helper;
 if (helper == null) {// First check (no locking)
  synchronized (this) {
  h = helper;
  if (helper == null)
   helper = h = new Helper();
  }
 }
 return helper;
 }
}

Type 7, singletons of immutable objects

The immutable object (immutable object) itself is thread-safe, does not require synchronization, and is simplest to implement in a singleton. For example, if Helper is an immutable type, just modify the singleton field with final:


class Foo {
 private final Helper singleton = new Helper();
 public Helper getHelper() {
 return singleton;
 }
 // other functions and members...
}

Vulnerability: The old memory model (prior to JDK 1.5 release) failed and can only be used after JDK 1.5 because the new memory model enhances the semantics of final and volatile. There is also a problem is clear what is immutable object, if the meaning of immutable object is uncertain, please do not use, in addition, the current immutable object can not guarantee that this kind of 1 straight is immutable object in the future (code is always constantly modified), use with caution!

When you need to use singletons, use delayed initialization with caution, and give priority to urgent instantiation (simple, elegant and error-free)

Summarize


Related articles: