preface

Nice to meet you ~ welcome to read my article.

The volatile keyword plays an important role in Java multithreaded programming, and its proper use can reduce many thread safety issues. But you can see that very few developers use this keyword, including myself. When encountering a synchronization problem, the first thing that comes to mind is the keyword synchronize, which is a violent lock to solve all problems caused by multiple threads. But the price of locking is high. Problems such as thread blocking and system thread scheduling can have a serious performance impact. Using volatile, when appropriate, provides both thread-safety and significant performance gains.

Why lock it when volatile is good? One important reason: not knowing what volatile is. Locking is crude and almost every developer will use it (though not always correctly), while volatile is probably not even known to exist (including our previous author =_=). So what is volatile? What does he do? In what scenario? What are his underlying principles? Can he really be thread safe? This series of questions, is a common interview related topics, and it is this article to solve the problem.

So, let’s get started.

Know the volatile

The volatile keyword serves two purposes: variable changes are immediately visible to other threads, and instruction rearrangements are prohibited.

We’ll talk about the second one later, but we’ll focus on the first one. In plain English, if I change a variable in one thread, other threads will immediately know that I changed it. Huh? Did I change the value without other threads knowing? Let’s get a sense of the first effect of the volatile keyword from example code.

private  boolean stopSignal = false;

public void fun(a){
    // Create 10 threads
    for (int i=0; i<=10; i++){new Thread(() -> {
            while(! stopSignal){// Loop to wait
            }
            System.out.println(Thread.currentThread().toString()+"I stopped.");
        }).start();
    }
    new Thread(() -> {
        stopSignal = true;
        System.out.println("Stop it.");
    }).start();
}
Copy the code

This code is very simple. Create 10 threads to loop and wait for stopSignal. When stopSignal is true, it breaks out of the loop and prints a log. Then we start another thread and change stopSignal to true. Under normal circumstances, it would print “stop for me”, then print 10 “I stopped”, and end the process. Let’s see what happens. To run:

Uh huh? Why only print two I stopped? And if you look at the stop symbol on the left, it means that the process is not finished. This means that for the rest of the thread, they will still get stopSignal false instead of the latest true. So the problem is that changes to variables in one thread are not immediately visible to other threads. And we’re going to talk about what caused this problem, and how we’re going to solve this problem. Locking is a good idea, as long as we add a lock in the loop to judge and modify the value, we can get the latest data. But as mentioned earlier, locking is a heavyweight operation, and then add the loop, the performance will probably go straight down the drain. The best solution is to make the variable volatile, as follows:

private volatile  boolean stopSignal = false;
Copy the code

Let’s run it again:

Oh, that’s it. It’s all stopped. Variables that use the volatile key modifier are immediately visible to other threads as soon as they are modified. This is the first important use of the volatile keyword: variable changes are immediately visible to other threads. We’ll talk more about reordering later.

So why are changes to variables not immediately visible to other threads? Why does volatile achieve this effect? Can we make each variable volatile? To solve these problems, we need to start with the Java memory model. Buckle up, we’re ready to drive into the ground floor.

Java memory model

The Java memory model is not the heap area, stack area, method area and so on. It is a model of how data is shared between threads. It is also a specification of how threads read and write shared data. The memory model is fundamental to understanding Java concurrency problems. Limited to space here not in depth, just a brief introduction. It’s gonna be another swastika. Here’s a picture:

The JVM generally divides memory into two parts: thread private and thread shared, which is also called primary memory. The private part of the thread does not have concurrency issues, so the main focus is on the thread shared area. The graph can be roughly interpreted as follows:

  1. All shared variables are stored in main memory
  2. Each thread has its own working memory
  3. Working memory holds a primary memory copy of 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, all data is stored in main memory, and the thread must first copy the data to its own working memory and then modify the data in its own working memory. When the changes are complete, write them back to main memory. One thread cannot access another thread’s data, which is why there is no concurrency problem with thread-private data.

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

Instruction rearrangement

The reason we haven’t talked about reordering is because it’s an optimization that the JVM does at the back-end of compilation, and hidden problems in the code are hard to reproduce and hard to see through code execution. Instruction reordering is an optimization of bytecode compilation by the just-in-time compiler, and is influenced by the runtime environment. Limited to ability, the author can only talk about this problem through theoretical analysis.

We know that the JVM typically executes bytecode in two ways: interpreter execution and just-in-time compiler execution. The interpreter is relatively easy to understand, just lines of code explaining execution, so there is no reordering problem. However, interpreted execution has a major problem: interpreted code takes a certain amount of processing time and cannot be optimized for compilation results, so interpreted execution is usually used when the application is just started or when an exception is encountered during compilation. Just-in-time compilation is to compile the hot code into machine code during the running process, and execute the machine code directly when the code is run without explanation, thus improving the performance. In the compilation process, we can optimize the compiled machine code. As long as the results are consistent, we can optimize the machine code according to the characteristics of the computer world, such as method inlining, common subex elimination, and so on. Instruction rearrangement is one of them.

Computers don’t think like us. We program with an object-oriented mind, but computers have to execute with a process-oriented mind. So, the JVM changes the order in which the code is executed without affecting the results. Note that this does not affect the result of execution, meaning under the current thread. For example, we might initialize a component in one thread and set the flag bit to true when the initialization is complete. Then other threads only need to listen for the flag bit to hear whether the initialization is complete, as follows:

// Initialize the operation
isFinish = true;
Copy the code

In the case of the current thread, notice that it looks like the initialization is done first, and then the assignment is done, because the result is expected. But!! From the perspective of other threads, this entire order of execution is out of order. The JVM may perform isFinish assignment before initialization; However, if you listen for isFinish changes on another thread, you may have a problem that isFinish is true before it is initialized. Volatile prevents instruction reordering, ensuring that all initializations are complete before isFinish is assigned.

Specific definitions of volatile

Here are two things that may seem irrelevant to our hero, volatile, but are very important.

First of all, with your understanding of the Java memory model, now you know why a thread makes changes to a variable that are not immediately known to the other thread? A thread may not write back to main memory immediately after modifying a variable, and other threads may not go to main memory to get the latest data immediately after the main memory data is updated. That’s the problem.

Variables decorated with the volatile keyword specify that data must be retrieved from main memory each time it is used; Data must be immediately synchronized to main memory after each modification. This allows each thread to receive changes to the variable immediately. Dirty old data will not be read.

The second function is to prevent order reordering. This is using a JVM stipulation that all operations must be completed before a memory operation can be synchronized. And we know that every time volatile is assigned, it is synchronized to main memory. So, before synchronization, make sure everything is done. So when another thread detects a change in a variable, everything that was done before the assignment must have been done.

Since the volatile keyword is so good, can it be used everywhere? Of course not! As mentioned earlier in the Java memory model, the cache was separated to improve CPU efficiency. If a variable does not need to be thread-safe, frequent memory writes and reads can be a significant performance drain. Therefore, use volatile only where they must be used.

Are volatile variables necessarily thread-safe

Just to be clear, what is thread-safe?

  1. An operation must be atomic, either not performed at all, or executed at once without being affected by other operations.
  2. Changes to variables relative to other threads must be immediately visible.
  3. The execution of code must be ordered in the eyes of other threads.

From the above analysis we talked about: code order, modification visibility, but, the lack of atomicity. In the JVM, all reads and writes in main memory are atomically guaranteed by the JVM. Wouldn’t volatile be thread-safe? And it isn’t.

The JVM’s computation process is not atomic. Let’s take an example and look at the code:

private volatile int num = 0;

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

In the normal case, the final output should be 100000. Let’s look at the result:

Why $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 atomicity of the operation.

1. Fetch variable data from workspace to processor 2. Add one to the data in the processor. 3. Write the data back to the working memory. If the variable has already been modified during the process of fetching the variable data to the processor, the increment operation will result in an error. Here’s an example:

Variable A =5, the current thread fetched 5 to the processor, at which point a was changed to 8 by another thread, but the processor continued to work, incrementing 5 to 6, and then writing 6 back to main memory, overwriting data 8, resulting in an error. Therefore, volatile is not absolutely thread-safe due to the non-atomic nature of Java operations.

So 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 invariant constraints with other state variables.

In plain English, operations do not depend on any state operations. Because the state of the dependency may have changed during the operation without the processor knowing it. If state-dependent operations are involved, other thread-safe schemes, such as locking, must be used to ensure atomicity of operations.

Applicable scenario

Status flags/multithreaded notifications

Status flags are a good use of the volatile keyword, as we saw in the first part, to set flags to tell all other threads to execute logic. Or, as in the previous section, set a flag to notify other threads when initialization is complete.

A more common similar scenario is thread notifications. We can set a variable and have multiple threads observe it. You only need to change the value in one thread, and all other threads observing the variable will be notified. Very lightweight and much simpler logic.

Ensure complete initialization: double lock checking problem

Singletons are the ones we use a lot. One of the more common ones is double-lock checking, as follows:

public class JavaClass {
	// Static internal variables
   private static JavaClass javaClass;
    // Constructor is private
   private JavaClass(a){}
    
   public static JavaClass getInstance(a){
       // The first call is null
       if (javaClass==null) {// The second load is null
           synchronized(JavaClass.class){
               if (javaClass==null){
                   javaClass = newJavaClass(); }}}returnjavaClass; }}Copy the code

This kind of code is familiar, right, but I won’t go into the design logic. Is such code absolutely thread-safe? Not really. In some extreme cases, there are still problems. JavaClass = new javaClass (); This code right here.

Creating an object is not an atomic operation, it has three main child operations:

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

Normally, this order is also followed. But the JVM is optimized for instruction rearrangement. It might become:

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

If a reference is assigned before it is initialized, the external reference may be an incomplete initialized object, which can cause problems. So you can volatile the singleton and restrict instruction reordering, and that won’t happen. Of course, the more recommended way to write the singleton pattern is to use class loading to ensure global singleton and thread safety, as long as you have only one class loader, of course. I won’t expand it here because of space limitations.

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

conclusion

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 may not be found after several rounds of testing, but they crash as soon as they are launched, and it is extremely difficult to reproduce and find the cause. So it is very important to learn the principle of concurrency and the idea of concurrent programming. At the same time, we should pay more attention to the principle. Grasp the principles and essence, and other relevant knowledge will come in handy.

Hope this article is helpful.

Full text here, the original is not easy, feel help can like collection comments forward. I have no talent, any ideas welcome to comment area exchange correction. If need to reprint please comment section or private communication.

And welcome to my blog: Portal