Chapter 7 Detailed explanation of volatile

Variables modified by volatile have two major characteristics:

can guarantee

  • visibility
  • orderliness

Why these functions can be achieved? The underlying principle is the memory barrier

volatile memory semantics

The volatile keyword can ensure the visibility of shared variables. Compared with ordinary shared variables, using the volatile keyword can ensure the visibility of shared variables

  • When the thread reads the volatile keyword, JMM will set the working memory corresponding to the thread as invalid, and the thread directly reads the value from the main memory to the working memory
    • Yield and sleep will cause the thread to give up the CPU. When the thread is scheduled back to the CPU again, it may re-read the main memory (the JVM specification clearly states that the yield and sleep methods do not necessarily refresh the working memory and read the main memory forcibly, but volatile will Forcefully refresh the memory)
  • When the thread writes the volatile keyword variable, the currently modified variable value (in the working memory) is immediately refreshed to the main memory, and other threads that are reading this variable will wait (not block) until the operation of writing back to the main memory is completed , ensuring that what is read must be the refreshed main memory value.

In a word, the variable modified by volatile will refresh the main memory immediately after a certain working memory is modified, and set the variable in other working memory to invalid.

memory barrier

Recall the role of volatile

  • visibility

    • Immediately flush back to main memory + invalidation handling.
  • orderliness

    • Instruction rearrangement is prohibited: rearrangement is prohibited if there are data dependencies .

what is

  • Memory barrier (also called memory barrier, memory barrier, barrier instruction, etc.) is a type of synchronization barrier instruction. It is a synchronization point in the random access operation of memory by the CPU or compiler, making all read and write operations before this point The operations after this point can only be executed after all are executed), avoiding code reordering .

  • Memory barrier is actually a JVM instruction. The rearrangement rules of the Java memory model require the Java compiler to insert specific memory barrier instructions when generating JVM instructions. Through these memory barrier instructions, volatile realizes the visibility and control in the Java memory model. Ordered, but volatile cannot guarantee atomicity.

    • All write operations before the memory barrier must be written back to main memory.

    • All read operations after the memory barrier get the latest results of all write operations before the memory barrier (visibility is achieved).

    • In a word: writing to a volatile field happens-before any subsequent reading of this volatile field, also called reading after writing.

Memory barrier classification

  • In the previous chapter, we explained the happens-before principle, which is similar to the interface specification. How to implement it?
  • What do you rely on to land? What can you guarantee? Does it work for you?

Roughly divided into two types

Write Barrier (Store Memory Barrier) : Tells the processor to synchronize all data stored in caches (store buffers) to main memory before writing the barrier. That is to say, when you see the Store barrier instruction, you must execute all the write instructions before this instruction before you can continue execution.

Read barrier (Load Memory Barrier) : The read operations of the processor after the read barrier are executed after the read barrier. In other words, after the Load barrier instruction, it can be guaranteed that subsequent read data instructions will be able to read the latest data.

Subdivided into four types

insert image description here

What is guaranteed orderliness?

  • Disable command rearrangement
    • Disable reordering via memory barriers
  1. Reordering may affect the execution and implementation of the program. Therefore, we sometimes want to tell the JVM that you don't want to be "smart" and reorder it for me. I don't need to sort it here, just listen to the master.

  2. Regarding compiler reordering, JMM will prohibit specific types of compiler reordering according to the reordering rules.

  3. For processor reordering, the Java compiler inserts memory barrier instructions at appropriate locations in the generated instruction sequence to prohibit specific types of processor reordering.

The underlying implementation principle of volatile is memory barrier, Memory Barrier (Memory Fence)

  • A write barrier will be added after the write instruction to volatile variables.
    • The write barrier will ensure that when the instruction is reordered, the code before the write barrier will not be queued after the write barrier
  • A read barrier will be added before the read instruction for volatile variables.
    • The read barrier will ensure that when the instruction is reordered, the code after the read barrier will not be arranged before the read barrier

