In-depth understanding of the Java memory model (4)-volatile keyword

Characteristics of volatile

When we declare the shared variable as volatile, the reading/writing of this variable will be very special. A good way to understand the characteristics of volatile is to treat a single read/write of a volatile variable as using the same monitor lock to synchronize these single read/write operations. Let's use specific examples to illustrate, please see the following sample code:



class VolatileFeaturesExample {
    volatile long vl = 0L;  // 使用 volatile 声明 64 位的 long 型变量 
    public void set(long l) {
        vl = l;   // 单个 volatile 变量的写 
    }

    public void getAndIncrement () {

        vl++;    // 复合(多个)volatile 变量的读 / 写 

    }

    public long get() {
        return vl;   // 单个 volatile 变量的读 
    }

}

Suppose there are multiple threads calling the three methods of the above program. This program is semantically equivalent to the following program: 

class VolatileFeaturesExample {
    long vl = 0L;               // 64 位的 long 型普通变量 

    public synchronized void set(long l) {     // 对单个的普通 变量的写用同一个监视器同步 
        vl = l;
    }

    public void getAndIncrement () { // 普通方法调用 
        long temp = get();           // 调用已同步的读方法 
        temp += 1L;                  // 普通写操作 
        set(temp);                   // 调用已同步的写方法 
    }

    public synchronized long get() { 
        // 对单个的普通变量的读用同一个监视器同步 
        return vl;

    }

}

As shown in the example program above, a single read/write operation of a volatile variable is synchronized with the read/write operation of an ordinary variable using the same monitor lock, and the execution effect between them is the same.

The happens-before rule of the monitor lock guarantees the memory visibility between the two threads that release the monitor and acquire the monitor, which means that the read of a volatile variable can always see (any thread) on this volatile variable The last write.

The semantics of the monitor lock determines that the execution of the critical section code is atomic. This means that even for 64-bit long and double variables, as long as it is a volatile variable, reading and writing to the variable will be atomic. If there are multiple volatile operations or compound operations similar to volatile++, these operations are not atomic as a whole.

In short, volatile variables themselves have the following characteristics:

  • Visibility. Reading a volatile variable can always see (any thread) the last write to this volatile variable.
  • Atomicity: The read/write of any single volatile variable is atomic, but compound operations similar to volatile++ are not atomic.

 

Volatile write-read the happens before relationship established

The above is about the characteristics of volatile variables. For programmers, the impact of volatile on thread memory visibility is more important than the characteristics of volatile itself, and we need to pay more attention to it.

Starting from JSR-133, the writing-reading of volatile variables can realize the communication between threads.

From the perspective of memory semantics, volatile has the same effect as monitor lock: volatile write and monitor release have the same memory semantics; volatile read and monitor acquisition have the same memory semantics.

Please see the following example code using volatile variables:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;               //2
    }

    public void reader() {

        if (flag) {                //3
            int i =  a;           //4
            ……
        }
    }

}

Suppose that after thread A executes the writer() method, thread B executes the reader() method. According to the happens before rule, the happens before relationship established in this process can be divided into two categories:

  1. According to the program sequence rules, 1 happens before 2; 3 happens before 4.
  2. According to the volatile rule, 2 happens before 3.
  3. According to the transitive rule of happens before, 1 happens before 4.

The graphical representation of the above happens before relationship is as follows:

In the figure above, the two nodes linked by each arrow represent a happens before relationship. The black arrow indicates the program sequence rules; the orange arrow indicates the volatile rule; the blue arrow indicates the happens before guarantee provided by combining these rules.

Here, after thread A writes a volatile variable, thread B reads the same volatile variable. All shared variables that are visible to thread A before writing the volatile variable will become visible to thread B immediately after thread B reads the same volatile variable.

 

volatile write-read memory semantics

The memory semantics written by volatile are as follows:

  • When writing a volatile variable, JMM will flush the shared variable in the local memory corresponding to the thread to the main memory.

Taking the above sample program VolatileExample as an example, suppose thread A first executes the writer() method, and then thread B executes the reader() method. Initially, the flag and a in the local memory of the two threads are both initial states. The following figure is a schematic diagram of the state of shared variables after thread A executes volatile write:

As shown in the figure above, after thread A writes the flag variable, the values ​​of the two shared variables in local memory A that have been updated by thread A are flushed to the main memory. At this time, the values ​​of the shared variables in the local memory A and the main memory are the same.

The memory semantics of volatile read are as follows:

  • When reading a volatile variable, JMM will invalidate the local memory corresponding to the thread. The thread will then read the shared variable from main memory.

The following is a schematic diagram of the shared variable state after thread B reads the same volatile variable:

As shown in the figure above, after reading the flag variable, the local memory B has been invalidated. At this point, thread B must read the shared variable from main memory. The read operation of thread B will cause the values ​​of shared variables in local memory B and main memory to become consistent.

If we look at the two steps of volatile writing and volatile reading together, after reading thread B reads a volatile variable, all visible shared variable values ​​of writing thread A before writing this volatile variable will immediately become to the reading thread B is visible.

The following is a summary of the memory semantics of volatile write and volatile read:

  • Thread A writes a volatile variable. In essence, thread A sends a message (which modifies the shared variable) to a thread that will read the volatile variable next.
  • Thread B reads a volatile variable. In essence, thread B receives a message sent by a thread before (modification of the shared variable before writing this volatile variable).
  • Thread A writes a volatile variable, and then thread B reads the volatile variable. This process is essentially thread A sending a message to thread B through main memory.

 

Implementation of volatile memory semantics

Next, let's take a look at how JMM implements the memory semantics of volatile write/read.

We mentioned earlier that reordering is divided into compiler reordering and processor reordering . In order to implement volatile memory semantics, JMM will restrict these two types of reordering types respectively. The following is a table of volatile reordering rules formulated by JMM for the compiler:

Can reorder Second operation
First operation Normal read/write volatile read volatile write
Normal read/write     NO
volatile read NO NO NO
volatile write   NO NO

For example, the last cell in the third row means: In the program sequence, when the first operation is a read or write of a common variable, if the second operation is a volatile write, the compiler cannot reorder this Two operations.

From the above table we can see:

  • When the second operation is a volatile write, no matter what the first operation is, it cannot be reordered. This rule ensures that operations before volatile writes will not be reordered by the compiler to after volatile writes.
  • When the first operation is a volatile read, no matter what the second operation is, it cannot be reordered. This rule ensures that operations after a volatile read will not be reordered by the compiler before the volatile read.
  • When the first operation is volatile write and the second operation is volatile read, reordering cannot be performed.

In order to realize the memory semantics of volatile, the compiler inserts a memory barrier in the instruction sequence to prohibit specific types of processor reordering when generating bytecode. For the compiler, it is almost impossible to find an optimal arrangement to minimize the total number of insertion barriers. For this reason, JMM adopts a conservative strategy. The following is a JMM memory barrier insertion strategy based on a conservative strategy:

  • Insert a StoreStore barrier before each volatile write operation.
  • Insert a StoreLoad barrier after each volatile write operation.
  • Insert a LoadLoad barrier after each volatile read operation.
  • Insert a LoadStore barrier after each volatile read operation.

The above memory barrier insertion strategy is very conservative, but it can ensure that the correct volatile memory semantics can be obtained on any processor platform and any program.

The following is a schematic diagram of the instruction sequence generated after a volatile write is inserted into the memory barrier under a conservative strategy:

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

What's more interesting here is the StoreLoad barrier behind the volatile write. The function of this barrier is to avoid reordering of volatile writes and possible subsequent volatile read/write operations. Because the compiler often cannot accurately determine whether a StoreLoad barrier needs to be inserted after a volatile write (for example, the method returns immediately after a volatile write). In order to ensure the correct implementation of volatile memory semantics, JMM has adopted a conservative strategy here: insert a StoreLoad barrier after each volatile write or before each volatile read. From the perspective of overall execution efficiency, JMM chose to insert a StoreLoad barrier after each volatile write. Because the common usage pattern of volatile write-read memory semantics is: one write thread writes a volatile variable, and multiple reader threads read the same volatile variable. When the number of reader threads greatly exceeds the number of writer threads, choosing to insert the StoreLoad barrier after volatile writing will bring a considerable improvement in execution efficiency. From here we can see a characteristic of JMM in its implementation: first ensure correctness, and then pursue execution efficiency.

