The volatile keyword in detail

1. Error case

       The volatile keyword is introduced through a case, such as the following code example: At this time , the communication between the two threads without the volatile keyword will be problematic

public class ThreadsShare {
    
    
  private static boolean runFlag = false; // 此处没有加 volatile
  public static void main(String[] args) throws InterruptedException {
    
    
    new Thread(() -> {
    
    
      System.out.println("线程一等待执行");
      while (!runFlag) {
    
    
      }
      System.out.println("线程一开始执行");
    }).start();
    Thread.sleep(1000);
    new Thread(() -> {
    
    
      System.out.println("线程二开始执行");
      runFlag = true;
      System.out.println("线程二执行完毕");
    }).start();
  }
}

Output result:
image

Conclusion: Thread 1 did not feel the signal that Thread 2 has changed runFlag to true, so the sentence "The beginning of thread execution" has not been output, and the program has not ended.

Just like the following scenario:
Insert picture description here

       In the current scenario, it may appear that processor A and processor B did not flush the data in their respective write buffers back to the memory, and assign the values ​​A = 0 and B = 0 read from the memory to X and Y , At this time, the data in the buffer is flushed into the memory, causing the final result to be inconsistent with the actual desired result. Because only the data in the buffer is flushed into the memory is the real execution

The cause of this problem :

       When a computer executes a program, every instruction is executed in the processor. In the process of executing instructions, data reading and writing are bound to be involved. Temporary data during the running of the program is stored in the main memory (physical memory). At this time, there is a problem. Because the processor executes fast, the process and processing of reading data from memory and writing data to memory The speed of execution of instructions by the processor is much slower than that, so if any operation on the data is carried out through interaction with the memory, it will greatly reduce the speed of instruction execution. In order to solve this problem, the CPU cache is designed. When each thread executes a statement, it will first read the value from the main memory, then copy it to the local memory, and then perform data operations to refresh the latest value to Main memory. This will cause a phenomenon of inconsistent cache

In response to the above phenomenon, a cache coherency protocol is proposed: MESI

       The core idea is: The MESI protocol ensures that the copies of shared variables used in each cache are consistent. When the processor writes data, if the operating variable is found to be a shared variable, that is, a copy of the variable exists in other processors, it will send a signal to other processors to invalidate the cache line of the shared variable ( bus sniffing) Exploring mechanism ), so when other processors need to read this variable, and find that the cache line that caches the variable in their cache is invalid, then it will reread it from the memory.

Sniffing cache coherency protocol:

       All memory transfers occur on a shared memory bus, and all processors can see this bus. The cache itself is independent, but the memory is shared. All memory accesses must be arbitrated, that is, only one processor can read and write data in the same instruction cycle. The processor not only interacts with the memory bus during memory transfers, but also constantly exchanges data on the sniffing bus to track what other caches are doing, so when a processor reads and writes memory, other processors will be notified (Proactive notification) They use this to synchronize their cache saves. As long as one processor writes memory, other processors will know that this memory is invalid in their cache segment.

Detailed MESI:

In the MESI protocol, each cache line has four states:

  1. Modified means that this line of data is valid, the data has been modified and the data in the memory is inconsistent, and the data only exists in the current cache
  2. Exclusive to Exclusive , this row of data is valid, the data is consistent with the data in the memory, and the data only exists in this cache
  3. Shared shared, this row of data is valid, the data is consistent with the data in the memory, and the data is stored in many caches.
  4. Invalid this row of data is invalid

       Here Invalid, shared, and modified are in line with the sniffing cache coherency protocol, but Exclusive means exclusive, the current data is valid and consistent with the data in the memory, but only the Exclusive state in the current cache solves that a processor is reading Before writing the memory, we have to notify other processors of this problem. The processor can only write when the cache line is Exclusive and Modified. That is to say, only in these two states, the processor monopolizes the cache line.

       When a processor wants to write a cache line, if it does not have control rights, it must first send a request for control rights to the bus. At this time, other processors will be notified to invalidate their copies of the same cache segment. The processor can modify the data when it is in control, and at this time, the processor has only one copy of the cache line and only in its cache, there will be no conflict, otherwise if other processors always want to read the cache line , The exclusive or modified cache line must be returned to the shared state first , if it is a modified cache line, the content must be written back to the memory first

       So java provides a lightweight synchronization mechanism volatile

2. Function

       Volatile is a lightweight synchronization mechanism provided by Java. Volatile is lightweight because it does not cause thread context switching and scheduling. But the synchronization of volatile variables is poor, it cannot guarantee the synchronization of a code block, and its use is more prone to errors. The volatile keyword is used to ensure visibility, that is, to ensure the memory visibility of shared variables to solve the cache coherency problem . Once a shared variable is modified by the volatile keyword, it has two semantics: memory visibility and prohibiting instruction reordering. In a multithreaded environment, the volatile keyword is mainly used to perceive the modification of shared variables in a timely manner, and enable other threads to immediately obtain the latest value of the variable

