In java-related job interviews, many interviewers like to test candidates’ knowledge of Java concurrency, and using the volatile keyword as a small starting point can often lead to questions that involve the Java Memory model (JMM), Java concurrent programming features, and so on. In depth, you can also look at the underlying JVM implementation and operating system knowledge.
Let’s take a closer look at the Volitile keyword through a hypothetical interview.
Interviewer: What about Java concurrency? Describe your understanding of the keyword volatile
As far as I can see, a shared variable that is modified by volatile has two characteristics:
1. Memory visibility of different threads’ operations on this variable is guaranteed;
2. Disable reordering of commands
Interviewer: Can you elaborate on what is memory visibility and what is reordering?
It’s a lot to talk about, but LET me start with the Java memory model.
The Java Virtual Machine specification seeks to define a Java memory model (JMM) to mask memory access differences across hardware and operating systems, allowing Java programs to achieve consistent memory access across platforms. Simply put, since the CPU executes instructions faster than it does memory access is much slower by more than an order of magnitude, the processor bosses have added several layers of caching to the CPU.
In the Java memory model, a wave of abstractions is applied to the above optimizations. The JMM specifies that all variables are stored in main memory, similar to ordinary memory mentioned above, and that each thread contains its own working memory, easily understood as a register or cache on the CPU. Therefore, the operation of the thread is based on the working memory, they can only access their own working memory, and before and after the work of the value of synchronization back to the main memory.
So I’m a little confused, take a piece of paper and draw:
Using working memory and main memory, although faster, also brings some problems. For example, here’s an example:
i = i + 1;
Copy the code
If I starts at 0, when only one thread executes it, the result will definitely be 1. When two threads execute it, will the result be 2? Not necessarily. This may be the case:
Thread 1: load I from main memory // I + 1 // I = 1 thread 2: load I from main memory // I + 1 // I = 1 thread 1: load I from main memory // I + 1 // I = 1 Save I to main storage Thread 2: Save I to main storageCopy the code
If the two threads follow the above process, then I will end up with a value of 1. If the last write back is slow, and you read the value of I, it could be 0, that’s a cache inconsistency problem.
Which brings us to the question you just asked. The JMM is built around how to handle atomicity, visibility, and order during concurrency. By addressing these three characteristics, you can eliminate the cache inconsistency problem. Volatile is about visibility as well as order.
Interviewer: What about those three characteristics?
1. Atomicity: In Java, reads and assignments to basic data types are atomic. Atomicity means that they are not interrupted and must be completed or not performed. Such as:
i = 2; j = i; i++; I = I + 1;Copy the code
Above four operations, I = 2 is a read operation, must be atomic operations, j = I do you think is the atomic operations, in fact, can be divided into two steps, one is to read the value of the I, and then assigned to j, this is where the 2 step operation, called atomic operations, i++ and I = I + 1 is equivalent, read the value of the I + 1, then write back to the main memory, So that’s three steps. So in the example above, the final value can be different because it does not satisfy atomicity.
So it’s just a simple read, assignment is atomic, assignment is numeric, and with variables there’s an extra step of reading the value of the variable. One exception is that the virtual machine specification allows two 32 operations for 64-bit data types (long and double), but the latest JDK implementations do implement atomic operations.
The JMM implements only basic atomicity, and operations like i++ above must be synchronized and locked to keep the entire block of code atomicity. The thread must flush the value of I back to main memory before releasing the lock.
2. Visibility:
Speaking of visibility, Java uses volatile to provide visibility. When a variable is volatile, the change is immediately flushed to main memory, and when another thread needs to read the variable, it reads the new value out of memory. Ordinary variables do not guarantee this.
Visibility can also be guaranteed with synchronized and locks, which are more expensive for the thread to flush the shared variable values back into main memory before releasing the Lock.
3. Order is in order.
The JMM allows compilers and processors to reorder instructions, but specifies the as-if-serial semantics, which means that no matter how much the program is reordered, the execution result of the program cannot be changed. For example, the following program:
Double PI = 3.14; //A double r = 1; //B double s= pi * r * r; //CCopy the code
A->B->C; B->A->C; C ->C; C ->A->C; The JMM guarantees that reordering does not affect single-threaded execution, but it is prone to problems in multi-threaded applications.
Something 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}}Copy the code
If there are two threads executing the above code segment, thread 1 executing write, thread 2 executing multiply, must ret be 4? Not necessarily:
In this case, you can ensure that the program is “ordered” by using the volatile keyword to disallow reordering, or you can use the heavyweight synchronized and Lock to ensure that the code in that area is executed at once.
In addition, the JMM has some a priori orderliness, that is, orderliness that can be guaranteed without any means, often referred to as the happens-before principle. <
> Defines the following happens-before rule:
- Rule of program order: Every action in a thread happens before any subsequent action in that thread
- Monitor lock rule: The unlocking of a thread happens before the subsequent locking of that thread
- The volatile variable rule: a write to a volatile field happens before subsequent reads to that field
- Transitivity: If A happens-before B, and B happens-before C, then A happens-before C
- Start () the rules: If thread A performs an operation
ThreadB_start()
(start thread B), then thread AThreadB_start()
Happens-before any operation in B- The join () principle: If A executes
ThreadB.join()
And returns successfully, then any operation in thread B happens-before from thread AThreadB.join()
The operation succeeded.- Interrupt () principle: the thread
interrupt()
Method calls occur before the interrupted thread code detects the occurrence of the interrupt event and can passThread.interrupted()
Method to check whether an interrupt has occurred- Principle of finalize ()The completion of an object’s initialization occurs first
finalize()
The beginning of the method
Rule 1: The program order rule says that in a thread, all operations are in order, but in the JMM, reorder is allowed as long as the results are the same. The happens-before rule here emphasizes that the results of a single thread are correct, but there is no guarantee that the same is true for multiple threads.
The second rule, the monitor rule, is to make sure that the lock has been released before locking.
The third rule, which applies to volatile in question, is that if one thread writes to a variable and another thread reads it, the write must precede the read.
The fourth rule is happens-before transitivity.
The following several will not be repeated one by one.
Interviewer: How does the keyword volatile satisfy the three characteristics of concurrent programming?
That brings us back to the volatile rule: a write to a volatile field happens before a subsequent read to that field. If a variable is declared volatile, when I read it, I will always be able to read its latest value, which means that whenever any other thread writes to it, it will be updated to main memory, and I will be able to read the value from main memory. That is, the volatile keyword guarantees visibility and order.
Continue with the above code 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}}Copy the code
This code is not only plagued by reordering, even if 1 and 2 are not reordered. 3 will not be executed so smoothly. Suppose thread 1 performs the write operation first, and thread 2 performs the multiply operation. Since thread 1 assigns flag to 1 in the working memory, it may not be written back to main storage immediately. Therefore, multiply reads flag from main storage when thread 2 executes, and the value may still be false, and the statements in brackets will not be executed.
If you change it to something like:
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}}Copy the code
So thread 1 executes write and thread 2 executes multiply. According to the happens-before principle, this process satisfies the following three types of rules:
- Rules of procedural order: 1 happens-before 2; 3 happens-before 4; (Volatile limits reordering of instructions, so 1 executes before 2)
- Volatile rules: 2 happens-before 3
- The transitive rule: 1 happens-before 4
In terms of memory semantics
When a volatile variable is written, the JMM refreshes the shared variable in the thread’s local memory to main memory
When a volatile variable is read, the JMM invalidates the thread’s local memory, and the thread then reads the shared variable from main memory.
Interviewer: The two-point memory semantics of volatile guarantee visibility and order, but does it guarantee atomicity?
This does not guarantee atomicity for reads and writes of volatile variables, but does not apply to compound operations such as volatiles ++, as shown in the following example: volatiles cannot be read or written with volatiles.
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++){ newThread(){
public void run() {
for(int j=0; j<1000; j++) test.increase(); }; }.start(); }while(thread.activecount ()>1) // Ensure that all previous threads have executed thread.yield (); System.out.println(test.inc); }Copy the code
It’s supposed to be 10000, but it’s likely to be less than 10000 when it runs. One might argue that volatile does not guarantee visibility. If one thread makes a change to inc, the other thread should see it immediately. But the int ++ operation here is a compound operation, which involves reading inc, incrementing it, and then writing it back to main memory.
Suppose thread A, which reads inc with A value of 10, blocks because no changes have been made to the variable and cannot trigger volatile rules.
Thread B also reads the value of inc, which is still 10 in main memory, increments, and is immediately written back to main memory, which is 11.
At this point, it is thread A’s turn to execute, because the working memory is holding 10, so continue to do increment, write back to main memory, 11 is written again. So even though the two threads execute increase() twice, they only add it once.
Someone said, “Doesn’t volatile invalidate cached rows?” But in this case, thread A does not change the inc value before thread B does the operation, so when thread B reads, it still reads 10.
If thread B writes 11 back into main memory, doesn’t thread A’s cache line be set to invalid? Thread A will only read the main memory value if it finds that its cache line is invalid, so thread A will have to continue incrementing.
To sum up, the function of atomicity is not maintained in the case of this compound operation. However, in the above example of setting a flag value, volatile is still atomized because the read/write operations to the flag are single-step.
The only way to ensure atomicity is to use the synchronized,Lock, and atomic classes, which encapsulate the basic data types increment, decrement, plus, and subtraction. Make sure these operations are atomic.
Interviewer: Not bad, but do you know the underlying mechanism of volatile?
If code with and without volatile is generated as assembly code, you will find that code with volatile is prefixed with an extra lock instruction.
The lock prefix directive actually acts as a memory barrier, which provides the following functionality:
1. Do not reorder the following instructions before the memory barrier 2. 3. Writing also causes other cpus or cores to invalidate their Cache, making the newly written value visible to other threads.
Interviewer: Where do you use volatile, to name two examples?
- The flag of the state quantity, just like the flag above, I will mention 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}}Copy the code
Such reads and writes, marked as volatile, ensure that the changes are immediately visible to the thread. Synchronized is more efficient than synchronized.
2. Implementation of singleton pattern, typical double-checked locking (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(); }}returninstance; }}Copy the code
This is a lazy singleton pattern in which objects are created only when they are used, and in order to avoid reordering initialization instructions, instance is volatile.
Interviewer: Tell us a few ways to write singletons, and how about this one?
Well, that’s another topic, and that volatile question is finally over… Let’s see if you got it