[Java concurrent programming] volatile (two): in-depth analysis of the principle of volatile (code sample to CPU cache)

First raise a question: "What does the keyword volatile do?". There may be two common answers:

  • One is to treat volatile as a locking mechanism, and think that adding volatile to a variable is like adding the sychronized keyword to a function. Different threads will lock access to specific variables;
  • The other is to regard volatile as an atomic operation mechanism. It is believed that after adding volatile, the increment operation of a variable will become atomic

In fact, these two interpretations are completely wrong. The core knowledge of the volatile keyword is related to the Java Memory Model (JMM). Although JMM is only a memory model in the process-level virtual machine of the Java virtual machine, this memory model is very similar to the hardware system that combines the CPU, cache, and main memory in the computer composition. Understand the JMM, you can easily understand the relationship between the CPU, cache and main memory in the computer composition.

1. Volatile example

Let's take a look at a Java program first. This is a classic volatile code from the well-known Java developer website dzone.com. We will modify this code for various small experiments in the future.

1.1 Code example one

public class VolatileTest {
    
      
    private static volatile int COUNTER = 0; // volatile修饰
 
    public static void main(String[] args) {
    
      
        new ChangeListener().start();  // 启动ChangeListener线程  
        new ChangeMaker().start(); // 启动ChangeMaker线程
    }
 	
    // 监听COUNTER变量,当COUNTER发生变化时就将变化的值打印出来
    static class ChangeListener extends Thread {
    
    
        @Override     
        public void run() {
    
       
            int threadValue = COUNTER;  
            while ( threadValue < 5){
    
             
                if( threadValue!= COUNTER){
    
         
                    System.out.println("Got Change for COUNTER : " + COUNTER + ""); 
                    threadValue= COUNTER;            
                }     
            }    
        }  
    }
    
   // 监听COUNTER变量,当COUNTER小于5时就每个500毫秒将COUNTER自增1
   static class ChangeMaker extends Thread{
    
       
       @Override 
       public void run() {
    
      
           int threadValue = COUNTER;  
           while (COUNTER <5){
    
                 
               System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");  
               COUNTER = ++threadValue;     
               try {
    
                     
                   Thread.sleep(500);      
               } catch (InterruptedException e) {
    
     
                   e.printStackTrace(); 
               }    
           }     
       }  
   }
}

The output of the program is not surprising. The ChangeMaker function will increase the COUNTER from 0 to 5 one at a time. Because this increment is every 500 milliseconds, and ChangeListener is busy waiting to monitor the COUNTER, so every increment will be monitored by the ChangeListener, and the corresponding result will be printed out

Incrementing COUNTER to : 1 
Got Change for COUNTER : 1
Incrementing COUNTER to : 2 
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3 
Incrementing COUNTER to : 4 
Got Change for COUNTER : 4 
Incrementing COUNTER to : 5
Got Change for COUNTER : 5

1.2 Code example two

At this time, if we slightly modify a line of code in the above program and remove the volatile keyword that we set when we define the variable COUNTER, what will happen?

private static int COUNTER = 0;

The result is that ChangeMaker can still work normally. COUNTER can still be incremented by 1 every 500ms, but ChangeListener does not work anymore. In the eyes of ChangeListener, it always seems that the value of COUNTER is still 0 at the beginning. It seems that the change of COUNTER is completely "invisible" to our ChangeListener.

Incrementing COUNTER to : 1 
Incrementing COUNTER to : 2 
Incrementing COUNTER to : 3 
Incrementing COUNTER to : 4 
Incrementing COUNTER to : 5

1.3 Code example three

This interesting little program is not over yet, we can make some minor changes to the program. We no longer let the ChangeListener perform a completely busy wait, but in the while loop, we wait a little for 5 milliseconds to see what happens.

static class ChangeListener extends Thread {
    
    

    @Override  
    public void run() {
    
       
        int threadValue = COUNTER;  
        while ( threadValue < 5){
    
         
            if( threadValue!= COUNTER){
    
       
                System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");
                threadValue= COUNTER;       
             }        
             try {
    
               
                 Thread.sleep(5);    
             } catch (InterruptedException e) {
    
     
                 e.printStackTrace();
             }     
         }   
    } 
 }