Write Barrier (Store Memory Barrier) : Tells the processor to synchronize all data stored in caches (store buffers) to main memory before writing the barrier. That is to say, when you see the Store barrier instruction, you must execute all the write instructions before this instruction before you can continue execution.

Read barrier (Load Memory Barrier) : The read operations of the processor after the read barrier are executed after the read barrier. In other words, after the Load barrier instruction, it can be guaranteed that subsequent read data instructions will be able to read the latest data.

volatile variable rules of happens-before

A write to a volatile field happens-before any subsequent read to the volatile field , also called a read-after-write.

image-20230613132209519

Just have an impression here for the time being.

  • When the first operation is a volatile read, no matter what the second operation is, it cannot be reordered. This operation ensures that operations after the volatile read will not be rescheduled to before the volatile read .

  • When the second operation is a volatile write, no matter what the first operation is, it cannot be reordered. This operation ensures that operations before the volatile write will not be rescheduled to after the volatile write .

  • When the first operation is a volatile write and the second operation is a volatile read, it cannot be rearranged.

JMM divides the memory barrier insertion strategy into four rules:

read barrier

  • Insert a barrier after each volatile读operationLoadLoad
  • Insert a barrier after each volatile读operationLoadStore
image-20230613133327722

write barrier

  • Insert a barrier in front of each volatile写operationStoreStore

  • Insert a barrier after each volatile写operationStoreLoad

image-20230613133556238

insert image description here

volatile property

How to ensure visibility

The underlying implementation principle of volatile is memory barrier, Memory Barrier (Memory Fence)

  • A write barrier will be added after the write instruction to volatile variables.
    • Ensure that all changes to shared variables before the barrier are synchronized to main memory.
  • A read barrier will be added before the read instruction for volatile variables.
    • Ensure that after the barrier, the read of the shared variable loads the latest data in the main memory

Write Barrier (Store Memory Barrier) : Tells the processor to synchronize all data stored in caches (store buffers) to main memory before writing the barrier. That is to say, when you see the Store barrier instruction, you must execute all the write instructions before this instruction before you can continue execution.

Read barrier (Load Memory Barrier) : The read operations of the processor after the read barrier are executed after the read barrier. In other words, after the Load barrier instruction, it can be guaranteed that subsequent read data instructions will be able to read the latest data.

illustrate

  • Ensure that different threads can see the results in time after completing operations on a variable, that is, once the shared variable is changed, all threads can see it immediately.

example

public class VolatileTest1 {
    
    
//    static boolean flag = true;//不加volatile,没有可见性
    static volatile boolean flag = true;//加volatile,有可见性
    public static void main(String[] args) throws InterruptedException {
    
    
        new Thread(()->{
    
    
            System.out.println(Thread.currentThread().getName()+"\t come in");
            while (flag){
    
    //默认flag是true,如果未被修改就一直循环,下面那句话也打不出来

            }
            System.out.println(Thread.currentThread().getName()+"\t flag被修改为false,退出.....");

        },"t1").start();
        //暂停几秒
        TimeUnit.SECONDS.sleep(2);
        flag=false;
        System.out.println("main线程修改完成");
    }
}
//没有volatile时
//t1   come in
//main线程修改完成
//--------程序一直在跑(在循环里)

//有volatile时
//t1   come in
//main线程修改完成
//t1   flag被修改为false,退出.....

Explanation of the above code principle

  • Why can't I see the value of the flag changed to false by the main thread main in thread t1?

The problem may be:

  • After the main thread modifies the flag, it does not refresh it to the main memory, so the t1 thread cannot see it.

  • The main thread refreshes the flag to the main memory, but t1 always reads the value of the flag in its own working memory, and does not update the main memory to obtain the latest value of the flag.

Our demands:

  • After the thread modifies the copy in the working memory, it is immediately flushed to the main memory;
  • Every time a shared variable is read in the working memory, it is read again in the main memory and then copied to the working memory.

