The main content of this article is a common knowledge point in Java interview: volatile keywords. This article introduces all aspects of volatile keywords in detail. I hope that after reading this article, you can perfectly solve the related problems of volatile keywords.
In Java-related job interviews, many interviewers like to examine the interviewer's understanding of Java concurrency. Using the volatile keyword as a small entry point, you can often ask the Java memory model (JMM) and some features of Java concurrent programming. In depth, you can also examine the underlying JVM implementation and operating system related knowledge. Let’s take a hypothetical interview process to gain an in-depth understanding of the volitile keyword!
As far as I understand, shared variables modified by volatile have the following two characteristics:
1. Ensure memory visibility of different threads to the variable operation;
2. Prohibit command reordering
This is a lot to talk about, so I'll start with the Java memory model. The Java virtual machine specification attempts to define a Java memory model (JMM) to block out memory access differences between various hardware and operating systems, so that Java programs can achieve consistent memory access effects on various platforms. Simply put, since the CPU executes instructions very quickly, the memory access speed is much slower, and the difference is not an order of magnitude, the big guys who work on the processor have added several layers of cache to the CPU. In the Java memory model, the above optimization is abstracted again. JMM stipulates that all variables are in main memory, similar to the ordinary memory mentioned above, and each thread contains its own working memory. It can be regarded as a register or cache on the CPU for easy understanding. Therefore, thread operations are mainly based on working memory. They can only access their own working memory, and they must synchronize the value back to main memory before and after work. I don't even know what I said so, take a piece of paper to draw:
When executing a thread, the value of the variable will be first read from the main memory, then loaded to the copy in the working memory, and then passed it to the processor for execution. After the execution is completed, the copy in the working memory will be assigned a value, and then the value in the working memory will be passed back to the main memory, and the value in the main memory will be updated. Although the use of working memory and main memory is faster, it also brings some problems. For example, look at the following example:
i = i + 1;
Assuming that the initial value of i is 0, when only one thread executes it, the result will definitely get 1. When two threads execute, will the result get 2? This is not necessarily the case. This may be the case:
Thread 1: load i from main memory // i = 0 i + 1 // i = 1 Thread 2: load i from main memory // Because thread 1 has not written the value of i back to main memory, i is still 0 i + 1 //i = 1 Thread 1: save i to main memory thread 2: save i to main memory
If two threads follow the above execution process, then the last value of i is actually 1. If the last write back is slow, and you can read the value of i again, it may be 0, which is the cache inconsistency problem. The following is to mention the question you just asked. JMM is mainly established around how to deal with the three characteristics of atomicity, visibility and order in the concurrency process. By solving these three problems, the problem of cache inconsistency can be solved. And volatile is related to visibility and orderliness.
1. Atomicity: In Java, the reading and assignment operations of basic data types are atomic operations. The so-called atomic operations mean that these operations are uninterruptible and must be completed for a certain period of time, or they will not be executed. for example:
i = 2;j = i;i++;i = i + 1;
Among the above four operations, i=2 is a read operation, which must be an atomic operation. j=i thinks it is an atomic operation. In fact, it is divided into two steps. One is to read the value of i, and then assign the value to j. This is a 2-step operation. It cannot be called an atomic operation. i++ and i = i + 1 are actually equivalent. Read the value of i, add 1, and write it back to the main memory. That is a 3-step operation. Therefore, in the above example, the last value may have many situations because it cannot satisfy the atomicity. In this way, there is only simple reading. Assignment is an atomic operation, or only numerical assignment. If you use variables, there is an additional operation to read the variable value. An exception is that the virtual machine specification allows 64-bit data types (long and double) to be processed in 2 operations, but the latest JDK implementation still implements atomic operations. JMM only implements basic atomicity. Operations like the i++ above must be synchronized and Lock to ensure the atomicity of the entire code. Before the thread releases the lock, it will inevitably brush the value of i back to the main memory. 2. Visibility: Speaking of visibility, Java uses volatile to provide visibility. When a variable is modified by volatile, the modification to it will be immediately refreshed to the main memory. When other threads need to read the variable, the new value will be read in memory. This is not guaranteed by ordinary variables. In fact, synchronized and Lock can also ensure visibility. Before the thread releases the lock, it will flush all the shared variable values back to the main memory, but synchronized and Lock are more expensive. 3. Ordering JMM allows the compiler and processor to reorder instructions, but stipulates the as-if-serial semantics, that is, no matter how reordering, the execution result of the program cannot be changed. For example, the following program segment:
double pi = 3.14; //Adouble r = 1; //Bdouble s= pi * r * r;//C
The above statement can be executed in A->B->C, with the result being 3.14, but it can also be executed in the order of B->A->C. Because A and B are two independent statements, while C depends on A and B, A and B can be reordered, but C cannot be ranked first in A and B. JMM ensures that reordering will not affect the execution of a single thread, but problems are prone to occur in multi-threading. For example, code like this:
int a = 0;bool flag = false;public void write() { a = 2; //1 flag = true; //2}public void multiply() { if (flag) { //3 int ret = a * a;//4 }}If two threads execute the above code segment, thread 1 first executes write, then thread 2 then executes multiply, will the value of ret must be 4? The result is not necessarily:
As shown in the figure, 1 and 2 in the write method are reordered. Thread 1 first assigns the flag to true, then executes it to thread 2, ret directly calculates the result, and then to thread 1. At this time, a is assigned to 2, which is obviously one step later. At this time, you can add the volatile keyword to the flag, prohibit reordering, which can ensure the orderliness of the program, and you can also use heavyweight synchronized and lock to ensure orderliness. They can ensure that the code in that area is executed at one time. In addition, JMM has some innate order, that is, orderliness that can be guaranteed without any means, which is usually called the happens-before principle. <<JSR-133: Java Memory Model and Thread Specification>> defines the following happens-before rules: 1. Program sequence rules: For each operation in a thread, happens-before is used for any subsequent operations in the thread 2. Monitor lock rules: Unlock a thread, happens-before is used for subsequent locking of this thread 3. volatile variable rules: Write a volatile domain, happens-before is used for subsequent reading of this volatile domain 4. Transitiveness: If A happens-before B and B happens-before C, then A happens-before C 5.start() rules: If thread A performs an operation ThreadB_start() (start thread B), Then ThreadB_start() happens-before of thread A's arbitrary operation in B 6.join() principle: If A executes ThreadB.join() and returns successfully, then any operation in thread B happens-before of thread A successfully returns from ThreadB.join() operation in thread A. 7. Interrupt() principle: The call to the thread interrupt() method occurs first when the interrupt event is detected by the interrupted thread code. You can use the Thread.interrupted() method to detect whether there is an interruption. 8. finalize() principle: The initialization completion of an object occurs first when the finalize() method begins. The first rule of the program sequence rule says that in a thread, all operations are in sequence, but in JMM, as long as the execution result is the same, reordering is allowed. The focus of happens-before here is also the correctness of the single-thread execution result, but it cannot be guaranteed that the same is true for multi-threading. Rule 2 The monitor rules are actually easy to understand. Before adding the lock, you can only continue to add the lock. Rule 3 applies to the volatile in question. If one thread writes a variable first and another thread reads it, then the write operation must be before the read operation. The fourth rule is the transitiveness of happens-before. I won’t go into details about the following few.
Then we need to re-mentioned volatile variable rules: write a volatile domain, happens-before to read this volatile domain later. Let me take this out again. In fact, if a variable is declared as volatile, then when I read the variable, I can always read its latest value. Here, the latest value means that no matter which other thread writes the variable, it will be updated to the main memory immediately. I can also read the newly written value from the main memory. In other words, the volatile keyword can ensure visibility and orderliness. Let's take the above code as an example:
int a = 0;bool flag = false;public void write() { a = 2; //1 flag = true; //2}public void multiply() { if (flag) { //3 int ret = a * a;//4 }}This code is not only troubled by reordering, even if 1 and 2 are not reordered. 3 won't be executed so smoothly either. Suppose that thread 1 executes the write operation first, and thread 2 then performs the multiply operation. Since thread 1 assigns flag to 1 in working memory, it may not be written back to main memory immediately. Therefore, when thread 2 executes, multiply reads the flag value from main memory, which may still be false, so the statements in brackets will not be executed. If changed to the following:
int a = 0;volatile bool flag = false;public void write() { a = 2; //1 flag = true; //2}public void multiply() { if (flag) { //3 int ret = a * a;//4 }}Then thread 1 executes write first, and thread 2 then executes multiply. According to the happens-before principle, this process will satisfy the following three types of rules: Program order rules: 1 happens-before 2; 3 happens-before 4; (volatile restricts instruction reordering, so 1 is executed before 2) volatile rules: 2 happens-before 3 Transitive rules: 1 happens-before 4 When writing a volatile variable, JMM will flush the shared variable in the local memory corresponding to the thread to the main memory. When reading a volatile variable, JMM will set the local memory corresponding to the thread to invalidate, and the thread will read the shared variable from the main memory next.
First of all, my answer is that the atomicity cannot be guaranteed. If it is guaranteed, it is only atomicity for the reading/writing of a single volatile variable, but there is nothing to do with compound operations like volatile++, such as the following example:
public class Test { public volatile int inc = 0; public void increase() { inc++; } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) // Ensure that the previous threads have completed Thread.yield(); System.out.println(test.inc); }Logically speaking, the result is 10,000, but it is likely to be a value less than 10,000 when running. Some people may say that volatile does not guarantee visibility. One thread should see the modifications to inc by inc, and the other thread should immediately see it! But the operation inc++ here is a composite operation, including reading the value of inc, increasing it by itself, and then writing it back to the main memory. Suppose thread A reads the value of inc to be 10, and it is blocked at this time because the variable is not modified and the volatile rule cannot be triggered. Thread B also reads the value of inc at this time. The value of inc in the main memory is still 10, and it will be automatically increased, and then it will be written back to the main memory, which is 11. At this time, it is thread A's turn to execute. Since 10 is saved in the working memory, it continues to increase itself and writes back to the main memory. 11 is written again. So although the two threads executed increase() twice, they only added once. Some people say, doesn't volatile invalidate the cache line? However, before thread A reads thread B and performs operations, the inc value is not modified, so when thread B reads, it still reads 10. Some people also say that if thread B writes 11 back to main memory, will not set thread A's cache line to invalidate? However, thread A has already done the read operation. Only when the read operation is done and the cache line is invalid will it read the main memory value. Therefore, thread A can only continue to do self-increment. To sum up, in this kind of composite operation, the atomic function cannot be maintained. However, in the above example of setting flag value, since the read/write operation of flags is single-step, it can still ensure atomicity. To ensure atomicity, we can only use synchronized, Lock and atomic atomic operation classes under concurrent packets, that is, the self-increment (add 1 operation), self-decrease (reduce 1 operation), addition operation (add a number), and subtraction operation (subtract one number) of the basic data types to ensure that these operations are atomic operations.
If you generate assembly code with the volatile keyword and the code without the volatile keyword, you will find that the code with the volatile keyword will have an additional lock prefix instruction. The lock prefix instruction is actually equivalent to a memory barrier. The memory barrier provides the following functions: 1. When reordering, the following instructions cannot be reordered to the location before the memory barrier 2. Make the cache of this CPU written to memory ** ** 3. The write action will also cause other CPUs or other kernels to invalidate their cache, which is equivalent to making the newly written value visible to other threads.
1. Status quantity mark, just like the flag above, I will mention it again:
int a = 0;volatile bool flag = false;public void write() { a = 2; //1 flag = true; //2}public void multiply() { if (flag) { //3 int ret = a * a;//4 }}This read and write operation to variables, marked as volatile, can ensure that modifications are immediately visible to the thread. Compared with synchronized, Lock has a certain efficiency improvement. 2. Implementation of singleton mode, typical double check lock (DCL)
class Singleton{ private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { synchronized (Singleton.class) { if(instance==null) instance = new Singleton(); } } return instance; }}This is a lazy singleton pattern, objects are created only when used, and to avoid reordering instructions for initialization operations, volatile is added to instance.
The above is the entire content of this article about explaining the volatile keywords that Java interviewers love to ask about in detail. I hope it will be helpful to everyone. Interested friends can continue to refer to other related topics on this site. If there are any shortcomings, please leave a message to point it out. Thank you friends for your support for this site!