并发编程-Java内存模型

时间:2023-01-08 17:57:51

JMM:Java内存模型
指令重排序分为:
编译器重排序-》指令并行重排序-》内存系统重排序(后两者都属于处理器重排序)

JMM通过禁止特定类型的重排序实现一致的内存可见性。

重排序:为了达到程序在运行的时候拥有更高的并行度,更快的执行率编译器和处理器会对程序进行一定的重新整理,其实就是为了实现对程序的优化对指令的执行顺序进行重新排序。但是很多重排序都是导致程序错乱的罪魁祸首,所以重排序时应该遵守as-if-serial语义happens-before规则,这样才能保证程序的正确执行。

对于多处理器的主机,每一个处理器多会有一个读写缓冲区,而且每一个处理器的缓冲区只对自己可见,对其他处理器不可见。处理器在进行读写的时候都是先读写到缓冲区中,再写到主存,在这个过程中如果是多线程的环境就极有可能产生脏读的现象,因为处理器的对缓冲区的写操作与缓冲区的对内存的写操作不一致。

编译器通过插入特定内存屏障的方式来避免某些指令的重排序。

内存屏障类型
LoadLoad Barriers : 比如说指令Load1;LoadLoad ;Load2 就是确保Load1数据装载先于Load2以及后续所有的装载指令。

StoreStore Barriers : 比如说指令Store1;StoreStore ;Store2 就是确保Store1数据对其他的处理器都可见(刷新到内存),而且先于Store2以及后续所有的存储指令存储。

LoadStore Barriers : 比如说指令Load1;LoadStore ;Store2 就是确保Load1数据装载先于Store2以及后续所有的存储指令存储刷新到内存。

StoreLoad Barriers : 比如说指令Store1;StoreLoad ;Load2 就是确保Shore1对其他处理器可见(刷新到内存),先于Load2以及后续所有的装载指令的装载,它会使该屏障之前所有的访存指令(装载和存储)执行完了以后再去执行屏障之后的内存访问指令。

happens-before规则:A hanppens-before B强调A操作对B操作可见,或者是A的操作结果对B可见,而不是强调他们执行的先后顺序。另外一个happens-before规则对应于一个或者多个处理器重排序规则或者编译器重排序规则,以下是它的几种规则:
(1)程序顺序规则:单线程内一个线程的每个操作,都happens-before于它的每一个后续操作;
(2)监视器锁规则:对一个锁的解锁happens-before于后续对这个锁的加锁;
(3)volatile变量规则:对于一个volatile变量的写hanppens-before于后续对这个变量的读;
(4)传递性:happens-before满足传递性;

as-if-serial语义:即在程序重排序的过程中应该不改变的程序的执行结果。即不重排序和重排序的执行结果应该一致。

依赖性在重排序中的影响:
(1)数据依赖性:如果两个操作访问同一个变量,而且其中有一个为写操作,那么这两个操作之间就存在数据依赖性,存在数据依赖性的两个操作,在单线程中,如果存在数据依赖性的两个操作产生了重排序,那么可能就会影响运行结果,在多线程中就不会有影响。

(2)控制依赖性在多线程中,对于控制依赖性的操作进行重排序,可能会对结果产生影响,单线程中控制依赖性就没有影响。

顺序一致性:假如在JMM中程序的执行能够被正确的同步,那么程序的执行具有顺序一致性,即将程序执行完了以后与该程序在顺序一致性模型中的执行结果是一样的(我认为意思就是程序在顺序一致性模型下的结果就是我们逻辑上正确的结果,而如果在JMM中程序没有被正确的执行的话可能就会产生错误的结果)。

数据竞争:当一个线程在写一个变量的同时另一个线程在读这个变量,而且他们之间没有通过同步来实现他们逻辑上的有序就称这个变量存在数据竞争,数据竞争导致程序的错乱。

顺序一致性的表现:顺序一致一般有两种表现形式,第一种就是一个线程执行完了所有操作另一个线程执行即:A1->A2->->An ->B1->B2->->Bn或者是不同线程交替执行,A1->B1->A2->B2->->An–>Bn

顺序一致性模型和JMM的差别
(1)顺序一致性模型保证单线程内的所有操作都按代码定义的顺序执行,而JMM不能保证。
(2)顺序一致性模型保证所有的线程都只能看到同一种操作执行顺序,而JMM不能保证,对于线程私有内存不同线程是相互不可见
(3)JMM中不保证像lang、double这类64位的数据在32位的机器上的读写操作的原子性(但是从jdk5的内存模型开始,对于任意读操作就是原子性的了,而64位的写操作还是非原子性),而顺序一致性模型中所有的内存读写操作都是原子的。

总线事务:分为总线读事务(从内存读到处理器)和总线写事务(从处理器写到内存),总线总是会试着将并发的总线事务同步执行,因为在一个处理器在执行总线事务的时候总线会禁止其他处理执行总线事务。

volatile内存语义
volatile变量的特性:
(1)可见性:对于一个volatile变量的读,总是能看到任意线程对这个volatile变量的写入;
(2)原子性:对任意的单个的volatile读、写操作都具有原子性,但是对类似自增之类的复合操作,就不能保证原子性了。

