One article to understand | volatile keyword in Java

Preface

Nice to meet you~

The volatile keyword plays a very important role in Java multi-threaded programming. Reasonable use can reduce many thread safety issues. But in fact, it can be found that very few developers use this keyword, including myself. When encountering synchronization problems, the first thing that comes to mind must be locking, that is, the synchronize keyword. Violent locks solve all multi-threading problems. However, the cost of locking is high. Thread blocking and system thread scheduling problems will cause serious performance impacts. If volatile is used in some suitable scenarios, it not only ensures thread safety, but also greatly improves performance .

Then why don't you put the useful volatile, but you have to lock it? A very important reason is: I don't understand what volatile is. Locking is simple and rude, almost every developer will use it (but not necessarily correctly), and volatile may not even know that there is this thing (including the previous author=_=). What is volatile? What role does he have? In what scenarios does it apply? What is his underlying principle? Can he really guarantee thread safety? This series of questions are common questions related to interviews, and they are exactly what this article will solve.

So, let's get started.

Know volatile

The volatile keyword has two functions: variable modification is immediately visible to other threads, and instruction rearrangement is prohibited.

Let's talk about the second role later, but let's talk about the first role first. In layman's terms, if I modify a variable in one thread, other threads will immediately know that I modified it. Ok? Did I modify the value other threads don’t know? Let us first experience the first effect of the volatile keyword from the example code.

private  boolean stopSignal = false;

public void fun(){
    
    
    // 创建10个线程
    for (int i=0;i<=10;i--){
    
    
        new Thread(() -> {
    
    
            while(!stopSignal){
    
    
                // 循环等待
            }
            System.out.println(Thread.currentThread().toString()+"我停下来了");
        }).start();
    }
    new Thread(() -> {
    
    
        stopSignal = true;
        System.out.println("给我停下来");
    }).start();
}

This code is very simple. Create 10 threads to wait in a loop stopSignal. When it stopSignalbecomes true, it will jump out of the loop and print the log. Then we open another thread and change it stopSignalto true. If you follow the normal situation, it should print "Stop for me" first, then print 10 "I stopped", and finally end the process. Let's take a look at the specific situation. Come, run:

Huh? Why did I stop printing only two? And look at the stop symbol on the left, indicating that the process has not ended. That is to say, in the remaining threads, the stopSignaldata they get is still false, not the latest true. So the problem is: the modification of variables in a thread is not immediately visible to other threads . We will talk about the cause of this problem later, and how to solve this problem now. Locking is a good way. As long as we add a lock when looping to judge and modify the value, we can get the latest data. But as mentioned earlier, the lock is a heavyweight operation, and then with the circulation, this performance is estimated to fall directly into the sewer. The best solution is to add the volatile keyword to the variable, as follows:

private volatile  boolean stopSignal = false;

Let's run it again:

Oh, it's okay, all stopped. The variable that uses the volatile key modification, as long as it is modified, other threads will be immediately visible. This is the first important role of the volatile keyword: variable modifications are immediately visible to other threads . We will talk about order rearrangement later.

So why is the modification of the variable not immediately visible to other threads? Why can volatile achieve this effect? Then can we add the volatile keyword to every variable? To solve these problems, we have to start with the Java memory model. Fasten your seat belts and our car is ready to drive into the bottom principle.

Java memory model

The Java memory model is not the heap area, stack area, method area, but the model of how to share data between threads, which can also be said to be the specification for threads to read and write shared data. The memory model is the basis for understanding Java concurrency issues. Due to space limitations, I will not discuss it in depth here, but only briefly introduce it. Otherwise, it will be a long essay again. First look at a picture:

JVM divides the overall memory into two parts: thread private and thread shared. The thread shared area is also called main memory . Concurrency problems will not occur in the private part of the thread, so the main focus is on the thread shared area. This picture can be roughly understood as follows:

  1. All shared variables are stored in main memory
  2. Each thread has its own working memory
  3. The working memory retains a copy of the main memory of the variables used by the thread
  4. Variable operations must be performed in working memory
  5. Different threads cannot access each other's working memory

To summarize briefly, all data must be placed in the main memory. To manipulate the data, a thread must first copy it to its own working memory, and then only modify the data in its own working memory; after the modification is completed, write it back to the main memory. RAM. A thread cannot access another thread's data, which is why there is no concurrency problem with thread-private data.

