[Java Study Notes - Concurrent Programming] Keyword volatile Detailed Explanation

foreword

This article introduces the keyword volatile of Java concurrent programming and its related principles, as well as some related basic computer knowledge.

1. Project Background

In recent projects, a lot of multi-threaded things have been written. Among them is the implementation of a multi-task light blocker, which leads to the application of the volatile keyword, and learn the principle and related basic knowledge by the way.

Because multitasking is executed by multiple threads, the blocking of multitasking naturally thinks of communication between threads . So some variables in the thread need to be visible .

When it comes to thread visibility, for Java, there are two implementation methods: volatile and synchronize.

volatile is only used to ensure the visibility of the variable to all threads, but does not guarantee atomicity

But because the lock of synchronize is too heavy, only one thread that gets the lock is allowed to perform tasks, so we choose volatile to implement this interrupter.

Second, the principle of volatile implementation

First of all, let's review from the underlying memory model of the computer, why threads are invisible between threads.

1. Why are threads invisible?

computer memory model

The operating speed of the current CPU is much faster than the reading and writing speed of the memory, so in order to ensure that the CPU can fully calculate, we use media with different reading and writing speeds, combined with actual application conditions, to form a computer. Among them, the CPU has a top-level 4-layer cache structure (built-in 3 layers), the cache (Cache) is used as a buffer between the memory and the processor, and the main memory and hard disk are external storage media.

insert image description here
The calculations of the CPU are all performed in its own high-speed cache, reducing the number of reads and writes with the main memory to ensure operating efficiency. The specific memory model is shown in the following figure:

insert image description here
As shown in the model above, the computer copies the required data from the main memory to the cache, and then writes the calculated results back to the main memory. In the process of performing calculations, the changes of various non-public variables are invisible to each thread, which will lead to thread insecurity.

JMM memory model

Java's memory model actually suffers from the same problem:

  • All instance variables and class variables are stored in main memory.
    (It does not contain local variables, since local variables are thread-private, so there is no race problem.)
  • All the reading and writing operations of the thread to the variables are completed in the working memory, but cannot directly read and write the variables in the main memory.

(picture)
Therefore, in Java's memory model, thread visibility has not yet been resolved. So, how to solve the problem of thread visibility?

2. How to solve visibility?

According to the computer and JMM memory model, if you want a variable to be visible across threads, there seem to be only two ways:

  • Lock the variable, the thread that gets the lock can calculate the variable, and other threads are blocked. (pessimistic lock: synchronized)
  • Write variables are all written to the main memory. After the main memory variables are updated, other threads are notified that the variables are expired, and the updated variables are used in the subsequent calculations of other threads. (volatile)

Because only the thread that has obtained the lock can perform calculations, the use of synchronized pessimistic locks undoubtedly achieves the visibility and atomicity of variables between threads . But it's expensive and has poor concurrency.

Although the use of volatile cannot guarantee its atomicity (thread is not safe), it can guarantee the visibility between threads. Therefore, as a multi-tasking blocker, the application of the volatile keyword meets the project requirements.

In order to better understand the scope of volatile and the risks of its application, you can first look at the memory semantics of volatile.

The memory semantics of volatile

Memory semantics can be understood as the functions and rules to be implemented in memory when volatile performs calculations:

  • When writing a volatile variable, JMM will refresh the value of the shared variable in the local memory corresponding to the thread to the main memory.
  • When reading a volatile variable, the JMM will invalidate the local memory corresponding to the thread and read the shared variable from main memory.

To achieve the above memory semantics, it is necessary to restrict the instruction rearrangement of JMM.

3. Instruction rearrangement under memory barrier control

command rearrangement

First, a brief introduction to what is instruction rearrangement.

In order to optimize performance, the compiler will reorder according to some specific rules under the premise of as-if-serial (no matter how reordering, the execution result under single thread cannot be changed.).

The significance of prohibiting instruction rearrangement is:

When writing a volatile variable for the first time, update the updated variable to the main memory; when a thread reads a volatile variable, it must read the variable in the main memory at the first time. This ensures correct functioning.

The classic example of a problem caused by instruction rearrangement is the double check of the singleton mode. Here is a pseudo-code first, and a simple example:

volatile boolean testMark = false;
int a = 2
//线程1执行task1
task1() {
    
    
	while(!flag) {
    
    
	
	}
	dosomething(a);
}
//线程2执行task2
task2() {
    
    
	a = 3;
	flag = true;
}

For the above example, it is clear that the coder intended to feed the value a = 3 into the dosomething function. But if, in task2, the two execution steps are rearranged ( for a single thread, even if the order of a = 3 and flag = true is reversed, the final calculation result of this thread will not be changed, so it is possible to reorder rows ).

Then after the instruction is rearranged, it is obvious that the function after execution may be incorrect, so before and after the variable modified by the volatile keyword, the instruction rearrangement is prohibited.

The way to prevent instruction reordering is to use memory barriers. Here, I won’t introduce it, just take a look at the definition:

The java compiler will insert memory barrier instructions at appropriate positions when generating instruction series to prohibit certain types of processor reordering.

happens - before

happens - before has two definitions and is represented by 8 rules. These are all implemented in JMM, we don't have to worry too much, here we just look at the rules of volatile.

