Third, talk concurrent - Why Java Concurrency must understand the Java Memory Model

Foreword

Earlier we said that the causes of insecurity threads in concurrent programming, mainly because of the shared variable visibility, reordering, atomic, also slightly raised bit memory model, memory model what is it? Why is it important to understand the Java memory model it? Then we come to this article chat Java Memory Model

What is the Java Memory Model

The main objective of the Java memory model program access rules defined for each variable, i.e., stored in the memory so that the underlying details and variables will be removed from memory in a virtual machine variables. Variable here with Java programming when said variable is not the same, including the only instance fields, static fields and elements that make up an array of objects, in fact, is what we call in Java instance variables and member variables, but does not include local variables and method parameters.

In addition, the memory model also describes a mechanism for multi-threaded read and write shared variables, the previous article we mentioned that in the long-winded here again. Java memory model specifies all the variables are stored in main memory, each thread also has its own working memory. Working memory thread stored in the main memory copy of a copy of the variables used in the thread, the thread all operations on shared variables must be in working memory, you can not directly operate the main memory variables, but the variables are copied to the local memory , the local memory after the operation is completed, then the results back to the main memory synchronization between different threads can not access the other variables in the working memory directly.

Here to explain, we are talking about here is the main memory, working memory, said memory to run at the level of division and the JVM heap, stack, method area, not on a division level, basically there is no relationship, from more fundamental point of view, the main memory directly correspond to a physical hardware memory, working memory is a virtual machine (or hardware, operating system) may make the working memory and the priority stored in the register in the buffer cache, the program is running, the main access is working memory.

UTOOLS1577690490404.png

JMM solution to the problem of concurrency

Java memory model definitions are all around the atomic variables, visibility, and re-ordered to expand, some of the rules enacted, all in order to ensure atomicity shared variables, visibility, orderly.

  • Atomic: we say that the operation of a shared variable must be atomic in nature, because if you do not have the atomicity, multi-threaded under that situation, if there is no other additional synchronous operation, will produce data inconsistencies.

  • Visibility: We usually say that the memory visibility. Suppose there are two threads A, B, while a shared variable D operation, there is no time to get the thread thread B A D operating results of the future, then we say that the operation thread for thread A B operation is not visible .

  • Reordering: in a multithreaded environment, for a shared variable if non-atomic operation, it may exhibit the phenomenon of out of order.

In order to solve Java reordering, atomicity, visibility problem, a two-level language keywords Synchronized and Volatile.

Here we will not mention the static keyword, although the static keyword can be disguised to solve the problem, but does not have a relationship, and memory model, but to use the static keyword special loading mechanism.

The volatile keyword is the most lightweight synchronization mechanism provided by the Java memory model, it can keep things in order and visibility of memory multithreading shared variables. The synchronized keyword is a mutex, method or block of code can guarantee at run time, the same time only one way to enter the critical region, while it can guarantee the visibility of the shared memory variables. Because the protected area at the same time only one thread access, so the atomic issues have been changed to protect the. We can see the process through the implementation of synchronized image below.

synchronized.gif

Java Memory Model DCL

We pass a specific example look at the use of volatile and synchronized

The following is a way to write a singleton pattern double check, although we rarely used this wording in the actual development, but in this specific wording we can analyze synchronized and volatile. We should use the actual development enumeration, static code block, static inner singleton class more.

public class Singleton {
private static Singleton instance = null;
private int a;
private Singleton() {
a = 4;
}
public static Singleton getInstance() {
if (instance == null) { // 1. 第一次检查
synchronized (Singleton.class) { // 2
if (instance == null) { // 3. 第二次检查
instance = new Singleton(); // 4
}
}
}
return instance;
}
}
复制代码

The above wording is incorrect, the above wording is no way in the case of multi-threaded to ensure that a single case, we have to analyze.

If there are A, B are two threads, thread A first execution proceeds to step 4, since the instance = new Singleton (); not atomic, we know that a new object into the following steps.

a. Create an object instance, allocates a memory space.

b. Make object header, the initialization properties.

c. The object reference assigned to the instance.