So why not modify the data directly from the main memory, but write it back to the main memory after modifying the working memory? This involves the design of a high-speed buffer. To put it simply, the processor is very fast, but it needs to frequently read and write data in the memory during execution, and the speed of memory access is far behind the speed of the cpu, which reduces the efficiency of the cpu. Therefore, a high-speed buffer is designed, and the processor can directly manipulate the data in the high-speed buffer, wait until idle time, and then write the data back to the main memory, which improves performance. In order to shield the design of high-speed buffers by different platforms, JVM has designed a Java memory model so that developers can program for a unified memory model. As you can see, the working memory here corresponds to the high-speed buffer at the hardware level.

Order rearrangement

We haven't talked about instruction rearrangement before, because this is an optimization performed by the JVM in the back-end compilation stage, and the hidden problems in the code are difficult to reproduce, and it is difficult to see the difference through code execution. Instruction rearrangement is an optimization of the bytecode compilation process of the just-in-time compiler, which is affected by the runtime environment. Limited to ability, the author can only talk about this issue through theoretical analysis.

We know that JVM generally has two ways to execute bytecode: interpreter execution and just-in-time compiler execution. The interpreter is easier to understand, because it interprets and executes lines of code, so there is no problem of instruction rearrangement. However, there are big problems with interpretation and execution: interpretation of the code takes a certain amount of processing time and cannot optimize the results of the compilation . Therefore, interpretation and execution are generally used when the application is just started or when an exception is encountered in the instant compilation. On the other hand, just-in-time compilation is to compile the hotspot code into machine code in the running process, and when the code is run, the machine code can be directly executed without interpretation, which improves performance . In the compilation process, we can optimize the compiled machine code. As long as the running results are consistent, we can optimize the machine code according to the characteristics of the computer world, such as method inlining, common sub-expression elimination, etc., instructions Rearrangement is one of them.

Computer thinking is different from our human thinking. We program according to object-oriented thinking, but computers must execute according to "process-oriented" thinking. Therefore, the JVM will change the execution order of the code without affecting the execution result . Note that this does not affect the execution result, but refers to the current thread. For example, we may initialize a component in a thread, and set the flag to true when the initialization is completed, and then other threads only need to monitor the flag to monitor whether the initialization is complete, as follows:

// 初始化操作
isFinish = true;

In the current thread, note that it seems that the initialization operation will be performed first, and then the assignment operation will be performed, because the result is in line with expectations . but! ! ! In the eyes of other threads, the entire execution order is messy. The JVM may perform the isFinishassignment operation first , and then perform the initialization operation; and if you monitor isFinishchanges in other threads , there may be isFinisha problem that the initialization is not completed but it is true. The volatile may prohibit instruction rearrangement ensure that isFinishbefore being assigned, all actions have been completed initialization .

The specific definition of volatile

The above mentioned two knowledge points that seem to have little to do with our protagonist volatile, but they are actually very important knowledge points.

First of all, through the understanding of the Java memory model, you now know why there is a reason why threads modify variables that are not immediately known to other threads, right? **After the thread modifies the variable, it may not immediately write back to the main memory, and other threads, after the main memory data is updated, will not immediately go to the main memory to obtain the latest data. **This is also the problem.

The variable modified by the volatile keyword stipulates that the data must be obtained from the main memory each time it is used; the data must be synchronized to the main memory immediately after each modification . In this way, each thread can immediately receive the modification information of the variable. There will be no situation of reading dirty data and old data.

The second function is to prohibit order rearrangement. Here is a rule that uses the JVM: all operations before the synchronized memory operation must have been completed . And we know that every time volatile is assigned, it will be synchronized to the main memory. Therefore, before synchronization, ensure that all operations must be completed. So when other threads monitor the change of the variable, the operations before the assignment must have been completed.

Since the volatile keyword is so good, can it be used everywhere? Of course not! When talking about the Java memory model earlier, it was mentioned that in order to improve the efficiency of the cpu, the high-speed buffer was separated. If a variable does not need to ensure thread safety, then frequent writing and reading of memory is a huge performance consumption. Therefore, use volatile only where it must be used.

Are volatile modified variables necessarily thread-safe?

First of all, make it clear, what is thread-safe?

  1. An operation must comply with atomicity, either not executed, or completed at one time, and will not be affected by other operations during the execution.
  2. Changes to variables must be immediately visible relative to other threads.
  3. The execution of the code appears to be orderly in the eyes of other threads.

From the above analysis, we have talked about: the orderliness of the code, the visibility of the modification , but the lack of atomicity. In the JVM, reading and writing in the main memory all satisfy the atomicity. This is the atomicity guaranteed by the JVM. Isn't volatile thread-safe? Not really.

