In a lazy-loaded singleton with double-checked locks and no volatile, I wouldn’t really get two different singletons due to instruction reordering, but I would get “half” singletons.
Volatile, which plays its magic, is arguably the “most frequent keyword” in Java concurrent programming, used to keep memory visible and prevent instruction reordering.
Keep memory visible
Memory Visibility: All threads can see the latest state of the shared Memory.
Failure data
Here is a simple mutable integer class:
public class MutableInteger {
private int value;
public int get(a){
return value;
}
public void set(int value){
this.value = value; }}Copy the code
MutableInteger is not thread-safe because both get and set methods are performed without synchronization. If thread 1 calls the set method, thread 2, which is calling get, may or may not see the updated value.
The solution is simple: declare value as volatile:
private volatile int value;Copy the code
The magic volatile keyword
The magic of the volatile keyword solves the magic of invalid data.
Read and write Java variables
Java interacts with working memory and main memory through several atomic operations:
- Lock: Acts on main memory, marking variables as thread-exclusive.
- Unlock: Activates the main memory to unlock it.
- Read: Uses main memory to transfer the value of a variable from main memory to the thread’s working memory.
- Load: Applies to the working memory, putting the variable values passed by the read operation into a copy of the variable in the working memory.
- Use: uses working memory to pass the value of a variable in the working memory to the execution engine.
- Assign: assigns a value received from the execution engine to a variable in the working memory.
- Store: variable applied to working memory that transfers the value of a variable in working memory to main memory.
- Write: a variable that operates on main memory and places the value of the variable passed from the store operation into the main memory variable.
How does Volatile keep memory visible
The special rule for volatile is:
- The read, load, and use actions must occur consecutively.
- The assign, Store, and write actions must appear consecutively.
Therefore, using volatile variables ensures that:
- Every time
Read before
The latest values must be flushed from main memory first. - Every time
After writing
Must be synchronized back to main memory immediately.
That is, a variable that is decorated with the volatile keyword will always see its latest value. The most recent modification to variable V in thread 1 is visible to thread 2.
Prevents command reordering
In happens-before memory model based on partial order relation, instruction rearrangement technique greatly improves program execution efficiency, but also introduces some problems.
An instruction rearrangement problem – partially initialized objects
Lazy loading of singleton patterns and race conditions
A lazy-loaded singleton pattern is implemented as follows:
class Singleton {
private static Singleton instance;
private Singleton(a){}
public static Singleton getInstance(a) {
if ( instance == null ) { // There is a race condition
instance = new Singleton();
}
returninstance; }}Copy the code
A race condition causes the instance reference to be assigned multiple times, giving the user two different singletons.
DCL and partially initialized objects
To solve this problem, use the synchronized keyword to change the getInstance method to the synchronized method; But such serialized singletons are intolerable. So my ape predecessors designed a DCL (Double Check Lock) mechanism that kept most requests from entering the blocking code block:
class Singleton {
private static Singleton instance;
private Singleton(a){}
public static Singleton getInstance(a) {
if ( instance == null ) { // When instance is not null, it is still possible to point to a partially initialized object
synchronized (Singleton.class) {
if ( instance == null ) {
instance = newSingleton(); }}}returninstance; }}Copy the code
It “looks” perfect: it reduces blocking and avoids race conditions. Yes, but there is actually a problem — when instance is not null, it is still possible to point to a “partially initialized object”.
The problem is this simple line of assignment:
instance = new Singleton();Copy the code
It’s not an atomic operation. In fact, it can be “abstracted” into the following JVM instructions:
memory = allocate(); //1: allocates memory space for the object
initInstance(memory); //2: initializes the object
instance = memory; //3: Sets instance to the newly allocated memory addressCopy the code
Operation 2 above depends on operation 1, but operation 3 does not depend on operation 2, so the JVM can reorder them for “optimization” purposes as follows:
memory = allocate(); //1: allocates memory space for the object
instance = memory; //3: Set instance to the newly allocated memory address (while the object is not initialized)
ctorInstance(memory); //2: initializes the objectCopy the code
As you can see, operation 3 takes precedence over operation 2 after the instruction is rearranged. That is, when an instance reference refers to memory, the new memory has not been initialized — that is, when an instance reference refers to a partially initialized object. At this point, if another thread calls the getInstance method, since instance already refers to a block of memory, the method returns the instance reference if the condition is false, and the user gets “half” of the singleton that was not initialized. To solve this problem, simply declare instance as volatile:
private static volatile Singleton instance;Copy the code
That is, in a lazy-loaded singleton pattern with only DCL and no volatile, there is still a concurrency trap. It’s true that I won’t get two different singletons, but I will get “half” singletons (incomplete initialization). However, many interview books refer to lazy loading singletons that go as far as the DCL without mentioning volatile at all. This “seemingly smart” mechanism has been touted by my peers who have just entered the Java world. When I learned from them in my senior internship interview, I also talked about Double Check from hungry and hungry people. Now it seems really stupid. For an interviewer examining concurrency, the implementation of singleton patterns is a good place to start, looking at design patterns but expecting you to move from design patterns to concurrency and memory models.
How does Volatile prevent instruction reordering
The volatile keyword prevents instructions from being reordered through a “memory barrier.”
To implement the memory semantics of volatile, when the bytecode is generated, the compiler inserts a memory barrier into the instruction sequence to prevent a particular type of handler from reordering. However, it is nearly impossible for the compiler to find an optimal arrangement that minimizes the total number of insertion barriers, so the Java memory model takes a conservative approach.
Here is the JMM memory barrier insertion strategy based on the conservative policy:
- Insert a StoreStore barrier before each volatile write.
- Insert a StoreLoad barrier after each volatile write.
- Insert a LoadLoad barrier after each volatile read.
- Insert a LoadStore barrier after each volatile read.
The advanced
In answering the above questions, I forgot to explain a very confusing question:
If this reordering problem exists, isn’t it possible to have the same problem inside a synchronized block?
This is the case:
class Singleton {...if ( instance == null ) { // An unexpected command rearrangement may occur
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
System.out.println(instance.toString()); // Where procedural order rules come into play}}}... }Copy the code
Can instance not be initialized when the instance.toString() method is called?
First, take comfort in the fact that synchronized blocks, while reordering internally, do not cause thread-safety problems within the block.
Happens-before Memory model and program order rules
Program order rule: If operation A in the program precedes operation B, operation A in the thread will precede operation B.
As mentioned earlier, such instruction reordering problems only occur in the happens-before memory model. The happens-before memory model maintains several happens-before rules, the most basic of the procedural order rules. The target object of the program order rule is two operations A and B in A program code, which ensures that the instruction rearrangement here will not destroy the sequence of operations A and B in the code, but has nothing to do with the sequence in different codes or even different threads.
Thus, instance = new Singleton() will still be reordered inside a synchronized block, but any reordered instructions are still guaranteed to be executed before inst.tostring (). Further, in a single thread, if (instance == null) guarantees execution before synchronized blocks; However, in multithreading, there is no partial order relationship between if (instance == NULL) in thread 1 and synchronized code block in thread 2. Therefore, instruction rearrangement inside synchronized code block in thread 2 is not expected for thread 1, leading to the concurrency trap here.
Similar happens-before rules include the volatile variable rule, the monitor lock rule, and so on. Piggyback can use existing happens-before rules to keep memory visible and prevent instruction rearrangements.
Pay attention to the point
This is a brief introduction to the use of volatile. However, one of the most common problems with using volatile is that:
Mistaking volatile variables for atomic ones.
The main reason for this misunderstanding is that the volatile keyword makes reading and writing of variables “atomic.” However, this atomicity is limited to reading and writing variables (including references), and cannot cover any operations on variables, namely:
- The increment of the base type (e.g
count++
) and other operations are not atomic. - Any non-atomic member of the object called (including
Member variables
andMembers of the method
) Not atomic.
If you want the above operations to be atomic, you have to do more with locks and atomic variables.
conclusion
In summary, the principle that volatile preserves memory visibility and prevents instruction reordering is essentially the same problem, and both are resolved by memory barriers. See the JVM books for more information.
Reference:
- Java concurrency: Volatile memory visibility and instruction rearrangement
This article is published under the Creative Commons Attribution – Share alike 4.0 International License. The attribution and link to this article must be reserved.