Java Memory Model-volatile, java model-volatile
Volatile features
When we declare the shared variable as volatile, the read/write operations on this variable will be very special. A good way to understand the volatile feature is to synchronize a single read/write of the volatile variable with the same lock. The following is an example. See the following sample code:
Class VolatileFeaturesExample {// use volatile to declare a 64-bit long variable volatile long vl = 0L; public void set (long l) {vl = l; // write of a single volatile variable} public void getAndIncrement () {vl ++; // composite (multiple) read/write of volatile variable} public long get () {return vl; // read a single volatile variable }}
Assume that multiple threads call the three methods of the above program respectively. This program is semantically equivalent to the following program:
Class VolatileFeaturesExample {long vl = 0L; // 64-bit long variables // write a single common variable using the same lock to synchronize public synchronized void set (long l) {vl = l;} public void getAndIncrement () {// call long temp = get () as a normal method; // call the synchronized read method temp + = 1L; // set (temp) for normal write operations; // call the synchronized write method} public synchronized long get () {// use the same lock to synchronize return vl for reading a single common variable ;}}
As shown in the preceding example, a single read/write operation on a volatile variable is synchronized with a read/write operation on a common variable using the same lock, they have the same execution effect.
The happens-before rule of the lock ensures the memory visibility between the two threads that release the lock and obtain the lock. This means that the read of a volatile variable is always visible (any thread) write the final content of the volatile variable.
The semantics of the lock determines that the code execution in the critical section is atomic. This means that even 64-bit long and double variables, as long as they are volatile variables, the read and write operations on the variables will be atomic. For multiple volatile operations or compound operations similar to volatile ++, these operations are not atomic in general.
In short, the volatile variable has the following features:
- Visibility: The read of a volatile variable always shows (any thread) The Final write to this volatile variable.
- Atomicity: read/write operations on any single volatile variable are atomic, but composite operations similar to volatile ++ are not atomic.
The happens before relationship established by volatile write-read
The above describes the features of volatile variables. For programmers, the impact of volatile on thread memory visibility is more important than volatile's own features, and we need to pay more attention to it.
From the JSR-133, the write-read of the volatile variable can realize the communication between threads.
From the perspective of memory semantics, volatile has the same effect as the lock: volatile writes and locks are released with the same memory semantics; volatile reads and locks have the same memory semantics.
See the following sample code for using the volatile variable:
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 …… } }}
Assume that thread A executes the writer () method, and 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 types:
The figure of the above happens before relationship is as follows:
In, each arrow links two nodes, representing a happens before relationship. The black arrow indicates the procedural sequence rules, the orange arrow indicates the volatile rules, and the blue arrow indicates the happens before guarantee provided after these rules are combined.
Here, after thread A writes A volatile variable, thread B reads the same volatile variable. All visible shared variables before thread A writes the volatile variable will be immediately visible to thread B after thread B reads the same volatile variable.
Volatile write-Read Memory Semantics
The memory semantics written by volatile is as follows:
When writing a volatile variable, JMM refreshes the shared variable in the local memory corresponding to the thread to the main memory. The above sample program VolatileExample is used as an example. Assume that thread A executes the writer () method first, and thread B executes the reader () method, in the initial state, both the flag and a in the local memory of the two threads are in the initial state. Is the state of shared variables after thread A executes volatile writing:
As shown in, after thread A writes the flag variable, the values of the two shared variables updated by thread A in the local memory A are refreshed to the main memory. In this case, the value of the shared variable in the local memory A is the same as that in the main memory.
The memory Syntax of volatile reads is as follows:
- When a volatile variable is read, JMM sets the local memory corresponding to this thread to invalid. The thread will then read the shared variable from the main memory.
The following figure shows the State of shared variables after thread B reads the same volatile variable:
As shown in, after reading the flag variable, the Local Memory B has been set to invalid. In this case, thread B must read the shared variable from the main memory. The read operation of thread B will make the value of the shared variable in the Local Memory B and the main memory become the same.
If we combine the volatile write and volatile read steps, after reading a volatile variable from thread B, the value of all visible shared variables before writing the volatile variable to write thread A will immediately become visible to read thread B.
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 to A thread that is going to read the volatile variable (which modifies the shared variable.
- Thread B reads a volatile variable. In essence, thread B receives the message sent by a previous thread (modifications made to the shared variable before the volatile variable is written.
- Thread A writes A volatile variable and then thread B reads the volatile variable. This process is essentially because thread A sends messages to thread B through the main memory.
Volatile memory semantics implementation
Next, let's take a look at how JMM implements volatile write/Read Memory semantics.
As mentioned above, excessive sorting is divided into compiler and processor re-sorting. To implement volatile memory semantics, JMM restricts the two types of reordering. The following table lists the volatile reordering rules set by JMM for the compiler:
| Can it be reordered? |
The 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 order, when the first operation is a read or write of a common variable, if the second operation is volatile write, the compiler cannot reorder these two operations.
From the table above, we can see that:
- When the second operation is volatile write, no matter what the first operation is, it cannot be reordered. This rule ensures that the operations before volatile write will not be reordered by the compiler after volatile write.
- 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 are not reordered by the compiler before volatile read.
- When the first operation is volatile write and the second operation is volatile read, it cannot be reordered.
To implement volatile memory semantics, the compiler inserts a memory barrier into the instruction sequence when generating bytecode to prohibit specific types of processors from being reordered. For the compiler, it is almost impossible to find an optimal arrangement to minimize the total number of insertion barriers. Therefore, JMM adopts a conservative strategy. The following is a JMM memory barrier insertion policy based on a conservative policy:
- Insert a StoreStore barrier before each volatile write operation.
- Insert a StoreLoad barrier behind each volatile write operation.
- Insert a LoadLoad barrier behind each volatile read operation.
- Insert a LoadStore barrier behind each volatile read operation.
The above memory barrier insertion policy is very conservative, but it can ensure that the volatile memory semantics can be obtained in any processor platform and any program.
The following is a sequence of commands generated after volatile write inserts a memory barrier under a Conservative policy:
The StoreStore barrier in ensures that all common write operations before volatile write are visible to any processor. This is because the StoreStore barrier will ensure that all the above general writes are refreshed to the primary memory before volatile writes.
What is interesting here is the StoreLoad barrier behind volatile writing. This barrier prevents the volatile write and the subsequent volatile read/write operations from being reordered. 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 ). To ensure the correct implementation of volatile memory semantics, JMM adopts a Conservative policy here: Insert a StoreLoad barrier after each volatile write or before each volatile read. From the perspective of overall execution efficiency, JMM selects to insert a StoreLoad barrier behind each volatile write. Because the common usage mode of volatile write-Read Memory semantics is: one write thread writes the volatile variable, and multiple read threads read the same volatile variable. When the number of read threads exceeds the number of write threads, inserting the StoreLoad barrier after volatile write will significantly improve the execution efficiency. Here we can see a feature of JMM in implementation: ensure correctness first, and then pursue execution efficiency.
The following is the sequence of commands generated after volatile reads are inserted into the memory barrier under the Conservative policy:
The LoadLoad barrier in is used to prevent the processor from sorting the above volatile reads with the following common reads. The LoadStore barrier is used to prohibit the processor from sorting the above volatile reads and the following normal writes.
The above volatile write and volatile READ memory barrier insertion policies are very conservative. In actual execution, the compiler can omit unnecessary barrier as long as the volatile write-Read Memory semantics is not changed. The following is an example of code:
Class VolatileBarrierExample {int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite () {int I = v1; // The first volatile read int j = v2; // The Second volatile reads a = I + j; // normal writes v1 = I + 1; // The first volatile writes v2 = j * 2; // The Second volatile write }... // Other methods}
For the readAndWrite () method, the compiler can optimize the bytecode generation as follows:
Note that the last StoreLoad barrier cannot be omitted. Because the method returns immediately after the second volatile is written. At this time, the compiler may not be able to accurately determine whether there will be volatile reads or writes. For security reasons, the compiler often inserts a StoreLoad barrier here.
The above optimization is for any processor platform. Because different processors have different "ELASTIC" processor memory models, the insertion of memory barrier can be further optimized based on the specific processor memory model. Taking the x86 processor as an example, in addition to the last StoreLoad barrier, other barrier will be omitted.
The volatile read and write in the previous Conservative policy can be optimized:
As mentioned above, x86 processors only reorder write-read operations. X86 does not reorder read-read, read-write, and write-write operations. Therefore, the memory barriers corresponding to these three types of operations are omitted in the x86 processor. In x86, JMM only needs to insert a StoreLoad barrier after volatile write to correctly implement the memory semantics of volatile write-read. This means that the volatile write overhead is much higher than the volatile read overhead in the x86 processor (because the StoreLoad execution barrier overhead is relatively large ).
Why does JSR-133 enhance volatile memory semantics?
In the old Java Memory Model prior to the JSR-133, although the volatile variables are not allowed to be reordered, the old Java memory model allows the volatile variables to be reordered with common variables. In the old memory model, the VolatileExample sample program may be reordered into the following sequence for execution:
In the old memory model, when there is no data dependency between 1 and 2, 1 and 2 may be reordered (3 and 4 are similar ). The result is: when the read thread B executes 4, it may not be able to see the modification to the shared variable when the write thread A executes 1.
Therefore, in the old memory model, the memory semantics of volatile write-read release without locks. To provide a more lightweight mechanism for inter-thread communication than locks, the JSR-133 expert group decided to enhance the memory semantics of volatile: strictly limiting the reordering of volatile variables and common variables by compilers and processors, make sure that the volatile write-read and lock release-Get are the same and have the same memory semantics. From the perspective of compiler re-sorting rules and processor memory barrier insertion policies, as long as the re-sorting between volatile variables and common variables may damage the memory semantics of volatile, this sort will be disabled by the compiler's re-sorting rules and processor memory barrier insertion policies.
Volatile only ensures that the read/write operations on a single volatile variable are atomic, And the mutex execution feature of the lock ensures that the execution of the entire critical code is atomic. In terms of functionality, the lock is more powerful than volatile; in terms of scalability and execution performance, volatile is more advantageous. If the reader wants to replace the monitor lock with volatile in the program