The calculation process of JVM is not atomic. Let's take an example, look at the code:

private volatile int num = 0;

public void fun(){
    
    
    for (int k=0;k<=10;k++){
    
    
        new Thread(() -> {
    
    
            int a = 10000;
            while (a > 0) {
    
    
                a--;
                num++;
            }
            System.out.println(num);
        }).start();
    }
}

According to the normal situation, the final output should be 100000. Let's look at the running results:

Why is it more than 50,000, shouldn't it be 100,000? This is because volatile only ensures that the modified data is immediately visible to other threads, but does not guarantee the atomicity of the operation process.

Here num++after compilation is divided into three steps: 1. Remove the variable data in the work area to the data processor 2. The processor is added in an operation to write the data back 3. The working memory. If, in the process of fetching the variable data to the processor, the variable has been modified, so the result of the increment operation at this time is wrong. for example:

Variable a=5, the current thread takes 5 to the processor, and at this time it ais changed to 8 by other threads, but the processor continues to work, incrementing 5 to 6, and then writing 6 back to main memory, overwriting data 8, which caused an error. Therefore, due to the non-atomic nature of Java operations, volatile is not absolutely thread safe .

When is he safe:

  1. The result of the calculation does not depend on the original state.
  2. There is no need to participate in the invariant constraint with other state variables.

In layman's terms, calculations do not need to rely on any state calculations. Because of the dependent state, it may have changed during the operation, and the processor does not know it. If it involves operations that need to rely on state, you must use other thread-safe solutions, such as locking, to ensure the atomicity of the operation.

Applicable scene

Status flag/multi-thread notification

The status flag is very suitable for using the volatile keyword, as we gave in the first part of the example, by setting the flag to notify all other threads to execute logic. Or as the example in the previous part, when the initialization is complete, a flag is set to notify other threads.

A similar scenario that is more commonly used is thread notification. We can set a variable, and then multiple threads observe it. In this way, you only need to change this value in one thread, and then other threads that observe this variable can be notified. Very light weight and simpler in logic.

Ensure complete initialization: double lock check problem

Singleton mode is often used by us. One of the more common ways of writing is double lock checking, as follows:

public class JavaClass {
    
    
	// 静态内部变量
   private static JavaClass javaClass;
    // 构造器私有
   private JavaClass(){
    
    }
    
   public static JavaClass getInstance(){
    
    
       // 第一重判空
       if (javaClass==null){
    
    
           // 加锁,第二重判空
           synchronized(JavaClass.class){
    
    
               if (javaClass==null){
    
    
                   javaClass = new JavaClass();
               }
           }
       }
       return javaClass;
   }
}

This kind of code is very familiar, right, I will not expand the specific design logic. So is such code absolutely thread-safe? No, in some extreme cases, problems still occur. The problem lies in javaClass = new JavaClass();this code.

Creating a new object is not an atomic operation, it has three main sub-operations:

  1. Allocate memory space
  2. Initialize the Singleton instance
  3. Assign instance instance reference

Under normal circumstances, it is executed in this order. But the JVM will optimize the instruction rearrangement. May become:

  1. Allocate memory space
  2. Assign instance instance reference
  3. Initialize the Singleton instance

Assignment reference is before initialization, then the reference obtained externally may be an incompletely initialized object, which causes problems. Therefore, you can modify the singleton object with volatile and restrict the rearrangement of instructions, so this situation will not happen. Of course, regarding the singleton mode, the more recommended way of writing is to use class loading to ensure global singleton and thread safety. Of course, the premise is that you have to ensure that there is only one class loader. Due to space limitations, it will not be expanded here.

Other types of initialization flags can also use the volatile keyword to limit instruction rearrangement.

to sum up

There is a lot of knowledge about concurrent programming in Java, and volatile is just the tip of the iceberg. The difficulty of concurrent programming is that its bugs are deeply hidden, and the problem may not be found after several rounds of testing, but it crashes as soon as it goes online, and it is extremely difficult to reproduce and find the cause. Therefore, it is very important to learn concurrency principles and concurrent programming ideas. At the same time, we must pay more attention to principles. Once you have mastered the principles and essence, then other related knowledge will be at your fingertips.

Hope the article is helpful to you.

This is the full text. It is not easy to be original. If it is helpful, you can like it, bookmark it and forward it.
The author is very knowledgeable, and if you have any ideas, please feel free to communicate and correct in the comment section.
If you need to reprint, please comment section or private message exchange.

Also welcome to my personal blog: Portal

Guess you like

Origin blog.csdn.net/weixin_43766753/article/details/109681733