The root causes of concurrency problems are visibility, atomicity and orderliness

The main goal of the Java memory model is to define access rules for variables in a program. That is, the low-level details of storing variables into and out of main memory in the virtual machine.

  • Main memory: The Java virtual machine specifies that all variables (not variables in the program) must be generated in main memory, which can be thought of as the heap for ease of understanding. This can be compared to the main memory of the physical machine, except that the main memory of the physical machine is the memory of the entire machine, while the main memory of the virtual machine is a part of the virtual machine memory.
  • Working memory: Each thread in the Java virtual machine has its own working memory, which is thread private and can be thought of as the virtual machine stack for ease of understanding. The thread’s working memory holds a copy of the variables needed by the thread in main memory. On the VM, the modification of main memory variables by a thread must be performed in the working memory of the thread. Threads cannot access each other’s working memory. If a variable’s value needs to be passed between threads, it must be mediated through main memory.

Visibility of thread working memory

Let’s use another piece of code to verify the visibility of multithreaded working memory. The following code loops the count++ operation num times each time the add() method is executed. In the main method we have created two threads, each calling add(100000) once. Let’s think about what the result should be.

public class Test { private int count = 0; public void add(int num){ for(int i=0; i<num; i++){ count++; System.out.println(Thread.currentThread().getName() + "current count : "+count); } } public static void main(String[] args) { Test test = new Test(); Thread t1 = new Thread(() -> { test.add(100000); }); Thread t2 = new Thread(() -> { test.add(100000); }); t1.start(); t2.start(); }}Copy the code

A lot of people might think that the output is 200,000, but if you call ADD (100,000) twice in a single thread, the result really should be 200,000; However, when multiple threads are enabled, such as thread T1 and thread T2, the result will be different. The final output value of count will be a random number between 100000 and 200000. The result is as follows:

Why did this happen?

Let’s assume that thread 1 and thread 2 are starting at the same time, so the first time we read count=0 into each thread’s working memory, and after we do count++, each thread’s working memory has a value of 1, and when we write to memory at the same time, we find that it’s 1, not 2. After that, each thread has the count value in its working memory, and both threads calculate based on the count value in its working memory, so the final count value is less than 200000. This is the visibility of thread work.

Visibility at the physical hardware level

On a single-core computer, where all threads execute on a single processor, the consistency between the processor’s cache and memory can be easily resolved. However, in the era of multi-core processors, each processor has its own cache. In the following figure, thread 1 accesses processor 1’s cache 1 while thread 2 accesses processor 2’s cache 2. In this case, if thread 1 and thread 2 modify the same variable val, The operations of thread 1 and thread 2 are not visible to each other.

At present, the cache based storage interaction well solves the speed contradiction between CPU and memory and other hardware. In the case of multi-core, each processor (core) must follow certain protocols such as MSI and MESI to ensure the consistency of data between the cache of each processor and the main memory.

atomic

In the current operating system, the execution is based on “time slice”. When multiple processes need to obtain CPU execution operations, the CPU executes the operations concurrently in the time slice rotation method. That is, the CPU allows a process to execute the operations for a short period of time, for example, 100 milliseconds. After 100 milliseconds, the operating system selects another process to execute (we call it “task switching”), as shown below:

  • Atom stands for “indivisible”;
  • Operations that are not interrupted by the thread scheduler throughout the operation are considered atomicity. Atomicity is the rejection of multi-threaded cross operation, whether multi-core or single-core, atomic quantity, at the same time can only have one thread to operate on it. For example, a=1 is atomic, but I ++ and I +=1 are not atomic.

I ++ and I +=1 do not guarantee atomic operations for the following reasons:

order

Orderliness refers to the order in which the program is executed.

Orderliness is different from different perspectives. Within this thread, all operations are in order (that is, instruction reordering does not result in a single threaded program executing any differently than before ordering); Observing from one thread to another, all operations are out of order because instruction reordering has occurred. The sequential semantics in the thread are the result of instruction reordering and the delay in synchronization between main and working memory. In the Java memory model, the compiler and processor are allowed to reorder instructions. The reordering process does not affect the execution of single-threaded programs, but affects the correctness of multi-threaded concurrent execution.

Using the dubbo check method, we will use volatile before the Singleton s variable. The volatile keyword is used to solve the concurrency problem caused by reordering optimization:

public class Singleton { private static volatile Singleton s; /** * dubbo check: existing problem (instruction reorder) * 1. 2. Instantiate objects in this space * 3. 1,3, [1,2,3]->[1,3,2] * if 1,3 is executed first, and 2 is not executed, s==null will be executed concurrently. * @return */ public static Singleton getInstance(){if(null == s){synchronized (Singleton.class){ if(null == s){ s = new Singleton(); } } } return s; }}Copy the code

Under normal circumstances, we should have the following steps to new an object:

  1. Apply for a block of memory space s
  2. Instantiate objects in this space
  3. Assign the address of this space to the variable S

But the actual order of execution might look like this:

  1. Apply for a block of memory space s
  2. Assign the address of this space to the variable S
  3. Instantiate objects in this space