假如线程A此时已经执行到了步骤4,因为步骤4不是一个原子操作,所以可能b和c操作之间发生了重排序,导致对象还没有完成属性的初始化,直接就将对象的引用赋给instance。而此时线程B到执行到了步骤1,发现此时instance不为null直接就返回了Singlotan,但是此时线程B拿到的是一个不完整的对象。修改的方式则是通过使用volatile来修饰,使用volatile修饰以后,步骤4也就不会发生重排序的情况。

内存模型对于原子性的保证

在这里我想单独的来介绍一下关于原子性的问题。

Java内存模型定义了8种原子操作,来完成内存的操作。

  1. read(读取):它把一个变量的值从主内存传输到线程的工作内存中,以便以后的load。
  2. load(载入):把read操作从主内存中的得到的变量值放入到工作内存中的变量副本中
  3. use(使用):把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值字节码指令时,会使用到这个指令。
  4. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时,执行此操作。
  5. store(存储):作用于工作内存的变量,它把工作内存中一个变量值传送到主内存中,以便随后的write操作使用。
  6. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入到主内存变量中。
  7. lock(锁定)把变量标识为一条线程独占的状态
  8. unlock(解锁):把一个处于锁定状态的变量释放出来

上面的那些指令1 - 6,是Java程序和计算机交互的时候使用到的指令,这些指令就涉及到了汇编层面。

int x = 10;             //语句1
int y = x; //语句2
x++; //语句3
x = x + 1; //语句4
Object z = new Object();//语句5
复制代码

上述的几种情况,只有语句1是原子操作。这样来解释一下吧,因为语句1,只使用到了一个assign指令就完成了上述的操作,因为只是将一个常数赋值给了变量x。但是其他的几种情况都是多个指令才能完成。内存模型虽然能保证这8个单独的指令是原子性的, 但是没有办法保证这些指令组合在一起的原子性。如果想保证这些组合指令的原子性,只能通过额外的操作来完成,比如说加锁。

内存模型规定,把一个变量从主内存复制到工作内存中,就要顺序的执行read和load操作,如果要把变量同步回主内存,就要顺序的执行store和write操作。需要注意的是,内存模型只规定了顺序执行,但是没有规定两个操作是连续执行。也就是说read 和 load ,store和write之间可以插入其他的指令,如对主内存中的变量a、b进行访问,可能出现的顺序是read a、read b、load b、load a。这也说明了为什么原子性会导致线程不安全。

内存模型之Happens-befor

如果代码的所有有序性都要通过volatile和synchronized来实现,那这样会让使用者感到很繁琐。我们在编写代码时候,不需要时刻考虑自己编写的代码之间是否会发生重排序,那是因为Java语言中有一 个“先行发生”(Happens-Before)的原则,它是判断数据是否存在竞争,依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操 作之间是否可能存在冲突的所有问题。规则如下:

  1. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before于书写在后面的操作。
  2. 监视器锁规则:在一个监视器锁上的解锁操作,必须在同一个监视器锁加锁之前执行。
  3. volatile变量规则:对一个volatile变量的写操作,happens-before于对这个变量的读操作。
  4. 传递性:如果操作 A happens-before 操作 B,而操作 B happens-before操作C,则可以得出,操作 A happens-before 操作C
  5. 线程启动规则:在线程上调用start()方法,必须在该线程执行任何操作之前执行。
  6. 线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已结束之前执行。
  7. 终结器规则:对象的构造函数必须在启动该对象终结器之前执行完成。
  8. 中断规则:对线程 interrupt方法的调用,happens-before被中断线程的代码检测到中断事件的发生。

happens-befor是阐述操作之间的内存可见性。==如果一个操作的结果,需要对另外一个操作可见,那么这连个操作之间必须存在happens-befor关系。这两个操作可以在一个线程内,也可以是在不同的线程之间。== 两个操作之间存在happens-before关系,并不意味着一定要按照 happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照 happens-before关系来执行的结果一致,那么这种重排序并不非法。

内存模型之as-if-serial

as-if-serial相对于happen-befor还是好理解很多。as-if-serial语义的是:所有的操作均可以为了优化而被重排序,但是必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守 as-if-serial语义。注意,as-if-serial 只保证单线程环境,多线程环境下无效。

如何理解上面的话呢,举个例子

int a = 1;
int b = 2;
复制代码