solve:

  • Using volatile to modify shared variables can achieve the above effect. Variables modified by volatile have the following characteristics:

    • When reading in a thread, each read will go to the main memory to read the latest value of the shared variable, and then copy it to the working memory.
    • The copy of the variable in the working memory is modified in the thread, and it will be immediately refreshed to the main memory after the modification.

The reading and writing process of volatile variables

The Java memory model defines 8 atomic operations between each thread's working memory and physical main memory.

  • read (read) → load (load) → use (use) → assign (assignment) → store (storage) → write (write) → lock ( lock) → unlock (unlock)
image-20230613153123079
  • read: Acts on main memory, transferring the value of the variable from main memory to working memory, main memory to working memory
  • load: Acts on the working memory, puts the variable value transferred from the main memory by read into the working memory variable copy, that is, data loading
  • use: Acts on the working memory, passing the value of the working memory variable copy to the execution engine. This operation will be performed whenever the JVM encounters a bytecode instruction that requires the variable.
  • assign: Acts on working memory and assigns the value received from the execution engine to the working memory variable. This operation will be performed whenever the JVM encounters a bytecode instruction to assign a value to a variable.
  • store: Acts on the working memory and writes the value of the assigned working variable back to the main memory.
  • write: Acts on main memory and assigns variable values ​​transferred from store to variables in main memory.

Since the above 6 instructions can only guarantee the atomicity of a single instruction, there is no large-area locking for the combined atomic guarantee of multiple instructions. Therefore, the JVM provides two other atomic instructions:

  • lock: Acts on main memory, marking a variable as a thread-exclusive state. Only locking when writing will only lock the process of writing the variable.
  • unlock: Acts on the main memory, releases a variable in a locked state before it can be occupied by other threads

The core operation is our write operation. When we write from the working memory to the main memory, a lock operation will be performed. After locking, the values ​​of other threads' working memory variables will be cleared. If other threads want to use this The variable must be rewritten before loading the value from the main memory. After the write is completed, unlock is performed to ensure visibility.

  • The lock here is just the process of locking some variables, that is, only after writing is completed, other threads can go to the main memory to read the data.

Why is there no atomicity

  • Compound operations of volatile variables are not atomic, such as number++

example

  • synchronizedand volatilecode demo
class MyNumber{
    
    
    //volatile int num=0;
    int num=0;
    public synchronized void add(){
    
    
        num++;
    }
}
public class VolatileNoAtomicDemo {
    
    
    public static void main(String[] args) {
    
    
        MyNumber myNumber = new MyNumber();
        for (int i = 0; i <10; i++) {
    
    
            new Thread(()->{
    
    
                for (int j = 0; j < 100; j++) {
    
    
                    myNumber.add();
                }
            }).start();
        }
        //暂停几秒钟线程
        try {
    
     TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) {
    
     e.printStackTrace(); }
        System.out.println(Thread.currentThread().getName() + "\t" + myNumber.num);

    }

}
//-------------volatile情况下
//main  941
//-----------synchronized请款下
//main  1000

Reading and assigning an ordinary variable

When thread 1 initiates a read operation on the main memory object to the first set of write operations, thread 2 may initiate a second set of operations on the main memory object at any time.

image-20230613163032166
  • When thread 1 starts the volatile read and write process, thread 2 can initiate a read operation at any time during its process, because our volatile lock only locks the write process in wirte

atomicity not guaranteed

  • From the bottom layer, i++ or number++ (when performing engine operations) is actually divided into three steps: data loading , data calculation , and data assignment . And these three steps of non-atomic operations

