聊聊高并发(三十三)Java内存模型那些事(一)从一致性(Consistency)的角度理解Java内存模型

可以说并发系统要解决的最核心问题之一就是一致性的问题,关于一致性的研究已经有几十年了,有大量的理论,算法支持。这篇说说一致性这个主题一些经常提到的概念,理清Java内存模型在其中的位置。


一致性问题更准确的说是一致性需求,看系统需要什么样的一致性保证。比如分布式领域的CAP理论说Consistency, Availability, Partition tolerance这三个要求同时只能满足两个,另外一个就要有所取舍。所以很多种场景下,Consistency可能只需要满足最终一致性,不用满足强一致性。


一致性问题在单机器单CPU的情况下是最简单的,由于只有一个CPU,所有的读写操作都可以按照全局的时间顺序执行

在单机器多CPU的情况下,多CPU并发执行,共用一个内存,一般通过共享内存的方式来处理一致性问题,通过定义满足不同一致性需求的内存模型来解决内存一致性问题(Memory Consistency)

在分布式环境中,多台机器多CPU通过网络来并发执行,一般通过消息通信的方式来处理一致性问题,比如分布式事务的多阶段提交,处理分布式存储的Paxos协议,ZooKeeper的Zab协议,处理的都是分布式存储场景下的数据一致性的问题。分布式环境中也有使用分布式共享内存的方式。


所以目前处理一致性问题主要有共享内存和消息通信这两个大的方式,每种方式里面又根据不同的需求有不同的实现方式。Java内存模型处理的就是单机器多CPU场景下的内存一致性问题


先来看看一致性的定义,这是冯诺依曼体系结构中对一致性的定义

Consistency: a read returns the most recently written value

一个读操作应该返回"最近"的一个写操作写入的值

但是"最近"(most recently)这个概念比较模糊,需要对其概念严格化,根据不同的严格化定义,这几十年来产生了多种不同的一致性定义,每种一致性定义要解决的场景也都有区别。

1. 严格一致性 Strict Consistency 线性一致性 Linearizability

这是最严格的概念模型,定义了在应用场景中,所有的读写操作都按照全局的时序来排列执行,比如在单机器多核CPU的场景下,所有的CPU需要共享一个全局的时钟顺序,并且所有CPU的任意读写操作都要按照这个全局的时钟顺序执行,一旦新写入了一个值,那么这个值必须马上被其他所有的CPU都能看到。在分布式场景下,所有的分布式节点都要共享一个全局的时钟顺序来执行。

严格一致性要求写操作能够马上(instantaneously)被传播出去,任意执行的节点要马上可以看到这个新写入的值。这个模型在数学上是可行的,但是在物理上是难以实现的,而且即使实现也是最低效率的,所以大家看看就好


2. 顺序一致性 Sequential Consistency

顺序一致性不要求全局的时钟顺序,它只需要各个CPU局部的时钟顺序,它由三个要点

  • 对每个单个CPU来说,它看到自己程序的执行顺序始终是和程序定义是一致的(单个CPU角度)
  • 每个CPU看到的其他CPU的写操作都是按照相同的顺序执行的,大家看到的最终执行的视图是一致的(从全局的角度)
  • 单个CPU对共享变量的写操作马上对其他CPU可见

这篇为什么程序员需要关心顺序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence)的例子很好,很能说明顺序一致性的特点,下面的例子来自这篇文章。


根据顺序一致性的特点,我们知道r1和r2的只能有这3种结果,因为顺序一致性允许不同的CPU并发执行,但是对单个CPU的指令来说是按照执行的程序顺序执行的,所以不会出现r1 = y先于x=1执行的情况。并且所有的处理器都只会看到同一个全局的执行顺序,要么Execution1,或者2,或者3,不会出现两个处理器看到不同的全局执行顺序的情况。这也就要求了单个处理器的写操作要马上被其他处理器可见。


从上面的例子我们可以看到严格的顺序一致性模型其实也是个概念模型,限制了编译器的优化空间。实际的实现中编译器做了大量的优化工作,这些优化工作的基础就是指令重排序操作,而指令重排序打破了这种严格的顺序一致性,比如单个处理器看到的指令执行顺序可以和它的程序定义顺序一致。


3. 因果一致性 Causal Consistency