volatitle变量的写-读内存语义:
从JDK5开始volatile变量的写读可以实现线程之间的通信,比如volatile变量的写的内存语义是锁的释放,而volatile读的内存语义是锁的获得。

注意:当一个volatile变量写的时候JMM会将新值立刻刷新到主内存之中去,并且让其他处理器缓存中的该变量失效。

volatile内存语义的总结:
(1)线程A写一个volatile变量,实质上就是线程A像接下来读取这个变量的线程B发出了一个消息;
(2)线程B读一个volatile变量实质上就是收到某个线程发出的消息(在写这个volatile变量之前对共享变量所做的修改);
(3)线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主存向线程B发了消息。

volatile内存语义的实现
(1)当第二个操作是volatile写操作的时候,不管第一个是什么操作都不能重排序,确保volatile写操作之前不会被编译器重排序到volatile之后;
(2)当第一个操作为读操作的时候,不管第二个操作是什么,都不能重排序,确保volatile读操作之后的操作不会被拍到volatile之前;
(3)当第一个为volatile写,第二个为volatile读,不能重排序。

JMM实现volatile内存语义的保守策略:
(1)在每个volatile写操作之前加一个SS屏障
(2)在每个volatile写操作之后加一个SL屏障
(3)在每个volatile读操作之后加一个LL屏障
(4)在每个volatile写操作之后加一个LS屏障

PS:L代表Load S代表Store,仅仅是在我这里这样简记,不是官方的

这个策略非常保守在任意的处理器平台上程序都能得到正确的volatile内存语义。

class VolatileExample{
int a;
volatile int v1=1;
volatile int v2=1;


void readAndWriter(){
int i=v1; //操作1 volatile读

int j=v2; //操作2 volatile读

a=i+j; //操作3 普通写

v1=i+1; //操作4 volatile写

v2=j*2; //操作5 volatile写

}

}

并发编程-Java内存模型
并发编程-Java内存模型

volatile只是实现了单个volatile变量的读写保证原子性,而锁机制是保证整个临界区内的代码的原子性,所以在使用的时候应该注意,避免造成程序错误。

锁释放和获取的内存语义:
(1)释放锁时,JMM就会将当前线程的工作内存中的共享变量刷新到主存中去;
(2)获取锁时,JMM就会使该线程中的共享变量失效,从而是的被监视器保护起来的临界区代码必须从主内存中去获取新值。
综上两点可知当一个线程释放锁以后,之后获取该锁并执行相同临界区的线程可以看到和该线程相同的变量(A的操作结果对B可见)。

锁释放与获取的总结:
(1)A线程释放一个锁,实质上就是向之后要获取这个锁的某个线程发出消息;
(2)线程B获取一个锁,实质上就是接收了之前某个线程发出的消息;
(3)线程A释放锁,线程B随后获取锁,实质上就是线程A向线程B发出了消息。

在Java中ReentrantLock的实现依赖于同步器框架AbstractQueuedSynchronizer(一下简称AQS),AQS中使用了一个volatile变量来维护他的同步状态。ReentrantLock可分为公平锁于非公平锁。

在AQS框架中有一系列的compareAndSet方法,对于这类方法都简称为CAS操作,对于CASjdk给出了如下定义:如果当前的值等于预期的值,就将同步状态的值设置为更定的更新值,这个操作同时具有和volatile读和写相同的内存语义。

多处理器机通过加lock前缀的方式来实现CAS(但处理器自身会维护单处理器内的顺序一致性,不需要lock前缀)。

intel中对lock前缀指令的定义
(1)在Pentium以及之前的处理器中,通过总线锁定的方式来实现CAS操作的原子性,但是这样的的开销特别大;
(2)之后的处理器中都是用了缓存行锁定的方式来实现CAS的原子性;
(3)禁止该指令,与之前和之后的读和写指令重排序;
(4)把所有的写缓冲区中的所有数据刷新到内存中。

公平锁与非公平锁内存语义总结:
(1)公平锁与非公平释放时最后都要写一个volatile变量state;
(2)公平锁获取锁的时候,首先会去读一个volatile变量;
(3) 非公平锁获取锁的时候,首先会用CAS更新volatile变量,这个操作具有volatile读和写的内存语义。

通过上面的对ReentrantLock的公平所与非公平锁的内存语义的分析可知对于锁的释放-获取的内存语义的实现至少有两种方式
(1)利用volatile的写-读具有的内存语义;
(2)利用CAS所附带的volatile读和volatile写的内存语义。

PS:CAS的原子性其实就是处理器以原子操作的方式实现了对内存执行“读-改-写”操作

通过以上的总结可知线程间至此为止有四种通信方式了:
(1)通过A线程写volatile变量,线程B随后读这个volatile变量;
(2)线程A写volatile变量随后线程B通过CAS更新这个变量;
(3)线程A通过CAS更新一个volatile变量,随后线程B通过CAS再次更新他;
(4)线程A通过CAS更新一个volatile变量,随后线程B读取这个volatile变量。

