Java Multithreading High Concurrency Basics (6) - JMM Reordering Rules

We know that the purpose of reordering is to improve the execution performance of the program by the compiler and processor without changing the execution result of the program. However, reordering is not arbitrary. To understand reordering, you need to know the rules that reordering must follow. To sum up, it is the Happens-Before rule we are going to talk about today. There are related descriptions in JSR-133: JavaTM Memory Model and Thread Specification. Please see the pdf file for the original English version, and download a copy for everyone to learn.

1. Happens-Before rule

The Happens-Before rule specifies the circumstances under which instructions cannot be reordered.

  • Program order principle: Within a thread , the process of code execution must ensure semantic seriality (as-if-serial, it seems to be serial; in addition, if there are dependencies in the data in the program, reordering is not allowed).
  • Monitor lock rules: Unlocking must happen before locking.
  • Transitive rule: If operation A precedes operation B, and operation B precedes operation C, then operation A must precede operation C.
  • Volatile rule: A write operation of a shared variable must precede a read operation, which is a requirement of volatile visibility semantics.
  • Thread start rule: A thread's start operation precedes any other operations within the thread.
  • Thread join rules: If the ThreadB.join() method is executed in the thread ThreadA, all operations of ThreadB are prior to the operations returned by ThreadB.join() in ThreadA.

2. Interpretation of the Happens-Before rule

1. as-if-serial semantics

It looks like serial - the mechanism of the reordering is transparent to the programmer by the compiler and processor, but the results we observe are consistent with the order in which the program was written, which is what it looks like.

To give an example:

 

int a = 2; // A
int b = 3; // B
int c = a*b; // C

In the above program, step C depends on steps A and B, but there is no dependency between steps A and B, and the dependency graph looks like this:

 

According to the order of program execution rules, since C depends on A and B, then the execution order of C cannot be ranked before A and B, but the order of A and B is interchangeable, that is, we execute in program order Semantics, the execution order seen is this: A-->B-->C, but the compiler and processor may reorder it like this: B-->A-->C, but this process is very important to us Said to be transparent, but the end result is the same as what we want. This is what looks like as-if-serial semantics.

 

2. Lock Rules

In concurrent programming, locks guarantee mutually exclusive access to critical sections, while also allowing the thread that releases the lock to send messages to another thread.

Let's take an example and start with a piece of code.

public class MonitorDemo {
  int a = 0;
  public synchronized void writer() { // 1
     a++; // 2
  } // 3
  public synchronized void reader() { // 4
      int i = a; // 5
      ……
  } // 6
}

 

For example, there are now two threads A and B. Thread A executes the writer method, and thread B then executes the reader method. According to the happens-before principle, let's sort out the happens-before relationship contained in this process.

①According to the principle of program sequence execution sequence, 1-->2-->3; 4-->5-->6

②According to the monitor lock rules, the acquisition of the lock precedes the release of the lock, so when thread A has not finished executing the writer, thread B cannot obtain the lock. So 3-->4.

③According to the transitive rule, then we can get 2-->5.

Finally, the happens-before relationship diagram we get is like this:


 

 

 

 That is to say, when thread B acquires the lock released by thread A, the content of the shared variable operated by thread A is visible to B (step 2 of thread A changes the value of a, and step 5 of thread B obtains The latest value of a can be obtained immediately after the same lock).

Here we also summarize the semantics of locks in concurrent programming:

  • Thread A releases a lock, in essence, thread A sends a message (the modification thread A made to the shared variable) to a thread that will acquire the lock next.

  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前共享变量所做修改的)消息。

  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。  

 3.volatile规则

3.1 volatile语义

在并发编程中,单个volatile变量的读、写可以看成是使用同一把锁对单个变量读写操作进行了同步锁操作。

例如,线程A和线程B执行下列代码,线程A执行set()方法,线程B随后执行get()方法。使用volatile
变量和对普通变量进行操作加锁的执行效果是一致的。

下面两个代码执行效果是等价的:

 

public class VolatileDemo {
  volatile long vl = 0L; // 使用volatile声明64位的long型变量
  public void set(long l) {
     vl = l; //1. 单个volatile变量的写
  } 
  public void getAndIncrement () {
     vl++; // 复合(多个)volatile变量的读/写
  }
  public long get() {
     return vl; //2. 单个volatile变量的读
  }
}

public class VolatileDemo2 {
  long vl = 0L; // 64位的long型普通变量
  public synchronized void set(long l) { // 对单个的普通变量的写加同步锁
     vl = l;
  }
  public void getAndIncrement () { // 普通方法调用
     long temp = get(); // 调用已同步的读方法
     temp += 1L; // 普通写操作
     set(temp); // 调用已同步的写方法
  }
  public synchronized long get() { // 对单个的普通变量的读加同步锁
     return vl;
  }
}
换句话说,volatile变量的写与锁的获取有相同的内存语义,volatile变量的读与锁的释放有相同的内存语义,这也就证明了对单个volatile变量的读写操作是原子性的,但是对volatile变量进行复合操作不具有原子性的,这个一定要注意。

 

我们来梳理下使用volatile变量的happens-before关系图,可能对理解更有帮助。

来个例子,线程A和线程B执行下列代码,线程A执行set()方法,线程B随后执行get()方法:

 

public class VolatileDemo {
  volatile long vl = 0L; // 使用volatile声明64位的long型变量
  public void set(long l) {//1
     vl = l; // 2
  } 
  public long get() {//3
     return vl; // 4
  }
}
 

 

①根据程序顺序执行原则,1-->2,;3-->4

②根据volatile规则,volatile变量的写先于读,所以2-->3

③根据传递性规则,1-->4

所以,我们最后得到的happens-before关系图是这样的:


总结一下,volatile的内存语义:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

3.2 volatile语义的实现

我们先来看看编译器制定的volatile重排序规则表。


 在规则表中,我们可以明确看到,

当第一个操作是volatile读时,不管第二个操作是什么都不能重排序。

当第一个操作是volatile写时,第二个操作为volatile读、写时不能重排序。

 

为了实现volatile的语义,编译器在编译代码时候,会生成对应的内存屏障指令,来禁止特定类型操作的处理器重排序。JMM采用保守(认为每个都必须这么做)的内存屏障插入策略来实现volatile语义:

  • 在每个volatile操作的前面插入一个StoreStore屏障。
  • 在每个volatile操作的后面插入一个StoreLoad屏障。
  • 在每个volatile操作的后面插入一个LoadLoad屏障。
  • 在每个volatile操作的后面插入一个LoadStore屏障。

举一例子体会下:

public class VolatileBarrierDemo {
	int c;
	volatile int a = 1;
	volatile int b = 2;
	void readAndWrite() {
		int i = a; // 第一个volatile读
		int j = b; // 第二个volatile读
		c = i + j; // 普通写
		a = i + 1; // 第一个volatile写
		b = j * 2; // 第二个 volatile写
	}
}

 最后生成的指令执行示意图如下(红色部分的屏障可以省略掉,因为紧跟着的操作跨越不了已有的屏障):



 

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=326012587&siteId=291194637