The effect of the program after using the volatile keyword:

How to use:

private volatile static boolean runFlag = false;

Code:

public class ThreadsShare {
    
    
  private volatile static boolean runFlag = false;
  public static void main(String[] args) throws InterruptedException {
    
    
    new Thread(() -> {
    
    
      System.out.println("线程一等待执行");
      while (!runFlag) {
    
    
      }
      System.out.println("线程一开始执行");
    }).start();
    Thread.sleep(1000);
    new Thread(() -> {
    
    
      System.out.println("线程二开始执行");
      runFlag = true;
      System.out.println("线程二执行完毕");
    }).start();
  }
}

Output result:
image

Conclusion: Thread 1 senses the signal that Thread 2 has changed runFlag to true, so the sentence "Thread starts executing" gets the output, and the program ends.

Two effects of volatile :

  1. When a thread writes a volatile variable, JMM will forcibly refresh the value of the variable in the local memory corresponding to the thread to the main memory
  2. This write operation will cause the cache of this shared variable in other threads to become invalid. If you want to use this variable, you must re-take the value in the main memory.

Thinking: What if two processors read or modify the same shared variable at the same time?

To access the memory, multiple processors must first obtain the memory bus lock. Only one processor can gain control of the memory bus at any time, so the above situation will not occur.

Important: The volatile keyword is used to ensure visibility, that is, to ensure the memory visibility of shared variables to solve the cache coherency problem


3. Features

3.1 Visibility

When a shared variable is volatile modified, it will ensure that the modified value will be updated to the main memory immediately. When other threads need to read it, it will go to the memory to read the new value. The visibility of ordinary shared variables cannot be guaranteed, because after ordinary shared variables are modified, it is uncertain when they will be written to the main memory. When other threads read, the memory may still have the original old value at this time, so Visibility cannot be guaranteed (the above case has already demonstrated the role of visibility )

3.2 Prohibition of order rearrangement

       In the Java memory model, the compiler and processor are allowed to reorder instructions, but the reordering process will not affect the execution of single-threaded programs, but it will affect the correctness of concurrent execution of multiple threads.

The volatile keyword prohibits instruction reordering has two meanings:

  1. When the program executes the read operation or write operation of the volatile variable, all the changes in the previous operations must have been carried out, and the results have been visible to the subsequent operations; the subsequent operations must have not been carried out;
  2. When optimizing instructions, you cannot place the statements that access the volatile variable behind it for execution, and you cannot place the statements following the volatile variable before it for execution.

       In order to solve the memory errors caused by processor reordering, the java compiler inserts memory barrier instructions at the appropriate position of the generated instruction sequence to prohibit specific types of processor reordering

Memory barrier instruction: Memory barrier is the realization of volatile semantics, which will be explained below

Barrier type Command example Description
LoadLoadBarriers Load1;LoadLoad;Load2 Load1 data load occurs before Load2 and all subsequent data loads
StoreStoreBarriers Store1;StoreStore;Store2 Store1 data flushing back to main storage occurs before Store2 and all subsequent data flushing back to main storage
LoadStoreBarriers Load1;LoadStore;Store2 Load1 data loading occurs before Store2 and all subsequent data are flushed back to main memory
StoreLoadBarriers Store1; StoreLoad; Load2 Store1 data flushing back to memory occurs before Load2 and all subsequent data loads

4.volatile 与 happens-before

public class Example {
    
    
  int r = 0;
  double π = 3.14;
  volatile boolean flag = false; // volatile 修饰
  /**
   * 数据初始化
   */
  void dataInit() {
    
    
    r = 1; // 1
    flag = true; // 2
  }
  /**
   * 数据计算
   */
  void compute() {
    
    
    if(flag){
    
     // 3
      System.out.println(π * r * r); //4
    }
  }
}

       If thread A executes dataInit(), thread B executes compute() according to the rules provided by happens-before (the previous java memory model is covered ) The java memory model has a talk about step 2 must be in accordance with the volatile rule before step 3, step 1 is in Before step 2, step 3 is before step 4, so step 1 is also before step 4 according to transitive rules.


5. Memory semantics

5.1 Read memory semantics

       When reading a volatile variable, the local working memory becomes invalid, and the current value of the volatile modified variable is obtained from the memory.

5.2 Write memory semantics

       When writing a volatile variable, the value in the local working memory is forced back to the memory.

5.3 Implementation of memory semantics

JMM volatile reordering rule table formulated by the compiler

Can reorder Second operation
First operation Ordinary read or write volatile read volatile write
Ordinary or write NO
volatile read NO NO NO
volatile write NO NO

