When Ali interviewed, he fell on the Java memory model

 

Recently, during the peak period of job-hopping, I also joined the fun and prepared to go out to see the opportunity. As a result, I wrote a resume. While I was voting, I reviewed the preparation test. Although it was over before it started, I was sad and sad. We still want to re-distribute?

In just half an hour, I think the worst answer is probably the interviewer’s question: Tell me about your understanding of the Java memory model?

This question is more general. Maybe I want to see if I really understand and master it. If you are also in an interview recently, then there is a high probability that this question will be asked. After all, Java is not a question of concurrency. of. The Java memory model is a manifestation of a person's level of concurrency. The reason is that when there is a problem with a concurrent program, you need to review the code line by line. At this time, only by mastering the Java memory model can you spot the problem with a bright eye.

What is the Java memory model

Before learning the Java memory model, let's first think about what is the main purpose of the Java memory model? Why define it? With this question we continue to look down.

There is a sentence you should keep in mind in concurrent programming, that is: visibility, orderliness, and atomicity are the source of all concurrent bugs.

Visibility issues

In the multi-core era, each CPU has its own cache. At this time, the data consistency between the CPU cache and the memory is not so easy to solve. When multiple threads are executed on different CPUs, these threads operate on different CPU caches. . For example, in the figure below, thread A operates the cache on CPU-1, and thread B operates the cache on CPU-2. Obviously, at this time, thread A's operations on variable V are not available for thread B. Visibility too. This is a "pit" that hardware programmers dig for software programmers.

Atomicity problem

Early operating systems were based on processes to schedule the CPU. Different processes did not share memory space. Therefore, the process must switch the memory mapping address to switch tasks, and all threads created by a process share a memory space, so The task switching cost of threads is very low. Modern operating systems are based on lighter threads for scheduling, and now we all refer to "task switching" as "thread switching."

Java concurrent programs are based on multi-threading, and naturally also involve task switching. Perhaps you may not think that task switching is also one of the sources of weird bugs in concurrent programming. The timing of task switching is mostly at the end of the time slice. We basically use high-level language programming now. A statement in high-level language often requires multiple CPU instructions to complete. For example, count += 1 in the above code requires at least three CPUs. instruction.

  1. Instruction 1: First, the variable count needs to be loaded from the memory to the register of the CPU;
  2. Instruction 2: After that, perform the +1 operation in the register;
  3. Instruction 3: Finally, write the result to the memory (the cache mechanism causes the CPU cache to be written instead of the memory).

The task switching of the operating system can occur after any CPU instruction is executed. Yes, it is a CPU instruction, not a statement in a high-level language. For the above three instructions, we assume count=0. If thread A performs thread switching after instruction 1 is executed, and thread A and thread B execute in the sequence shown in the figure below, then we will find that both threads execute count+ =1 operation, but the result is not the 2 we expected, but 1.

There may be something you don't understand here, that is count+=1. Generally speaking, we think that this line of code is a whole, even if the CPU switch is before or after this line of code, in fact, there is something wrong with it. The feature that one or more operations are not interrupted during the execution of the CPU becomes atomic. CPU atomicity guarantee refers to the CPU instruction level, but does not guarantee atomicity line operators of our high-level language, so we need to achieve atomic-level operators in many scenes.

Problems caused by instruction reordering

In order to optimize performance, the compiler sometimes changes the sequence of statements in the program. For example, in the program: "a=6; b=7;" after the compiler is optimized, it may become "b=7; a=6;". In this example, the compiler adjusts the order of statements, but does not affect the final result of the program. However, sometimes the optimization of the compiler and interpreter may lead to unexpected bugs.

A classic case is to use double check to create a singleton object, such as the following code: In the method of obtaining an instance getInstance(), we first judge whether the instance is empty, if it is empty, lock Singleton.class and check again whether the instance is If it is empty, create an instance of Singleton.

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

Suppose there are two threads A and B calling the getInstance() method at the same time, they will find instance == null at the same time, so they lock Singleton.class at the same time. At this time, the JVM guarantees that only one thread can lock successfully (assuming thread A) , Another thread will be in a waiting state (assuming thread B); thread A will create a Singleton instance, and then release the lock, after the lock is released, thread B is awakened, thread B tries to lock again, at this time it can be locked If the lock is successful, when thread B checks instance == null, it will find that a Singleton instance has been created, so thread B will not create a Singleton instance. This looks like everything is perfect and impeccable, but in fact this getInstance() method is not perfect. What's the problem? Out of the new operation, we think the new operation should be:

  1. Allocate a piece of memory M;
  2. Initialize the Singleton object on the memory M;
  3. Then the address of M is assigned to the instance variable.

