Java concurrent programming series [Unfinished]:
•Java Concurrency Programming: Core Theory
•Java concurrent programming: Synchronized and its implementation principles
•Java concurrent programming: Synchronized underlying optimization (lightweight lock, biased lock)
•Java concurrent programming: collaboration between threads (wait/notify/sleep/yield/join)
•Java concurrent programming: the use of volatile and its principles
1. The role of volatile
In the article "Java Concurrency Programming: Core Theory", we have mentioned the problems of visibility, orderliness and atomicity. Usually, we can solve these problems through the Synchronized keyword. However, if you have an understanding of the principle of Synchronized, you should know that Synchronized is a relatively heavyweight operation and has a relatively large impact on the performance of the system. Therefore, if there are other solutions, we usually avoid using Synchronized to solve the problem. The volatile keyword is another solution provided in Java to solve the problems of visibility and orderliness. Regarding atomicity, it is also a point that everyone is prone to misunderstanding: a single read/write operation of volatile variables can ensure atomicity, such as long and double type variables, but it cannot guarantee the atomicity of i++ operations, because in essence, i++ is read and write operations twice.
2. Use of volatile
Regarding the use of volatile, we can use several examples to illustrate its usage and scenarios.
1. Prevent reordering
Let's analyze the reordering problem from one of the most classic examples. Everyone should be familiar with the implementation of the singleton model, and in a concurrent environment, we can usually use the double check locking (DCL) method to implement it. The source code is as follows:
package com.paddx.test.concurrent;public class Singleton { public static volatile Singleton singleton; /** * The constructor is private, prohibiting external instantiation*/ private Singleton() {}; public static Singleton getInstance() { if (singleton == null) { synchronized (singleton) { if (singleton == null) { singleton = new Singleton(); } } return singleton; }}Now let's analyze why we need to add the volatile keyword between the variable singleton. To understand this problem, you must first understand the object construction process. Instantiating an object can actually be divided into three steps:
(1) Allocate memory space.
(2) Initialize the object.
(3) Assign the address of the memory space to the corresponding reference.
However, since the operating system can reorder instructions, the above process may also become the following process:
(1) Allocate memory space.
(2) Assign the address of the memory space to the corresponding reference.
(3) Initialize the object
If this process is the process, an uninitialized object reference may be exposed in a multi-threaded environment, resulting in unpredictable results. Therefore, to prevent reordering of this process, we need to set the variable to a variable of type volatile.
2. Achieve visibility
The visibility problem mainly refers to one thread modifying the shared variable value, while the other thread cannot see it. The main reason for the visibility problem is that each thread has its own cache area - thread working memory. The volatile keyword can effectively solve this problem. Let’s look at the following examples to know its function:
package com.paddx.test.concurrent;public class VolatileTest { int a = 1; int b = 2; public void change(){ a = 3; b = a; } public void print(){ System.out.println("b="+b+";a="+a); } public static void main(String[] args) { while (true){ final VolatileTest test = new VolatileTest(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test.change(); } }).start(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test.print(); } }).start(); } }}Intuitively speaking, there are only two possible results for this code: b=3;a=3 or b=2;a=1. However, running the above code (maybe it takes a little longer), you will find that in addition to the previous two results, there is also a third result:
...... b=2;a=1b=2;a=1b=3;a=3b=3;a=3b=3;a=1b=3;a=3b=2;a=1b=3;a=3b=3;a=3b=3;a=3...
Why does a result such as b=3;a=1 appear? Under normal circumstances, if you execute the change method first and then execute the print method, the output result should be b=3;a=3. On the contrary, if you execute the print method first and then execute the change method, the result should be b=2;a=1. So how does the result of b=3;a=1 come out? The reason is that the first thread modifies the value a=3, but is invisible to the second thread, so this result occurs. If both a and b are changed to variables of volatile type and executed, the result of b=3;a=1 will never appear again.
3. Ensure atomicity
The issue of atomicity has been explained above. volatile can only guarantee atomicity for single read/write. This problem can be described in JLS:
17.7 Non-Atomic Treatment of double and long For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.Writes and reads of volatile long and double values are Always atomic. Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values. Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency's sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform Writes to long and double values atomically or in two parts. Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible compplications.
The content of this passage is roughly similar to what I described earlier. Because the operations of the two data types of long and double can be divided into two parts: high 32 bits and low 32 bits, ordinary long or double types may not be atomic. Therefore, everyone is encouraged to set the shared long and double variables to volatile types, which can ensure that single read/write operations of long and double are atomic in any case.
There is a problem that volatile variables guarantee atomicity, which is easily misunderstood. Now we will demonstrate this problem through the following program:
package com.paddx.test.concurrent;public class VolatileTest01 { volatile int i; public void addI(){ i++; } public static void main(String[] args) throws InterruptedException { final VolatileTest01 test01 = new VolatileTest01(); for (int n = 0; n < 1000; n++) { new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test01.addI(); } }).start(); } Thread.sleep(10000);//Wait for 10 seconds to ensure that the above program execution is completed System.out.println(test01.i); }}You may mistakenly believe that after adding the keyword volatile to the variable i, this program is thread-safe. You can try running the above program. Here are the results of my local run:
Maybe everyone runs the results differently. However, it should be seen that volatile cannot guarantee atomicity (otherwise the result should be 1000). The reason is also very simple. i++ is actually a composite operation, including three steps:
(1) Read the value of i.
(2) Add 1 to i.
(3) Write the value of i back to memory.
There is no guarantee that these three operations are atomic. We can ensure the atomicity of +1 operations through AtomicInteger or Synchronized.
Note: The Thread.sleep() method was executed in many places in the above sections of code, with the purpose of increasing the chance of concurrency problems and has no other effect.
3. The principle of volatile
Through the above examples, we should basically know what volatile is and how to use it. Now let’s take a look at how the underlying layer of volatile is implemented.
1. Visibility implementation:
As mentioned in the previous article, the thread itself does not directly interact with the main memory data, but completes the corresponding operations through the thread's working memory. This is also the essential reason why data between threads is invisible. Therefore, to achieve visibility of volatile variables, you can start directly from this aspect. There are two main differences between writing operations on volatile variables and ordinary variables:
(1) When modifying the volatile variable, the modified value will be forced to refresh the main memory.
(2) Modifying the volatile variable will cause the corresponding variable values in the working memory of other threads to fail. Therefore, when reading the value of this variable again, you need to read the value in main memory again.
Through these two operations, the visibility problem of volatile variables can be solved.
2. Orderly implementation:
Before explaining this problem, let’s first understand the happen-before rules in Java. The definition of Happen-before in JSR 133 is as follows:
Two actions can be ordered by a happens-before relationship.If one action happens before another, then the first is visible to and ordered before the second.
In layman's terms, if a happen-before b, any operations a does are visible to b. (Everyone must remember this, because the word happen-before is easily misunderstood as before and after time). Let's take a look at what happen-before rules are defined in JSR 133:
• Each action in a thread happens before every subsequent action in that thread. • An unlock on a monitor happens before every subsequent lock on that monitor. • A write to a volatile field happens before every subsequent read of that volatile. • A call to start() on a thread happens before any actions in the started thread. • All actions in a thread happen before any other thread successfully returns from a join() on that thread. • If an action a happens before an action b, and b happens before an action c, then a happens before c.
Translated as:
•The previous operation happen-before in the same thread. (i.e., in a single thread, it is legal to execute in code order. However, the compiler and processor can reorder without affecting the execution results in a single thread environment. In other words, this is that the rules cannot guarantee compilation reordering and instruction reordering).
•Unlock operation on the monitor happen-before its subsequent locking operation. (Synchronized rules)
•Write operation to volatile variable happen-before subsequent read operations. (volatile rules)
•The start() method of the thread happens-before all subsequent operations of the thread. (Thread start rule)
•All operations of thread happen-before Other threads call join on this thread and return the successful operation.
•If a happen-before b, b happen-before c, then a happen-before c (transitive).
Here we mainly look at the third rule: the rules to ensure orderliness of volatile variables. The article "Java Concurrency Programming: Core Theory" mentioned that reordering is divided into compiler reordering and processor reordering. To implement volatile memory semantics, JMM restricts the reordering of these two types of volatile variables. The following is the table of reordering rules specified by JMM for volatile variables:
| Can Reorder | 2nd operation | |||
| 1st operation | Normal Load Normal Store | Volatile Load | Volatile Store | |
| Normal Load Normal Store | No | |||
| Volatile Load | No | No | No | |
| Volatile store | No | No | ||
3. Memory barrier
In order to implement volatile visibility and happen-befor semantics. The underlying JVM is done through something called a "memory barrier". Memory barrier, also known as memory fence, is a set of processor instructions used to implement sequential restrictions on memory operations. Here is the memory barrier required to complete the above rules:
| Required barriers | 2nd operation | |||
| 1st operation | Normal Load | Normal Store | Volatile Load | Volatile Store |
| Normal Load | LoadStore | |||
| Normal Store | StoreStore | |||
| Volatile Load | LoadLoad | LoadStore | LoadLoad | LoadStore |
| Volatile Store | StoreLoad | StoreStore | ||
(1) LoadLoad barrier
Execution order: Load1―>Loadload―>Load2
Ensure that Load2 and subsequent Load instructions can access the data loaded by Load1 before loading data.
(2) StoreStore barrier
Execution order: Store1―>StoreStore―>Store2
Ensure that the data of Store1 operation is visible to other processors before Store2 and subsequent Store instructions are executed.
(3) LoadStore barrier
Execution order: Load1―>LoadStore―>Store2
Ensure that before Store2 and subsequent Store instructions are executed, the data loaded by Load1 can be accessed.
(4) StoreLoad barrier
Execution order: Store1―> StoreLoad―>Load2
Make sure that before Load2 and subsequent Load instructions are read, Store1's data is visible to other processors.
Finally, I can use an example to illustrate how the memory barrier is inserted in the JVM:
package com.paddx.test.concurrent;public class MemoryBarrier { int a, b; volatile int v, u; void f() { int i, j; i = a; j = b; i = v; //LoadLoad j = u; //LoadStore a = i; b = j; //StoreStore v = i; //StoreStore u = j; //StoreLoad i = u; //LoadLoad //LoadStore j = b; a = i; }}4. Summary
Overall, understanding volatile is still relatively difficult. If you don’t understand it in particular, you don’t need to hurry. It takes a process to fully understand it. You will also see the usage scenarios of volatile many times in subsequent articles. Here I have a basic understanding of the basic knowledge of volatile and the original one. Generally speaking, volatile is an optimization in concurrent programming, which can replace Synchronized in some scenarios. However, volatile cannot completely replace the position of Synchronized. Only in some special scenarios can volatile be applied. In general, the following two conditions must be met at the same time to ensure thread safety in a concurrent environment:
(1) The write operation to variables does not depend on the current value.
(2) This variable is not included in the invariant with other variables.
The above article on Java concurrent programming: the use of volatile and its principle analysis is all the content I share with you. I hope you can give you a reference and I hope you can support Wulin.com more.