Start with a question: “What does the keyword volatile do?” . There are probably two common answers:

  • One approach is to treat volatile as a locking mechanism, arguing that volatile is like adding the sychronized keyword to a function, and that access to a particular variable by different threads will be locked.
  • The other is to think of volatile as an atomization mechanism, assuming that by adding volatile the increment of a variable becomes atomic

In fact, both interpretations are completely wrong. The key to volatile is the Java Memory Model (JMM). Although the JMM is only a memory model for a Process-level Virtual machine, the Java Virtual Machine, the memory model is very similar to the hardware architecture that combines CPU, cache, and main memory in a computer. Understanding the JMM makes it easy to understand the relationship between the CPU, cache, and main memory in your computer’s composition.

1. Examples of volatile

Let’s start with a Java program. This is classic volatile code from dzone.com, a popular Java developer site, and we’ll modify it for various experiments.

1.1 Code Example 1

public class VolatileTest {  
    private static volatile int COUNTER = 0; / / volatile
 
    public static void main(String[] args) {  
        new ChangeListener().start();  // Start the ChangeListener thread
        new ChangeMaker().start(); // Start the ChangeMaker thread
    }
 	
    // Listen for the COUNTER variable and print the value as it changes
    static class ChangeListener extends Thread {
        @Override     
        public void run(a) {   
            int threadValue = COUNTER;  
            while ( threadValue < 5) {if( threadValue! = COUNTER){ System.out.println("Got Change for COUNTER : " + COUNTER + ""); threadValue= COUNTER; }}}}// Listen for the COUNTER variable and increment it by 1 every 500 milliseconds if it is less than 5
   static class ChangeMaker extends Thread{   
       @Override 
       public void run(a) {  
           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(); 
               }    
           }     
       }  
   }
}
Copy the code

The program’s output is not surprising. The ChangeMaker function increases COUNTER from 0 to 5 one at a time. Since this increment occurs every 500 milliseconds, and ChangeListener is busy waiting to listen to the COUNTER, each increment is monitored by ChangeListener and the corresponding result is 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
Copy the code

1.2 Code Example two

What would happen if we made a small change to the program to remove the volatile keyword from the COUNTER variable?

private static int COUNTER = 0;
Copy the code

It turns out that ChangeMaker still works, increments COUNTER by one every 500ms, but ChangeListener no longer works. In ChangeListener’s mind, it seems to think that 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
Copy the code

1.3 Code Example three

This interesting little program is not over yet, we can make a few more changes to the program. Instead of having ChangeListener do a complete busy wait, we wait a little bit for 5 milliseconds in the while loop to see what happens.

static class ChangeListener extends Thread {

    @Override  
    public void run(a) {   
        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(); }}}}Copy the code

Another amazing phenomenon is about to happen. Although our COUNTER variable still doesn’t have the volatile keyword set, our ChangeListener seems to have woken up. After “sleeping” for 5 milliseconds in each loop through Thread.sleep(5), ChangeListener is able to fetch 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
Copy the code

2. Explain the effects of volatile

2.1 principle of volatile

These interesting observations come from our Java memory model and the meaning of the keyword volatile. So what does the volatile keyword mean? It ensures that all reads and writes to this variable are synchronized to main memory and not read from Cache. What to make of this explanation? Let’s analyze it through the example we just gave.

  • In the first example where volatile is used, all data is read and written to main memory. So naturally, between our ChangeMaker and our ChangeListener, we see the same COUNTER value.
  • By the time we made a small change to the second paragraph, we removed the volatile keyword. In this case, the ChangeListener is a busy waiting loop, trying to fetch COUNTER continuously from the current thread’s “Cache”. As a result, the thread does not have time to synchronize the updated COUNTER value from main memory. So it’s stuck on a loop where COUNTER=0.
  • In the third piece of code, we did not use volatile, but thead. Sleep gave the thread a break in just 5ms. Now that the thread is not so busy, it has an opportunity to synchronize new data from main memory to its cache. So the next time ChangeListener looks at the COUNTER value, she’ll see what ChangeMaker has done.

Although the Java memory model is an abstract model within a virtual machine isolated from the hardware implementation, it gives us a good example of the “cache synchronization” problem. In other words, if our data is being updated in A different thread or CPU core, because different threads or CPU cores have their own caches, it’s very likely that the update from thread A to thread B will not be visible.

2.2 Extend to CPU cache

In fact, we can compare the Java memory model to the CPU structure of a computer.

The Intel CPUS we use today are usually multi-core. Each CPU core has its own L1 and L2 caches, followed by the L3 Cache and main memory shared by multiple CPU cores.

CPU caches are much faster than main memory, and L1/L2 caches are much faster than L3 caches. Therefore, the CPU always tries to fetch data from the CPU Cache, rather than fetching data from main memory every time.

This hierarchy is similar to the Java memory model where each thread has its own thread stack. When a thread reads COUNTER data, it actually reads from the Cache copy of the local thread stack, not from main memory.

3. How volatile works: Memory barriers

Volatile actually achieves both visibility and order through memory barriers

  • role

    • Prevents reordering of instructions on both sides of the barrier
    • Force the data to be written back to main storage -> invalidate the corresponding data in the remaining caches -> other threads need to read it again in main storage
  • classification

  • Store: Updates main storage

  • Load: Invalidates the cache and forcibly refreshes it

    • LoadLoad: After volatile reads, avoid reordering volatile reads and subsequent common reads
    • StoreStore: Disallows reordering of volatile writes before volatile writes
  • LoadStore: After volatile reads, avoid reordering volatile reads and subsequent normal writes
  • StoreLoad: After volatile writes, avoid reordering volatile writes from possible volatile reads and writes