For example, the meaning of the last cell in the third row:

       When a local operation is a normal operation, if the second operation is a volatile write, the compiler cannot reorder these two operations

5.4 Summary

  1. When the second operation is a volatile write, the first operation cannot be reordered no matter what. This rule guarantees that operations before volatile writing cannot be rearranged by the compiler to behind volatile writing
  2. When the first operation is volatile read, no matter what the second operation is, it cannot be reordered. This rule ensures that operations after volatile read will not be compiled by the compiler to before volatile
  3. When the first operation is volatile write and the second operation is volatile read, it cannot be reordered

       In order to realize the memory semantics of volatile, the compiler inserts a memory barrier in the instruction sequence to prohibit certain types of processor sorting when generating bytecode.

JMM memory barrier insertion strategy:

  1. Insert a StoreStore barrier before every volatile write operation.
  2. Insert a StoreLoad barrier after each volatile write operation.
  3. Insert a LoadLoad barrier after each volatile read operation.
  4. Insert a LoadStore barrier after each volatile read operation.

Schematic diagram of instruction sequence generated after volatile write is inserted into the memory barrier:

Insert picture description here

       The StoreStore barrier can ensure that all the previous ordinary write operations are visible to any processor before the volatile write. This is because the StoreStore barrier will ensure that all the ordinary writes above are flushed to the main memory before the volatile write.

       The StoreLoad barrier can guarantee the reordering of volatile writes and possible subsequent volatile read or write operations.

Schematic diagram of the instruction sequence generated after volatile read inserted into the memory barrier:

image

       The LoadLoad barrier is used to prohibit the processor from reordering the volatile read above and the normal read below.

       The LoadStore barrier is used to prohibit the processor from reordering the volatile read above and the normal read and write below.


6. Actual combat

6.1 The use of volatile must meet the conditions

  • Write operations to variables do not depend on the current value
  • The variable is not included in an invariant with other variables

       In fact, these conditions indicate that the effective values ​​that can be written to volatile variables are independent of the state of any program, including the current state of the variable. In fact, the above two conditions are to ensure that the operation of the volatile variable is an atomic operation , so as to ensure that the program using the volatile keyword can be executed correctly when concurrently.

6.2 Scenarios that volatile is mainly used

       Perceive the modification of shared variables in time in a multi-threaded environment, and enable other threads to immediately get the latest value of the variable

Scenario 1: State mark amount (example in the text)

public class ThreadsShare {
    
    
  private volatile static boolean runFlag = false; // 状态标记
  public static void main(String[] args) throws InterruptedException {
    
    
    new Thread(() -> {
    
    
      System.out.println("线程一等待执行");
      while (!runFlag) {
    
    
      }
      System.out.println("线程一开始执行");
    }).start();
    Thread.sleep(1000);
    new Thread(() -> {
    
    
      System.out.println("线程二开始执行");
      runFlag = true;
      System.out.println("线程二执行完毕");
    }).start();
  }
}

Scene two Double-Check

The DCL version of singleton mode is the abbreviation of double check lock, and the Chinese name is double-ended retrieval mechanism. The so-called double-ended search is to make a judgment before and after locking

public class Singleton1 {
    
    
    private static Singleton1 singleton1 = null;
    private Singleton1 (){
    
    
        System.out.println("构造方法被执行.....");
    }
    public static Singleton1 getInstance(){
    
    
        if (singleton1 == null){
    
     // 第一次check
            synchronized (Singleton1.class){
    
    
                if (singleton1 == null) // 第二次check
                    singleton1 = new Singleton1();
            }
        }
        return singleton1 ;
    }
 }

       Use synchronized to lock only the part of the code that creates the instance, not the entire method. Both before and after locking are judged, this is called a double-ended retrieval mechanism. This really only creates one object. However, this is not absolutely safe. A new object is also divided into three steps:

  • 1. Allocate object memory space
  • 2. Initialize the object
  • 3. Point the object to the allocated memory address, at this time the object is not null

       There is no data dependency in steps two and three, so the compiler allows these two sentences to reverse the order during optimization. When the instructions are rearranged, there will be problems with multi-threaded access. So there is the following final version of the singleton pattern. In this case, no order rearrangement will occur

public class Singleton2 {
    
    
  private static volatile Singleton2 singleton2 = null;
  private Singleton2() {
    
    
    System.out.println("构造方法被执行......");
  }
  public static Singleton2 getInstance() {
    
    
    if (singleton2 == null) {
    
     // 第一次check
      synchronized (Singleton2.class) {
    
    
        if (singleton2 == null) // 第二次check
          singleton2 = new Singleton2();
      }
    }
    return singleton2;
  }
}

Guess you like

Origin blog.csdn.net/weixin_38071259/article/details/112345166