因果一致性是一种弱的顺序一致性,只有有因果关系的数据才需要保证顺序一致性,没有因果关系的数据不需要保证顺序一致性,也就是说对于没有因果关系的数据不需要其他处理器看到一致的视图。

那么什么是因果关系呢?必须处理器A写了一个x = a, W(x)a,处理器B先读取x的值,再写x的值,R(x)a, W(x)b,那么对处理器B来说,它的写x操作和处理器A的写x操作 就有因果关系,因为它后写入的值可能依赖于处理器A先写入的值。这时候这两个操作要保持顺序一致性,也就是说其他处理器看到的顺序都是W(x)a, W(x)b。

实习因果一致性的实现复杂,需要额外的建立一个依赖关系图,即一个操作依赖于其他什么操作。


4. 处理器一致性/ PRAM(Piplined RAM) 管道式存储器

这两种一致性经常被放在一起,概念基本一致,他们比因果一致性更弱,只要求从一个处理器来的写操作按照同样的顺序被其他处理器看到,不同处理器的写操作可以按照不同的顺序被看到,也就是说它不保证有因果关系的写操作按照执行的顺序执行,拿上面因果一致性的例子来说,虽然W(x)a和W(x)b存在因果关系,但是对不同的处理器来说,它们可以先看到a也可以先看到b。

它的优点是同一个处理器的写操作被管道化,相当于用管道串行了,并且隐藏了写操作的延迟。比如一个处理器的写操作可能还在写缓存区没有刷新到内存被其他处理器看到,这个处理器的读操作可以马上进行对这个变量的读操作,而不需要等待它的写操作完全被写入内存,大大提高了系统的性能。不同处理器的写操作是并行执行的

处理器一致性还是比较严格的一致性模型,因为同一个处理器的写操作还是严格按照顺序执行。而重排序的优化可以对没有数据相关性的写操作进行重排序。


上面这几种一致性模型处理的问题域是对所有的共享变量而言,下面三种一致性模型是针对有明确定义的同步变量而言,可以理解为Java中的volatile变量,内置锁的获取/释放


5. 弱一致性 Weak Consistency

弱一致性只对被同步操作保护的共享变量而言,规定了只有对共享变量的同步操作完成之后,共享数据才可能保持一致性.在同步操作过程中,是不保证一致性的,单个处理器对共享变量的修改对其他处理器是不可见的。相比与严格的顺序一致性,它只保持了执行顺序上的顺序一致性,至于可见性必须要等待同步操作结束

  • 对同步变量的读写按照顺序一致性
  • 只有所有对同步变量的写操作完成之后才能对同步变量进行访问
  • 只有所有对同步变量的访问(读/写)完成后才能对同步变量访问


6. 释放一致性 Release Consistency

弱一致性的粒度太大,包含了进入同步操作和释放同步操作两部分,而只有同步操作整体完成后,其他处理器才有可能保持一致性。 释放一致性规定了对同步变量的释放操作后,就对同步变量的状态广播到其他处理器


7. 进入一致性 Entry Consistency

和释放一致性一样,也是为了减小弱一致性的粒度,进入同步变量时,获取同步变量的最新状态


所以如果一个共享变量要被同步操作保护,那么所有操作它的地方都要被同步保护,否则就不保证一致性


8. 缓存一致性 Cache Consistency

缓存一致性的语义和上面的数据一致性模型有些区别,它主要说的是多个CPU缓存之间的一致性协议,我们要知道的是现代CPU基本都提供了缓存一致性的实现,比如一个CPU修改了一个缓存,那么其他CPU可以马上看到修改的缓存数据。这篇文章说了下缓存一致性的内容聊聊高并发(五)理解缓存一致性协议以及对并发编程的影响


在分布式存储领域的弱一致性,最终一致性的语义和并发编程里的一致性语义稍有差别,实际上事务隔离级别也是对数据一致性的不同需求,这些概念以后有机会说数据库的时候再提。


在这篇文章聊聊高并发(十九)理解并发编程的几种"性" -- 可见性,有序性,原子性 中我们说并发编程中的可见性和有序性,分析了上面的这么多一致性模型,我们可以看到顺序一致性是严格保证了所有共享变量的可见性和有序性。深入理解Java内存模型(三)——顺序一致性 的这张图画的很有意思,所有对共享变量的操作在顺序一致性下被串行了