java.util.concurrent包的实现:
(1)首先,申明共享变量为volatile;
(2)然后,使用CAS的原子条件来更新实现线程间的通信;
(3)同时,配合以volatile读\写和CAS所具有的的内存语义来实现线程间的通信。

包括AQS,非阻塞数据结构,和原子变量类这些concurrent包中的基础类都是按照这个模式来实现的,而其他的高层类又是基于这些基层类来实现的。
并发编程-Java内存模型

final内存语义定义
final域的重排序规则:
(1)在构造函数内对一个final域的写入,与随后把这个被构造的对象赋值给一个引用变量,这两个操作之间不能重排序(即对于在构造函数中对final域初始化happens-before于将生成的对象赋值给相应的引用);
(2)初次读一个包含final域的对象的引用,与随后的初次访问这个对象里面的final域,两个操作之间不能够被重排序(拥有具体对象的引用happens-before于使用这个对象里面的final域)。

但是对于普通域的读写则不具备以上两条准则

final的写重排序规则
(1)JMM禁止把final域的写重排序到构造函数之外;
(2)会在final域的写之后构造函数return之前,加入一个SS屏障来实现(1)

final域的读重排序规则:在同一个线程中JMM会禁止将读对象的引用与读这个对象里面的final域重排序,编译器读每一个final域之前加一个LL屏障实现。

final内存语义在现在的处理器中的实现:编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏;在读final域的操作前面插入一个LoadLoad屏障。

happens-before
对happens-before禁止重排序的分类:
(1)会改变程序执行结果的重排序;
(2)不会改变程序执行结果的重排序。

对于以上两种重排序JMM在实现的时候采取了不同的策略:对于类型(1)坚决禁止,类型(2)JMM对于编译器和处理器在优化的时候都不做要求(可以任意重排序,反正你都改变不了结果)。

并发编程-Java内存模型
并发编程-Java内存模型

JSR-133 中happens-before的定义:
(1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
(2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

上面的(1)是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
上面的(2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。

as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。as-if-serial语义和happens-before这么做的目的,都是为了 在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

happens-before规则:
《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则。
(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的
读。
(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

例1:
并发编程-Java内存模型
分析:
(1)1 happens-before 2和3 happens-before 4由程序顺序规则产生。由于编译器和处理器都要遵守as-if-serial语义,也就是说,as-if-serial语义保证了程序顺序规则。因此,可以把程序顺序规则看成是对as-if-serial语义的“封装”。
(2)2 happens-before 3是由volatile规则产生。前面提到过,对一个volatile变量的读,总是能看到(任意线程)之前对这个volatile变量最后的写入。因此,volatile的这个特性可以保证实现volatile规则。
(3)1 happens-before 4是由传递性规则产生的。这里的传递性是由volatile的内存屏障插入策略和volatile的编译器重排序规则共同来保证的。

例2:
并发编程-Java内存模型
解析:
1 happens-before 2由程序顺序规则产生。2 happens-before 4由start()规则产生。根据传递性,将有1 happens-before 4。这实意味着,线程A在执行ThreadB.start()之前对共享变量所做的修改,接下来在线程B开始执行后都将确保对线程B可见。

例3:假设线程A在执行的过程中,通过执行ThreadB.join()来等待线
程B终止;同时,假设线程B在终止之前修改了一些共享变量,线程A从ThreadB.join()返回后会
读这些共享变量。
并发编程-Java内存模型
解析:2 happens-before 4由join()规则产生;4 happens-before 5由程序顺序规则产生。根据传递性规则,将有2 happens-before 5。这意味着,线程A执行操作ThreadB.join()并成功返回后,线程B中的任意操作都将对线程A可见。

Java内存模型综述:
(1)顺序一致性模型是一个理论参考模型,JMM是一个语言级别的内存模型,处理器内存模型是一个硬件级别的模型。
(2)处理器级别的内存模型,都比较追求速率,所以规则都比较松,限制比较弱,而且各个处理器的实现还有差别,所以JMM为了给程序员提供一个一致 的内存模型,在编译的时候就在适当的位置插入了适当的内存模型,即JMM屏蔽了不同处理器之间内存模型的差异,他在不同的处理器平台之上为Java程序员呈现了一个一致性内存模型。
(3)JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型、处理器内存模型和顺序一致性内存模型的强弱对比示意图。
并发编程-Java内存模型
并发编程-Java内存模型
JMM的内存可见性保证:
(1)单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
(2)正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
(3)未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
并发编程-Java内存模型
注意,最小安全性保障与64位数据的非原子性写并不矛盾。它们是两个不同的概念,它们“发生”的时间点也不同。最小安全性保证对象默认初始化之后(设置成员域为0、null或false),才会被任意线程使用。最小安全性“发生”在对象被任意线程使用之前。64位数据的非原子性写“发生”在对象被多个线程使用的过程中(写共享变量)。当发生问题时(处理器B看到仅仅被处理器A“写了一半”的无效值),这里虽然处理器B读取到一个被写了一半的无效值,但这个值仍然是处理器A写入的,只不过是处理器A还没有写完而已。最小安全性保证线程读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。但最小安全性并不保证线程读取到的值,一定是某个线程写完后的值。最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。