image-20230613164003755

  • For the visibility of volatile variables, the JVM only ensures that the value loaded from the main memory to the thread working memory is the latest, and it is only the latest when the data is loaded .
  • However, in a multi-threaded environment, the " data calculation" and "data assignment" operations may occur multiple times . If the data is loaded or the main memory volatile modified variable is modified, the operations in the thread's working memory will be invalidated and the main memory will be read. The latest value, the operation has a write loss problem. That is, the variables in the private memory of each thread and the public memory of the main memory are not synchronized, which leads to data inconsistency. It can be seen that volatile solves the visibility problem when reading variables, but it cannot guarantee atomicity. Lock synchronization must be used in scenarios where multi-threads modify main memory shared variables.
  • For example, in this i++ operation, when you load 5 from the main memory and load it into the working memory, when the ++ operation is about to be performed, thread 2 performs a read operation and also reads 5 from the main memory, because Thread 1 does not perform a write operation, and all main memory values ​​are still the latest, which is consistent with volatile characteristics. Then the thread performs a set of 8 operations, and then changes to 6 and writes it to the main memory. The 5 of thread 1 becomes invalid and needs to be performed. Read 6 from memory again, but this time the ++ operation is lost, so everything is still thread-unsafe.

in conclusion

  • Volatile is not suitable for participating in operations that depend on the current value, such as i=i+1, i++, etc.
  • So where can volatile, which relies on visibility, be used? Usually volatile is used as a boolean value or int value to save a certain state. (Once the boolean value is changed it is quickly visible and you can do other operations)

Disable command rearrangement

  • Reordering refers to a method used by compilers and processors to reorder the instruction sequence in order to optimize program performance, sometimes changing the order of program statements.

  • There is no data dependency and can be reordered;

  • Data dependencies exist, reordering is prohibited

    • Data dependency: If two operations access the same variable, and one of the two operations is a write operation, there is a data dependency between the two operations.

But the rearranged instructions must not change the original serial semantics! This must be considered in concurrency design!

Examples of data dependencies

image-20230613170730141

  • There is no data dependency, you can reorder ===> Reorder OK.
image-20230613170905809
  • There is a data dependency, reordering is prohibited ===> If reordering occurs, the program running results will be different.
  • Compilers and processors will respect data dependencies when reordering and will not change the execution of two dependent operations. However, the data properties between different processors and different threads will not be considered by the compiler and processor. , which will only work in single-processor and single-thread environments

How to use volatile correctly (actually works)

Single assignment is possible, but assignment with coincidence operations is not (such as i++)

  • The following two single assignments are possible

    • volatile int a = 10;

    • volatile boolean flag = false

Status flag to determine whether the business is over

//这个前面讲过
public class UseVolatileDemo{
    private volatile static boolean flag = true;

    public static void main(String[] args){
        new Thread(() -> {
            while(flag) {
                //do something......循环
            }
        },"t1").start();

        //暂停几秒钟线程
        try { TimeUnit.SECONDS.sleep(2L); } catch (InterruptedException e) { e.printStackTrace(); }

        new Thread(() -> {
            flag = false;
        },"t2").start();
    }
}


Low-cost read and write locking strategies

When reading is more important than writing

  • The most naive method is to add two synchronized, but using volatile for reading and synchronized for writing can improve performance.
public class UseVolatileDemo{
    //
   // 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
   // 理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
     
    public class Counter {
        private volatile int value;

        public int getValue(){
            return value;   //利用volatile保证读取操作的可见性
         }
        public synchronized int increment(){
            return value++; //利用synchronized保证复合操作的原子性
        }
    }
}

Singleton mode double lock case

public class SafeDoubleCheckSingleton
{
    
    
    private static SafeDoubleCheckSingleton singleton; //-----这里没加volatile
    //私有化构造方法
    private SafeDoubleCheckSingleton(){
    
    
    }
    //双重锁设计
    public static SafeDoubleCheckSingleton getInstance(){
    
    
        if (singleton == null){
    
    
            //1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
            synchronized (SafeDoubleCheckSingleton.class){
    
    
                if (singleton == null){
    
    
                    //隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
                    singleton = new SafeDoubleCheckSingleton();
                    //实例化分为三步
                    //1.分配对象的内存空间
                    //2.初始化对象
                    //3.设置对象指向分配的内存地址
                }
            }
        }
        //2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
        return singleton;
    }
}