Volatile domain rule: A write operation to a volatile domain happens-before any subsequent read of the volatile domain by any thread.

Just like the above example, after changing flag = true, it must be ensured that in the while loop of the task1 function, the value of flag is true next time.

4. Does volatile guarantee atomicity?

volatile can make long and double assignments atomic. (This will be expanded on another day.)

However, volatile cannot guarantee the atomicity of calculations . In fact, the most classic operation is ++.

When there is a variable ++ operation, the following three steps will be generated in one thread:

  • fetch value from main memory to working memory
  • Calculate the value of working memory
  • Update the calculated new value to main memory

The corresponding CPU instructions are as follows:

mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier

If there are two threads executing the ++ operation of the same volatile variable, the possible situation:

  • After thread 1 reads the variable to the working memory, because there is no lock, thread 2 grabs the CPU after thread 1 copies it, and immediately copies the variable in the main memory to the working memory.
  • After that, thread 1 and thread 2 successively incremented the working memory.
  • Finally, threads 1 and 2 respectively flush the self-incremented values ​​back to the main memory.

Note that what I said before: "Other thread variables become invalid after writing back to main memory" is for calculations that have not yet been performed . In the above example, assuming that after the calculation of thread 1, the calculation of thread 2 is followed immediately (thread 1 has not written back at this time), and then the updated value is written back, then thread 2 will not invalidate the variable (because the calculation has been completed) , so when thread 2 writes back, it will overwrite the value written back by thread 1, making the thread unsafe, so volatile calculations cannot guarantee atomicity.

3. Volatile and synchronized

In this section, we can learn about the combined application of volatile and synchronized: double check of singleton mode.

1. Singleton double check

If the program is single-threaded, the singleton pattern is really nothing to worry about. But if it is multi-threaded, you still need to check the problem of thread insecurity.

Write a simple singleton first:

public class Singleton {
    
    

    private static Singleton singleton = new Singleton();

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    
    
        return singleton;
        
    }
}

Obviously, the above hunger-style writing. The instance will be created when the JVM starts, and there will be no thread insecurity when creating the instance. (but possible)

But if it is written in a lazy style, there will obviously be thread safety issues:

public class Singleton {
    
    

private static Singleton singleton = null;

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    

        if(singleton == null){
    
    

            singleton = new Singleton();

        }

        return singleton;

    }

}

If there are two threads that need to obtain the instance now, it is easy to find that it is obviously not a singleton mode when creating an instance, and the thread is obviously not safe.

Also, we can obviously add a lock before the get function:

    public static synchronized Singleton getSingleton(){
    
    

        if(singleton == null){
    
    

            singleton = new Singleton();

        }

        return singleton;

    }

But this means a sharp drop in performance, because when getting an instance, only one thread that grabs the lock can always work.

But if you improve it a little bit, lock it in the following way:

public class Singleton {
    
    

    private static Singleton singleton =null;

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    
        if(singleton == null){
    
                                       
            synchronized (Singleton.class){
    
              
                if(singleton == null){
    
            
                    singleton = new Singleton(); 
                }
            }
        }
        return singleton;
    }
}

Although this improves performance, it still leads to thread insecurity. How is it not safe here? We can assume a limiting case - the instruction to create an object is rearranged:

Three steps to create an object:

  • Allocate memory space.
  • Call the constructor to initialize the instance.
  • return address to reference
  • Thread 1 applies for the object memory space and writes the memory address back to the main memory, but the object has not been initialized yet. (step 3 promoted to before step 2)

For a single thread, this is not illegal, because the calculation result of the last single thread is equal to that before rearrangement.

  • Thread 2 wants to get the instance at this time, goes to the main memory to get the address of the object, and accesses it, and finds that it is a null pointer.

Therefore, instruction rearrangement will cause problems, we need to add volatile to the variable to prohibit instruction rearrangement.

public class Singleton {
    
    

    private static volatile Singleton singleton =null;

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    
        if(singleton==null){
    
    
            synchronized (Singleton.class){
    
    
                if(singleton==null){
    
    
                    singleton =new Singleton();
                    }
            }
        }
    return singleton;
    }
}

That's why variables are prefixed with the volatile modifier.

Four, volatile and static

Variables modified by static: among multiple instances, the uniqueness of variables is guaranteed. But there are no guarantees of visibility and atomicity.
Variables modified by volatile: Variables are not unique among multiple instances. But thread visibility is guaranteed, atomicity is not guaranteed.

Therefore, the variable modified by static volatile is the uniqueness among multiple instances and the visibility between threads.

V. Summary

  • Applicable scenario: A property is shared by multiple threads, and one thread modifies this property, and other threads can immediately get the modified value, such as booleanflag; or as a trigger to achieve lightweight synchronization.
  • The read and write operations of volatile variables are lock-free and low-cost. It is not a replacement for synchronized because it does not provide atomicity and mutual exclusion.
  • Volatile can only be applied to attributes. We use volatile to modify attributes so that compilers will not reorder instructions for this attribute.
  • volatile can achieve visibility and prohibit instruction reordering in singleton double checks (special scenarios), thereby ensuring safety.

Thread visibility , instruction rearrangement prohibited , not necessarily atomic .

Guess you like

Origin blog.csdn.net/weixin_43742184/article/details/113887129