Another surprising phenomenon is about to happen. Although our COUNTER variable still does not set the volatile keyword, our ChangeListener seems to "woke up". After "sleeping" for 5 milliseconds in each loop through Thread.sleep(5), ChangeListener can get the value of COUNTER normally again.

Incrementing COUNTER to : 1 
Sleep 5ms, Got Change for COUNTER : 1 
Incrementing COUNTER to : 2 
Sleep 5ms, Got Change for COUNTER : 2 
Incrementing COUNTER to : 3 
Sleep 5ms, Got Change for COUNTER : 3 
Incrementing COUNTER to : 4 
Sleep 5ms, Got Change for COUNTER : 4 
Incrementing COUNTER to : 5 
Sleep 5ms, Got Change for COUNTER : 5

2. volatile explanation

2.1 volatile principle

These interesting phenomena actually come from our Java memory model and the meaning of the keyword volatile. What does the volatile keyword mean? It will ensure that our reading and writing of this variable will definitely be synchronized to the main memory instead of reading from the Cache . How to understand this explanation? We use the example just now to analyze.

  • In the first example of using the volatile keyword just now, because all data read and write come from the main memory. So naturally, the COUNTER value seen between our ChangeMaker and ChangeListener is the same.
  • When the second paragraph made a small modification, we removed the volatile keyword. At this time, the ChangeListener is in a busy waiting cycle. It tries to get the value of COUNTER continuously, so that it will get it from the "Cache" of the current thread. Therefore, this thread has no time to synchronize the updated COUNTER value from the main memory . In this way, it has been stuck in the endless loop of COUNTER=0.
  • In the third piece of code we modified again, although the volatile keyword was still not used, the Thead.Sleep of just 5ms gave this thread a breathing space. Since this thread is not so busy, it has the opportunity to synchronize new data from main memory to its own cache . Therefore, the next time the ChangeListener checks the COUNTER value, it will be able to see the changes caused by the ChangeMaker.

Although the Java memory model is an abstract model in a virtual machine that isolates the hardware implementation, it gives us a good example of the "cache synchronization" problem. In other words, if our data is updated in different threads or CPU cores, because different threads or CPU cores have their own caches, it is very likely that the update in the A thread will be invisible to the B thread. .

2.2 Extending to CPU cache

In fact, we can compare the Java memory model with the CPU structure in the computer composition.

The Intel CPUs we now use are usually multi-core. Each CPU core has its own L1 and L2 cache, and then there are L3 cache and main memory shared by multiple CPU cores.

Because the access speed of the CPU Cache is much faster than the main memory, and in the CPU Cache, the L1/L2 Cache is also faster than the L3 Cache. Therefore, the CPU always gets data from the CPU Cache as much as possible, instead of reading data from the main memory every time.

Insert picture description here

This hierarchical structure is like we are in the Java memory model. Each thread has its own thread stack. When a thread reads the COUNTER data, it actually reads the data from the Cache copy of the local thread stack, not from the main memory.

3. Volatile realization principle: memory barrier

Volatile actually achieves both visibility and order through the memory barrier

  • effect

    • Prevent reordering of instructions on both sides of the barrier
    • Forcibly write the modified data back to the main memory -> the corresponding data in the remaining cache is invalid -> other threads need to read it again in the main memory when reading again
  • classification

  • Store: Update main memory

  • Load: invalidate the cache and force refresh

    • LoadLoad: After volatile read, avoid reordering volatile read operations and subsequent ordinary read operations
    • StoreStore: Before volatile writing, it is forbidden to reorder the normal writing above and the following volatile writing
  • LoadStore: After volatile read, avoid reordering volatile read operations and subsequent ordinary write operations
  • StoreLoad: After volatile write, avoid reordering of volatile write operations and volatile read and write operations that may exist later
    Insert picture description here

Guess you like

Origin blog.csdn.net/weixin_43935927/article/details/108630814