In fact, the optimized execution path is like this:

  1. Allocate a piece of memory M;
  2. Assign the address of M to the instance variable;
  3. Finally, the Singleton object is initialized on the memory M.

What problems will it cause after optimization? We assume that thread A executes the getInstance() method first. When the instruction 2 is executed, the thread switch happens to happen and it switches to thread B; if thread B also executes the getInstance() method at this time, then thread B is executing the first judgment You will find instance != null, so the instance is returned directly, and the instance at this time is not initialized. If we access the member variables of instance at this time, it may trigger a null pointer exception.

In fact, in order to improve performance, both the compiler and the processor reorder the instructions at runtime. It can be divided into the following three categories:

  1. Compiler optimized reordering. The compiler can rearrange the execution order of statements without changing the semantics of a single-threaded program.
  2. Instruction-level parallel reordering. Modern processors use instruction-level parallelism (Instruction-Level Parallelism, ILP) to overlap multiple instructions. If there is no data dependency, the processor can change the execution order of the statements corresponding to the machine instructions.
  3. Reordering of the memory system. Because the processor uses caches and read/write buffers, this makes load and store operations appear to be performed out of order.

From the Java source code to the final actual execution of the instruction sequence, there will be three reorderings:

The above 1 belongs to the compiler reordering , and 2 and 3 belong to the processor reordering . These reordering may cause memory visibility problems in multithreaded programs. For compilers, JMM's compiler reordering rules prohibit certain types of compiler reordering (not all compiler reordering must be prohibited). For processor reordering, the JMM processor reordering rules require the Java compiler to insert specific types of memory barriers (Memory Barriers, Intel called Memory Fence) instructions when generating instruction sequences, and use memory barrier instructions to prohibit specific types of Processor reordering.

Knock on blackboard

Ok, after the above introduction, you must already know that the cause of visibility is caching, and the cause of ordering is compilation optimization. The most direct way to solve visibility and ordering is to disable caching and compilation optimization . But although this problem is solved, the performance of our program can be worrying.

A reasonable solution should be to disable caching and compilation optimization as needed . So, how to do "disable on demand"? For concurrent programs, only programmers know when to disable caching and compilation optimization, and you should know it here. The so-called "disable on demand" only needs to provide programmers with a method to disable caching and compilation optimization on demand. It's time for our protagonist to appear here, the Java memory model .

The Java memory model specifies how the JVM provides methods for disabling caching and compilation optimization on demand. Specifically, these methods include three keywords volatile, synchronized, and final, and six Happens-Before rules. Solve the problem of visibility and order in concurrent programming.

In the Java memory model, the most obscure part is the Happens-Before rule. We will not discuss the specific rules here. We will learn the definition and rules of Happens-Before in a follow-up article, so stay tuned.

A happens-before rule corresponds to one or more compiler and processor reordering rules. For Java programmers, the happens-before rule is simple and easy to understand. It prevents Java programmers from learning complex reordering rules and the specific implementation methods of these rules in order to understand the memory visibility guarantees provided by JMM. The relationship is shown in the figure below:

to sum up

Today, I mainly study the source of concurrent programming: visibility, orderliness, atomicity, and why these three problems occur in modern processors. The Java memory model defines a series of specifications for the visibility and order of these three issues, and provides methods to disable caching and compilation optimization on demand at the JVM level. Specifically, it includes three keywords : volatile , synchronized and final , as well as six Happens-Before rules . We will continue to learn about these specific implementation methods in subsequent articles.

In summary, the Java memory model is mainly divided into two parts. One is for application developers who write concurrent programs like you and me, and the other is for JVM implementers. We can focus on the former, which is related to writing concurrent programs. Part, the core of this part is the Happens-Before rule.

I believe that through reading this article, you have a deep understanding of JMM. It is recommended that you discuss with others or tell your friends after you think you understand it. If you can explain it to others in your own words, then it means you really Learned it.

Expand

Interviewer: Why doesn't JMM guarantee the atomicity of writing 64-bit long and double variables?

Friends who are interested in this question are welcome to pay attention to Xiaohei's official account and reply "0" to see the answer!

Guess you like

Origin blog.csdn.net/taurus_7c/article/details/105309445