The following is a schematic diagram of the instruction sequence generated after a volatile read is inserted into the memory barrier under a conservative strategy:

The LoadLoad barrier in the figure above 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 write below.

The above-mentioned memory barrier insertion strategy for volatile write and volatile read is very conservative. In actual execution, as long as the memory semantics of volatile write-read is not changed, the compiler can omit unnecessary barriers according to the specific situation. Below we illustrate with specific sample code:

class VolatileBarrierExample {

    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        
        int i = v1;           // 第一个 volatile 读 
        int j = v2;           // 第二个 volatile 读 
        a = i + j;            // 普通写 
        v1 = i + 1;          // 第一个 volatile 写 
        v2 = j * 2;          // 第二个 volatile 写 

    }

    ......                   // 其他方法 

}

For the readAndWrite() method, the compiler can make the following optimizations when generating bytecode:

Note that the last StoreLoad barrier cannot be omitted. Because after the second volatile is written, the method returns immediately. At this time, the compiler may not be able to accurately determine whether there will be a volatile read or write later. For safety reasons, the compiler often inserts a StoreLoad barrier here.

The above optimization is for any processor platform. Since different processors have different "tightness" processor memory models, the insertion of memory barriers can also be optimized according to the specific processor memory model. Take the x86 processor as an example, except for the last StoreLoad barrier in the above figure, all barriers will be omitted.

The volatile read and write under the previous conservative strategy can be optimized on the x86 processor platform to:

As mentioned earlier, x86 processors only reorder write-read operations. X86 does not reorder read-read, read-write, and write-write operations, so the memory barriers corresponding to these three types of operations will be omitted in the x86 processor. In x86, JMM only needs to insert a StoreLoad barrier after volatile write to correctly implement volatile write-read memory semantics. This means that in x86 processors, the overhead of volatile writes is much larger than that of volatile reads (because the overhead of the StoreLoad barrier is relatively large).

 

Why JSR-133 should enhance the memory semantics of volatile

In the old Java memory model before JSR-133, although reordering between volatile variables was not allowed, the old Java memory model allowed reordering between volatile variables and ordinary variables. In the old memory model, the VolatileExample sample program may be reordered to execute in the following sequence:

In the old memory model, when there is no data dependency between 1 and 2, then 1 and 2 may be reordered (3 and 4 are similar). The result is: when the reader thread B executes 4, it may not necessarily see the modification of the shared variable when the writer thread A executes 1.

Therefore, in the old memory model, volatile writes-reads without monitor release-get the memory semantics it has. In order to provide a lighter-weight communication mechanism between threads than monitor locks, the JSR-133 expert group decided to enhance the memory semantics of volatile: strictly restrict the reordering of volatile variables and ordinary variables by compilers and processors to ensure Volatile write-read and monitor release-acquisition have the same memory semantics. From the perspective of compiler reordering rules and processor memory barrier insertion strategy, as long as the reordering between volatile variables and ordinary variables may destroy the memory semantics of volatile, this reordering will be reordered by the compiler reordering rules and processor memory barriers. The insertion strategy is forbidden.

Since volatile only guarantees the atomicity of the read/write of a single volatile variable, the mutually exclusive execution characteristics of the monitor lock can ensure the atomicity of the execution of the entire critical section code. In terms of function, monitor locks are more powerful than volatile; in terms of scalability and execution performance, volatile has more advantages. If readers want to use volatile instead of monitor locks in their programs, please be cautious.

Previous articleIn-      depth understanding of the Java memory model (three)-sequential consistency
Next article In-     depth understanding of Java memory model (5)-lock

Thanks to the author for his contribution to this article

Cheng Xiaoming, Java software engineer, nationally certified system analyst and information project manager. Focus on concurrent programming and work at Fujitsu Nanda. Personal email: [email protected].
---------------------
Author: World coding
Source: CSDN
Original: https://blog.csdn.net/dgxin_605/article/details/86182774
Copyright: This article is the original article of the blogger, please attach a link to the blog post if you reprint it!

Guess you like

Origin blog.csdn.net/dgxin_605/article/details/86182774