In single thread case

  • In a single-threaded environment (or under normal circumstances), at the "problem code", the following operations will be performed to ensure that the initialized instance can be obtained
//三步
memory = allocate(); //1.分配对象的内存空间
ctorInstance(memory); //2.初始化对象
instance = memory; //3.设置对象指向分配的内存地址

Hidden dangers in the case of multi-threading (due to instruction reordering)
: In a multi-threading environment, at the "problem code", the following operations will be performed. Due to reordering, 2 and 3 are out of order, and the consequence is that other threads get null instead of completion Initialized object. (If it is not initialized, it is null)

normal circumstances

//三步
memory = allocate(); //1.分配对象的内存空间
ctorInstance(memory); //2.初始化对象
instance = memory; //3.设置对象指向分配的内存地址

Abnormal situation

//三步
memory = allocate(); //1.分配对象的内存空间
instance = memory; //3.设置对象指向分配的内存地址---这里指令重排了,但是对象还没有初始化
ctorInstance(memory); //2.初始化对象

solve

  • Add volatile modification
public class SafeDoubleCheckSingleton
{
    
    
    //通过volatile声明,实现线程安全的延迟初始化。
    private volatile static SafeDoubleCheckSingleton singleton;
    //私有化构造方法
    private SafeDoubleCheckSingleton(){
    
    
    }
    //双重锁设计
    public static SafeDoubleCheckSingleton getInstance(){
    
    
        if (singleton == null){
    
    
            //1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
            synchronized (SafeDoubleCheckSingleton.class){
    
    
                if (singleton == null){
    
    
                    //隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
                                      //原理:利用volatile,禁止 "初始化对象"(2) 和 "设置singleton指向内存空间"(3) 的重排序
                    singleton = new SafeDoubleCheckSingleton();
                }
            }
        }
        //2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
        return singleton;
    }
}


Instantiating a singleton is performed in multiple steps (allocating memory space, initializing the object, pointing the object to the allocated memory space). Some compilers will reorder the second and third steps for performance reasons (java allocates memory space, Point the object to the allocated memory space, initialize the object). In this way, a thread may obtain an instance that is not fully initialized.

interview answer

valatile visibility

  • In case of write operation, the latest value of this variable will be immediately refreshed to the main memory.
  • For read operations, you can always read the latest value of this variable, which is the last modified value of this variable.
  • When a thread receives a notification to read the value of a variable modified by volatile, the data in the thread's private working memory becomes invalid, and it needs to return to the main memory area to read the latest data

What is a memory barrier?

A memory barrier is a barrier instruction that causes the CPU or compiler to implement an ordering constraint on memory operations issued before and after the barrier instruction. Also called memory fence or fence instruction

What can memory barriers do?

  • Prevent reordering of instructions on both sides of the barrier
  • If a barrier is used when writing data, the data in the thread's private working memory is forced to be flushed back to the main physical memory.
  • A barrier is added when reading data, the data in the thread's private working memory becomes invalid, and the latest data is obtained from the main physical memory again.

Four major instructions for memory barriers

  • Insert a StoreStore barrier before each volatile write operation
    • Ordinary writes and volatile writes prohibit rearrangement
  • Insert a StoreLoad barrier after each volatile write operation
    • Volatile writes and normal reads prohibit rearrangement
  • Insert a LoadLoad barrier after each volatile read operation
    • Volatile reads and ordinary reads prohibit rearrangement
  • Insert a LoadStore barrier after each volatile read operation
    • Volatile reads and volatile writes are rearranged

3 sentence summary

  • Operations before volatile writing are prohibited from being rescheduled to after volatile writing.
  • Operations after volatile reading are prohibited from being rearranged before volatile.
  • volatile read after volatile write, prohibit reordering

Guess you like

Origin blog.csdn.net/qq_50985215/article/details/131194219