这两个赋值操作之间不存在任何的数据依赖,那么这两个操作,是可以被重排序的。有可能先给b变量赋值,再给a变量赋值。 但是如果操作是

int a = 1;
int b = a + 1;
复制代码

这两个操作是不能够重排序的。因为变量b 的值依赖于变量a。而且只能是在单线程环境下。多线程环境下,是没有办法保证的。

内存模型之volatile

JMM对于volatile读写的规则定义:

  1. 写的内存语义: 当写一个volatile变量时,操作完成以后JMM会把线程对应的本地内存中的共享变量立刻刷新到主内存。
  2. 读的内存语义: 当读一个volatile变量时,JMM会把线程对应的本地内存置为无效,线程接下来从主内存中读取共享变量。

JMM对volatile变量重排序规则定义:

  1. 如果第一个操作为volatile读,则不管第二个操作是什么,都不能重排序。这个操作确保volatile 读之后的操作,不会被编译器重排序到 volatile 读之前;
  2. 如果第二个操作为 volatile 写,则不管第一个操作是什么,都不能重排序。这个操作确保volatile写之前的操作,不会被编译器重排序到 volatile 写之后;
  3. 当第一个操作 volatile 写,第二个操作为 volatile 读时,不能重排序。

JMM是如何实现volatile的语义规则

可见性的实现

如果一个变量被声明volatile,那这个变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

有序性的实现

编译器在生成字节码时,会在指令序列中插入内存屏障,来禁止特定类型的处理器重排序。JMM采用了保守策略,规则如下:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。保证在volatile写之前,其前面的所有普通写操作,都已经刷新到主内存中。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。保证在volatile写之前,其前面的所有普通写操作,都已经刷新到主内存中。
  • 在每个volatile读操作的前面插入一个LoadLoad屏障。禁止处理器把上面的volatile读,与下面的普通读重排序。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。禁止处理器把上面的volatile读,与下面的普通写重排序。

内存模型之final

对于final域,编译器和处理器要遵守两个重排序规则。

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用 变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能 重排序。

通过例子以及下面的分析,来看一下上面的两个规则。

public class FinalTest {
int i; //普通变量
final int j; //final变量
static FinalTest obj;

public void FinalTest() { //构造函数
i = 1; //写普通域
j = 2; //写final域
}

public static void writer() { //写线程A执行
obj = new FinalTest();
}

public static void reader() { //读线程B执行
FinalTest object = obj; //读对象引用
int a = object.i; //读普通域
int b = object.j; //读final域
}
}
复制代码

final域写重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含2个方面。

  1. JMM禁止编译器把final域的写重排序到构造函数之外。

  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障 禁止处理器把final域的写重排序到构造函数之外。

    下面这个这个执行时序是可能发生的情况

final write semantics .png

写普通域的操作被编译器重排序到了构造函数之外,读普通域读取了初始化之前的值。而final域则不存在这种情况,final域被"规则"限定在了构造函数中,确保获取值的线程可以得到正确的结果。写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被 正确初始化过了,而普通域不具有这个保障。

final域读重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器。

Reading final semantic .png

读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。

如果final域是引用类型的话,写final域的重排序规则对编译器和处理器增加了如下约束:

在构造函数内,对一个final引用对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这个两个操作之间是不准重排序的。通过这个规则我们也相当于反向的验证了为什么在DCL单例模式中,为什么还需要使用volatile来修饰那个对引用对象了。

总结

通过上述所说,我们基本上对Java内存模型有了清晰的概念。知道了Java内存模型到底是什么,可以干什么,然而实际开发中我们只会使用到某些关键字。为啥还要掌握内存模型呢?

Then I look, although the development will only use my personal view is to write a keyword, but understand the memory model for the realization of these keywords or some of the specifications is still necessary to understand the Java memory model that can help us re-write concurrent program, let us make a judgment on the security of the code, it is determined whether the code is thread-safe, reduce some unnecessary mistakes. In addition, when I have a problem complicated by generated, it can help us live quickly locate problems and timely solutions given. I would like to write Java concurrent programs, understand the memory model is an essential step.

reference:

"Java Concurrency in combat."

"In-depth understanding of the Java Virtual Machine"

"In-depth understanding of the Java Memory Model"

Guess you like

Origin juejin.im/post/5e8005bdf265da794e52610b