“This is the fifth day of my participation in the November Gwen Challenge. Check out the event details: The Last Gwen Challenge 2021
As we learned from the previous chapter, synchronized is a heavyweight lock, although the JVM has done a lot to optimize it, and volatile is lightweight synchronized. If a variable is volatile, it is less expensive than synchronized because it does not cause thread context switching and scheduling. The Java language specification defines volatile as follows:
The Java programming language allows threads to access a shared variable, and to ensure that the shared variable can be updated accurately and consistently, threads should ensure that the variable is obtained separately through an exclusive lock.
If a variable is volatile, Java ensures that all threads see the same value. If one thread updates a volatile shared variable, other threads see the update immediately. This is called thread visibility.
Volatile, while seemingly simple to use and nothing more than a volatile in front of a variable, is not easy to use well (LZ admits that I still use it poorly and ambiguously).
Memory model related concepts
Understanding volatile is a bit tricky because it has to do with the Java memory model, so we need to understand the Java memory model before we can understand volatile. This is just a preliminary introduction, and LZ will cover the Java memory model in more detail.
Operating system semantics
When a computer runs a program, every instruction is executed in the CPU, and data reading and writing are bound to be involved in the execution process. We know that the data that the program is running is stored in main memory. One problem is that reading and writing data from main memory is not as fast as executing instructions from the CPU. If any interaction requires dealing with main memory, the efficiency is greatly affected. The CPU cache is unique to a CPU and is only relevant to the threads running on that CPU.
While CPU caching solves the problem of efficiency, it introduces a new problem: data consistency. During the execution of the program, the data needed for the execution is copied to the CPU cache. During the computation, the CPU no longer deals with main memory, but directly reads and writes data from the cache. The data is refreshed to main memory only after the execution is complete. Here’s a simple example:
i++
Copy the code
When the thread runs this code, it first reads I (I = 1) from main memory, then copies a copy to the CPU cache, then the CPU performs the + 1 (2) operation, then writes data (2) to the tell cache, and finally flusher to main memory. In fact, this is fine in a single thread, the problem is in multiple threads. As follows:
If we have two threads A and B both performing this operation (I ++), our normal logic would be that the value of I in main memory should be equal to 3, but is that the case? Analysis is as follows:
Both threads read the value (1) of I from main memory into their respective caches, then thread A performs the +1 operation and writes the result to the cache, and finally to main memory. Main memory I ==2, thread B does the same, and I in main memory is still =2. So you end up with 2, not 3. This phenomenon is the cache consistency problem.
There are two solutions to cache consistency:
- By locking the bus with LOCK#
- Through the cache consistency protocol
However, there is a problem with scheme 1, it is implemented in an exclusive way, that is, with the bus and LOCK# lock, only one CPU can run, and the other cpus have to block, which is relatively inefficient.
The second scheme, the Cache Consistency Protocol (MESI protocol), ensures that copies of shared variables used in each cache are consistent. The core idea is as follows: When a CPU writes data and finds that the variable is a shared variable, it notifies other cpus that the cache line of the variable is invalid. Therefore, when other cpus read the variable and find that it is invalid, they load data from main memory again.
Java memory model
Now let’s take a look at the Java Memory model, take a look at what guarantees the Java Memory model provides and what methods and mechanisms it provides in Java to ensure correct execution in multithreaded programming.
These three basic concepts are commonly encountered in concurrent programming: atomicity, visibility, and orderliness. Let’s take a look at volatile for a second
atomic
Atomicity: An operation or operations are either all performed without interruption by any factor, or none at all.
Atomicity is like transactions in a database; they live and die together as a team. It’s easy to understand atomicity. Let’s look at a simple example:
i = 0; ---1 j = i ; ---2 i++; ---3 i = j + 1; - 4Copy the code
Which of the above four operations are atomic and which are not? If you don’t understand it very well, you might think it’s all atomic, but in fact only one is atomic and nothing else is.
- 1- In Java, variables and assignments to primitive data types are atomic operations;
- 2- contains two operations: read I and assign I to j
- 3– Contains three operations: read I, I +1, assign the +1 result to I;
- 4– The same as three
In a single-threaded environment we can think of the whole step as atomic, but in a multi-threaded environment Java guarantees atomic only for variables and assignments of basic data types (note: in a 32-bit JDK, reading 64-bit data is not atomic, such as long, double*). To ensure atomicity in a multithreaded environment, you can use locks and synchronized to ensure atomicity.
Volatile does not guarantee atomicity for compound operations
visibility
Visibility means that when multiple threads access the same variable and one thread changes the value of the variable, other threads can immediately see the changed value.
As analyzed above, in a multithreaded environment, one thread’s operations on a shared variable are invisible to other threads.
Java provides volatile to ensure visibility.
When a variable is volatile, thread-local memory is invalid. When a thread changes a shared variable, it is immediately updated to main memory, and when other threads read the shared variable, it is read directly from main memory. Of course, both Synchronize and locks ensure visibility.
order
Orderliness: that is, the order in which the program is executed is the order in which the code is executed.
In the Java memory model, the compiler and processor are allowed to reorder instructions for efficiency. Reordering does not affect single-thread execution, but it does affect multithreading.
Java provides volatile to ensure some order. The most famous example is the DCL (double-checked lock) in the singleton pattern. LZ is not going to elaborate here.
Anatomy of the Volatile Principle
JMM is too large to be explained in a bit above. The above brief introduction is all paving the way for volatile.
Volatile guarantees thread visibility and provides some order, but not atomicity. Volatile is implemented using “memory barriers” underneath the JVM.
The above statement has two meanings
- Guaranteed visibility, not guaranteed atomicity
- Disallow instruction reordering
I’m going to skip the first level of semantics and focus on instruction reordering.
To improve performance, compilers and processors often reorder instructions when executing a program:
- Compiler reorder. The compiler can rearrange the execution order of statements without changing the semantics of single-threaded programs.
- Handler reorder. If there is no data dependency, the processor can change the execution order of the corresponding machine instructions.
Instruction reordering has no effect on the single thread. It does not affect the running results of the program, but it will affect the correctness of multithreading. Since instruction reordering affects the correctness of multithreaded execution, we need to disable it. So how does the JVM prohibit reordering? The happens-before principle guarantees order. It states that if the order of two operations cannot be deduced from the happens-before principle, they cannot be guaranteed order. You can reorder anything you want. Its definition is as follows:
- Previous actions happen-before subsequent actions in the same thread. (That is, execute code sequentially within a single thread. However, it is legal for the compiler and processor to reorder without affecting the results of execution in a single-threaded environment. In other words, this rule does not guarantee compilation reordering and instruction reordering.
- The unlock action on the monitor happens -before its subsequent locking action. (Synchronized rule)
- Writes to volatile variables happen-before subsequent reads. (Volatile rules)
- The thread’s start() method happens -before all subsequent actions of the thread. (Thread start rule)
- All thread operations happen-before other threads call join on this thread and return successful operations.
- If a happens before B, b happens before C, then a happens before C (transitivity).
Let’s focus on the third volatile rule: writes to volatile variables happen-before subsequent reads. To implement volatile memory semantics, the JMM reorders as follows:
With a little knowledge of the happen-before principle, let’s answer this question how does the JVM prohibit reordering?
A look at the assembly code generated with and without the volatile keyword showed that the volatile keyword added a lock prefix. The LOCK prefix instruction acts as a memory barrier. A memory barrier is a set of processing instructions used to implement sequential restrictions on memory operations. The underlying layer of volatile is implemented through memory barriers. Here is the memory barrier required to complete the above rule:
The JMM system is too large to be explained in a few words. We will analyze volatile again in depth in combination with the JMM.
conclusion
Volatile may seem simple, but understanding it can be tricky, and this is just a basic overview. Volatile is lighter than synchronized, can replace synchronized in some situations, but not completely, and should be used only in certain situations. To use it, the following two conditions must be met:
- Writes to variables do not depend on the current value;
- This variable is not contained in an invariant with other variables.
Volatile is often used in two scenarios: the status flag and the double check
The resources
- Understanding the Java Virtual Machine
- Fang Tengfei: The Art of Java Concurrent Programming
- Java concurrent programming: Volatile keyword resolution
- Java concurrent programming: The use of volatile and how it works