但是顺序一致性的性能是很差的,而且实现起来很昂贵,一方面它限制了重排序来保证执行顺序,另一方面它对所有的共享变量都要求保证可见性,来使所有CPU看到一直的执行视图。

现代的计算机系统提供了大量的优化操作,比如多个阶段的指令重排序,松散的内存模型。一般性能越高的机器,提供的内存模型越发地松散,比如允许各种情况的重排序,从而提高优化的空间。而常用的X86架构,只允许“写后读”的重排序。


Java内存模型是构建在这些底层内存模型上的语言级的内存模型,它要屏蔽底层各种内存模型的差异性,提供一致的对上层应用的一致性试图。另一方面它又要支持多种级别的优化操作,所以实际执行的程序和写的Java代码是完全不一样的顺序。


对于没有同步的共享变量的操作,Java内存模型只保证从单线程执行的角度来说,程序的执行结果和程序定义的结果是一致性的,只保证正确性,但是不保证执行的顺序性,因为没有数据相关性的代码是可以重排序的。


对于提供了同步的共享变量的操作,Java内存模型保证了弱一致性 / 释放一致性 / 进入一致性,通过加内存屏障(Memory Barrier)实现。

  • 读volatile变量,进入锁,都会保证进入一致性,刷新CPU缓存,保证数据的可见性。
  • 写volatile变量,释放锁,都会保证释放一致性,把写缓存区数据刷新到内存,保证数据的可见性。
  • Java内存模型还保证了同步变量的顺序性,对于同步变量的操作不允许进行指令重排序,比如锁临界区的数据逸出到临界区外,volatile的写操作被重排序到前面(JDK1.5之前volatile没有防止重排序的语义,导致双重检查加锁的单实例模式实际是失效的)

Java内存模型还支持一组Happens-Before定义的偏序关系,后面会专门说说Happens-Before


结论是Java内存模型是一种松散的语言级内存模型,提供了给编译器充分优化的空间,它只对显式同步的共享数据提供弱一致性支持,比如volatile变量,内置锁,显式锁,各种同步器,要记住的是这些被同步手段保护的共享数据在语义上是有顺序一致性的,防止重排序保证了它们在单个线程的顺序性,内存屏障又保证了它们在多个线程的可见性和顺序性

在一致性这个问题域中,各个层面扮演的角色大致如下:

1. 一致性模型,定义了各种一致性模型的理论基础

2. 硬件层,提供了实现某些一致性模型的硬件能力。硬件在默认情况下按照最基本的方式运行,比如

  • 对同一个线程没有数据依赖的指令可以重排序优化执行,有数据依赖的指令按照程序顺序执行,从而保证单线程程序运行的正确性
  • 保证读操作读到的数据肯定是之前在同一位置写入的数据

3. 语言层,少数语言提供了语言层面的满足一致性模型的编程能力,另外一些语言则直接使用硬件层提供了一致性编程的能力。提供一致性能力语言的工作方式如下:

  • 把满足一致性需求的编程能力作为一种资源,指定一些规则,比如volitile, synchronized,Happens-before规则等
  • 当应用层需要使用这种编程能力的时候,需要显式地提出申请,比如显式地使用volatile来标识变量
  • 通过编译器适配底层各种硬件平台提供了一致性编程的能力,比如有些平台使用内存屏障,有些平台使用read-modified-write,需要语言层来屏蔽这种差异性


4. 应用层,比如分布式系统,比如并发的服务器程序,它们在一致性问题中的工作有

  • 根据实际需求来定义应用所需要满足的一致性需求
  • 定义和选择相应的实现一致性需求的算法,比如分布式存储中通过消息协议实现的Paxos,Zab,多阶段提交等
  • 利用编程语言提供了基本的一致性编程的能力作为实现一致性需求算法的基础

从这个层面上理解, Java内存模型主要做了几件事情

1. 适配各种底层硬件平台提供的一致性编程能力,比如加一个内存屏障,在不同平台下要加的内存屏障的数量,顺序可能不同

2. 定义Happens-before规则

3. 定义了各种语言级提供一致性能力的语法,比如volatile, final, synchronized, 显式锁,各种同步器,Unsafe对CAS操作的封装等等



参考资料:

Consistency Model

深入理解Java内存模型(三)——顺序一致性

为什么程序员需要关心顺序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence)

存储一致性总结

阅读更多
所属专栏: 聊聊高并发
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