This optimization is not a problem in single-threaded mode, but it is possible to report NPE exceptions when multiple threads execute concurrently. Let’s assume that thread 1 executes the getInstance() method first, and when step 2 is complete, a thread switch happens, switching to thread 2. If thread 2 also executes the getInstance() method at this point, thread 2 will find s! = null, so return s directly, and s is not initialized at this time, if we access the member variable of S may raise the null-pointer exception.

Java Memory Model (How to solve visibility and order)

The Java memory model defines the interaction between threads and memory. In the JMM abstract model, threads are divided into main memory and working memory. Main memory is shared by all threads, typically instance objects, static fields, array objects, and other variables stored in heap memory. The working memory is exclusive to each thread, and all operations on variables must be carried out in the working memory. Variables in the main memory cannot be read or written directly, and values of shared variables between threads are transferred based on the main memory. In the JMM, eight atomic operations are defined to implement how a shared variable is copied from main memory to working memory and synchronized from working memory to main memory. See the interaction between thread, working memory, and main memory.

The cause of visibility is cache, and the cause of order is compilation optimization. The most direct way to solve visibility and order is to disable caching and compilation optimization, but the problem is solved, and the performance of our program is worried.

The logical solution would be to disable caching and compile optimizations as needed. So how do you disable on demand? For concurrent programs, only the programmer knows when to disable caching and compiler optimizations, and “disable on demand” means to disable them at the programmer’s request. So, to solve visibility and order problems, just provide the programmer with a way to disable caching and compile optimizations on demand.

The Java Memory Model is a complex specification that can be read from different perspectives, from our programmers’ point of view, as it specifies how the JVM provides ways to disable caching and compile optimizations on demand. Specifically, these methods include the keywords volatile, synchronized, and final, as well as the six happens-before rules, which are the focus of this installment.

The volatile keyword

When a variable is defined as volatile, it has two properties:

  • When a volatile variable is written, the JMM flusher the value of the shared variable from the thread’s local memory to main memory. When a volatile variable is read, the JMM invalidates the thread’s local memory. The thread will then read the shared variable from main memory and update the value of local memory. — — — — — – the visibility

  • Disallow instruction reordering optimizations (low-level through memory barriers, not covered here) —— order

Volatile did not solve the problem of atomicity, which we had to solve at the code level.

Access to volatile variables is not locked and therefore does not block the thread of execution, making volatile variables a lighter synchronization mechanism than the sychronized keyword.

public class Test{ private int count=2; private boolean flag=false; public void write1() { count=10; flag=true; } public void read1() {if(flag){system.out.print (count); } } public static void main(String[] args) { Singleton s = new Singleton(); Thread t1 = new Thread(() -> s.write1()); Thread t2 = new Thread(() -> s.read1()); t1.start(); t2.start(); }}Copy the code

In the code above, since the instructions are reordered, when thread 1 executes write1 flag=true and thread 2 executes read1, the value of count is indeterminate. It could be 10 or 2.

public class Test{ private boolean flag=false; private volatile boolean sync=false; public void write2() { count=10; sync=true; } public void read2() {if(sync){system.out.print (count); }} public static void main(String[] args) {Singleton s = new Singleton(); Thread t1 = new Thread(() -> s.write2()); Thread t2 = new Thread(() -> s.read2()); t1.start(); t2.start(); }}Copy the code

If sync=true, count must be 10. If the first thread calls write2 and the second thread calls read2, count must be 10. One might think that the count variable is not volatile. How do you guarantee 100% visibility? It is true that the volatile keyword had this problem before JDK5 and had to be volatile. However, in JDK5 and later, the semantics of the volatile keyword were enhanced in JSR133. Volatile variables themselves can be thought of as a barrier. This ensures that the preceding and following variables have volatile semantics, and because volatile prevents reordering, the correct result can still be obtained in multiple threads.

Happens-before rules

The JMM can provide programmers with A guarantee of memory visibility across threads through A happens-before relationship (if there is A happens-before relationship between thread A’s write operation A and thread B’s read operation B, even though A and B are executed in different threads, But JMM assures the programmer that operation A will be visible to operation B. The specific definition is:

  • If operation A happens-before OPERATION B, the execution result of operation A will be visible to operation B, and operation A will be executed before operation B.
  • The existence of a happens-before relationship between two operations does not mean that the specific implementation of the Java platform must be executed in the order specified by the happens-before relationship. The reorder is not illegal (that is, the JMM allows it) if the result of the reorder is the same as the result of the happens-before relationship.

Happens-before includes the following six rules:

  • Procedure order rule: For every action in a thread, happens-before any subsequent action in that thread.
  • Monitor lock rule: a lock is unlocked, happens-before a lock is subsequently locked.
  • Volatile variable rule: Writes to a volatile field, happens-before any subsequent reads to that volatile field.
  • Transitivity: If A happens-before B, and B happens-before C, then A happens-before C.
  • Start () rule: if thread A performs an operation threadb.start () (starts ThreadB), then thread A’s threadb.start () operation happens before any operation in ThreadB.
  • Join () rule: if thread A performs the operation threadb.join () and returns successfully, any operation in ThreadB happens-before thread A returns successfully from threadb.join ().