当前位置: 首页 > 后端技术 > Java

98%的程序员,都没有研究过JVM重排序和顺序一致性

时间:2023-04-01 16:03:51 Java

98%的程序员都没研究过JVM的重排序和顺序一致性一个是写操作,此时两个操作之间存在数据依赖。数据依赖分为以下三种:namecodeexampledescriptionreadafterwritinga=1;b=一个;写入变量后读取此位置。写完后写a=1;一=2;写完一个变量,再写这个变量。读后写a=b;b=1;读取一个变量后,写入这个变量。以上三种情况,只要将两个操作的执行顺序重新排序,就会改变程序的执行结果。如前所述,编译器和处理器可能会重新排序操作。编译器和处理器在重新排序时会尊重数据依赖性,并且编译器和处理器不会更改具有数据依赖性的两个操作的执行顺序。注意,这里所说的数据依赖只是针对在单个处理器中执行的指令序列和在单个线程中执行的操作,编译器和处理器并没有考虑不同处理器和不同线程之间的数据依赖。as-if-serialsemanticsas-if-serialsemantics的意思是:无论怎么重新排序(编译器和处理器提高并行度),(单线程)程序的执行结果都不会改变。编译器、运行时和处理器都必须遵守似串行的语义。为了符合as-if-serial语义,编译器和处理器不会对有数据依赖的操作重新排序,因为这样的重新排序会改变执行结果。但是,如果这些操作之间不存在数据依赖性,则编译器和处理器可能会对其进行重新排序。具体解释请看下面计算圆面积的代码示例:COPYdoublepi=3.14;//双r=1.0;//Bdoublearea=pi*r*r;//以上三个操作在C中的数据依赖如下图所示:如上图所示,A和C之间存在数据依赖,B和C之间也存在数据依赖。因此,在最终执行的指令序列中,C不能重新排到A和B的前面(C放在A和B的前面,程序的结果会改变)。但是A和B之间没有数据依赖,编译器和处理器可以重新排序A和B之间的执行顺序。下图展示了程序的两种执行顺序:as-if-serial语义保护了单线程程序,符合as-if-serial语义的编译器、runtime和processor是为编写的程序员共同创建的单线程程序造成了一种错觉:单线程程序是按照程序的先后顺序执行的。as-if-serial语义使单线程程序员不必担心重新排序会干扰他们或内存可见性问题。程序顺序规则根据happens-before的程序顺序规则,上述计算圆面积的示例代码中存在三个happens-before关系:COPYAhappens-beforeB;B发生在C之前;A发生在C之前;第三个这里的happens-before关系是从happens-before的传递性推导出来的。这里Ahappens-beforeB,但是在实际执行中,B可以先于A执行(见上面重排序后的执行顺序),如果Ahappens-beforeB,JMM并不要求A必须先于B执行。JMM只要求前一个操作(执行的结果)对后一个操作可见,并且前一个操作在第二个操作之前是有序的。这里,操作A的执行结果不需要对操作B可见;将操作A和操作B重新排序后的执行结果与happens-before顺序中的操作A和操作B的执行结果一致。在这种情况下,JMM会认为这次重新排序不是非法的(notillegal),JMM允许这次重新排序。在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的情况下,尽可能地发展并行性。编译器和处理器遵循这个目标,我们从happens-before的定义中可以看出,JMM也遵循这个目标。重新排序对多线程的影响现在让我们看看重新排序是否会改变多线程程序的执行结果。请看下面的示例代码:COPYclassReorderExample{inta=0;布尔标志=假;publicvoidwriter(){a=1;//1标志=真;//2}publicvoidreader(){if(flag){//3inti=a*a;//4...}}}标志变量是用来标识变量a是否已经写入的标志。这里假设有两个线程A和B,A先执行writer()方法,然后B线程再执行reader()方法。线程B在执行操作4时,线程A能看到操作1中共享变量a的写入吗?答案是:不一定可见。由于操作1和操作2没有数据依赖性,编译器和处理器可以重新排序这两个操作;同样,操作3和操作4没有数据依赖性,编译器和处理器也可以对这两个操作进行重新排序。我们先看看操作1和操作2重新排序后会发生什么?请看下面的程序执行时序图:如上图所示,操作1和操作2已经重新排序。程序执行时,线程A先写入标记变量flag,然后线程B读取这个变量。由于条件的计算结果为真,线程B将读取变量a。这个时候变量a根本就没有被线程A写过,这里多线程程序的语义被重排序破坏了!注:本文中红色虚线箭头表示错误的读操作,绿色虚线箭头表示正确的读操作。接下来,让我们看看当操作3和4重新排序时会发生什么(借助于此重新排序,我们可以顺便说明控制依赖关系)。下面是操作3和操作4重新排序后的程序执行时序图:在程序中,操作3和操作4之间存在控制依赖关系,当代码中存在控制依赖关系时,影响的程度指令序列执行中的并行性。为此,编译器和处理器会使用推测(Speculation)执行来克服控制依赖对并行性的影响。以处理器的推测执行为例,执行线程B的处理器可以预先读取并计算a*a,然后将计算结果暂时保存在一个叫做reorderbuffer(ROB)的硬件缓存中。当判断下一个操作3的条件为真时,将计算结果写入变量i。我们从图中可以看出,猜测执行本质上是对操作3和4进行了重新排序。重新排序在这里破坏了多线程程序的语义!在单线程程序中,有控制依赖的操作重排序不会改变执行结果(这就是为什么as-if-serial语义允许有控制依赖的操作重排序);但是在多线程程序中,Reordering操作有控制依赖可能会改变程序的执行结果。顺序一致性数据竞争和顺序一致性保证当程序没有正确同步时,就会存在数据竞争。java内存模型规范对datarace的定义是这样的:在一个线程中写入一个变量,在另一个线程中读取同一个变量,写入和读取没有同步顺序。当代码包含数据竞争时,程序执行通常会产生违反直觉的结果。如果一个多线程程序能够正确同步,这个程序将是一个没有数据竞争的程序。JMM对正确同步的多线程程序的内存一致性做出如下保证:如果程序正确同步,则程序的执行将具有顺序一致性(sequentiallyconsistent)——即程序的执行结果与程序在顺序一致的内存模型中的执行结果是相同的(我们很快就会看到,这对程序员来说是一个非常有力的保证)。这里的同步指的是广义上的同步,包括对常用同步原语(lock、volatile、final)的正确使用。顺序一致性内存模型顺序一致性内存模型是计算机科学家理想化的理论参考模型,它为程序员提供了强大的内存可见性保证。顺序一致性内存模型有两个特点:一个线程中的所有操作都必须按照程序的顺序执行。(不管程序是否同步)所有线程只能看到单一的操作执行顺序。在顺序一致的内存模型中,每个操作都必须以原子方式执行,并且对所有线程立即可见。顺序一致性内存模型为程序员提供了以下视图:从概念上讲,顺序一致性模型具有单个全局内存,可以通过左右切换连接到任何线程。同时,每个线程必须按照程序顺序执行内存读/写操作。从上图我们可以看出,在任意一个时间点,最多只能有一个线程连接到内存。当多个线程并发执行时,图中的开关设备可以将所有线程的所有内存读写操作序列化。为了更好的理解,下面我们用两张图进一步说明顺序一致性模型的特点。假设有两个线程A和B并发执行。其中线程A有3个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有3个操作,它们在程序中的顺序是:B1->B2->B3。假设两个线程使用一个监视器正确同步:A线程在执行完三个操作后释放监视器,然后B线程获取同一个监视器。那么程序在顺序一致性模型下的执行效果如下图所示:现在假设两个线程不同步。下面是顺序一致性模型中非同步程序的执行示意图:虽然一致性模型中整体执行顺序是乱序的,但是所有线程只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1->A1->A2->B2->A3->B3。这种保证是可能的,因为顺序一致的内存模型中的每个操作都必须立即对任何线程可见。但是,在JMM中没有这样的保证。不仅JMM中非同步程序的整体执行顺序是乱序的,所有线程看到的操作的执行顺序也可能不一致。例如,在当前线程将写入的数据缓存到本地内存,还没有刷新到主存之前,写操作只对当前线程可见;从其他线程的角度来看,会认为写操作根本就没有做。由当前线程执行。只有当当前线程将本地内存中写入的数据刷新到主存后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序会不一致。同步程序的顺序一致性影响我们用监视器同步前面的示例程序ReorderExample,看看一个正确同步的程序是如何具有顺序一致性的。请看下面的示例代码:COPYclassSynchronizedExample{inta=0;布尔标志=假;publicsynchronizedvoidwriter(){a=1;标志=真;}publicsynchronizedvoidreader(){if(flag){inti=a;...}}}在上面的示例代码中,假设线程A执行writer()方法后,线程B执行reader()方法。这是一个正确同步的多线程程序。按照JMM规范,程序的执行结果会和顺序一致性模型下程序的执行结果是一样的。下面是程序在两种内存模型下的执行时序对比图:在顺序一致性模型中,所有的操作都是按照程序的顺序串行执行的。在JMM中,临界区中的代码可以重新排序(但JMM不允许临界区中的代码“逃逸”到临界区外,这会破坏监视器的语义)。JMM会在退出管程和进入管程这两个关键时间点做一些特殊的处理,让线程在这两个时间点和顺序一致性模型有相同的内存视图(后面会详细说明)。虽然线程A在临界区重新排序了,但是这里的线程B由于监视器的互斥执行特性,根本无法“观察到”线程A在临界区重新排序。这种重新排序不仅提高了执行效率,而且不改变程序的执行结果。从这里我们可以看出JMM在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器优化打开大门。非同步程序的执行特性对于非同步或未正确同步的多线程程序,JMM仅提供最低限度的安全性:线程在执行期间读取的值要么是前一个线程写入的值,要么是默认值(0、null、false),JMM保证线程读操作读取到的值不会凭空而来(outofthinair)。为了达到最低限度的安全性,JVM在堆上分配对象时,首先清理内存空间,然后再在其上分配对象(JVM内部会同步这两个操作)。因此,当为对象分配预置零内存时,该字段的默认初始化已经完成。JMM不保证非同步程序的执行结果与顺序一致性模型下程序的执行结果一致。因为非同步程序是在顺序一致性模型中执行的,所以它在整体上是无序的,其执行结果是不可预测的。保证一个非同步程序在两个模型中执行相同是没有意义的。与顺序一致性模型一样,在JMM中执行一个非同步程序时,其整体也是乱序的,其执行结果是不可预测的。同时,这两种模型中非同步程序的执行特点有如下区别:顺序一致性模型保证单个线程中的操作会按照程序的顺序执行,而JMM不保证一个线程中的操作单线程会按照程序顺序执行(如上面临界区正确同步的多线程程序的重新排序)。这一点前面已经讲过,这里不再赘述。顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程都能看到一致的操作执行顺序。这一点前面已经讲过,这里不再赘述。JMM不保证64位long和double变量的读/写操作的原子性,而顺序一致性模型保证所有内存读/写操作的原子性。第三个区别与处理器总线的工作方式密切相关。在计算机中,数据通过总线在处理器和内存之间传递。处理器和内存之间的每次数据传输都是通过一系列步骤完成的,这些步骤称为总线事务。总线事务包括读事务和写事务。读取事务将数据从内存传输到处理器,而写入事务将数据从处理器传输到内存。每个事务在内存中读取/写入一个或多个物理上连续的字。这里的关键是总线将同步尝试并发使用总线的事务。在一个处理器的总线事务期间,总线禁止所有其他处理器和I/O设备执行内存读/写。我们用一张示意图来说明总线的工作机制:如上图所示,假设处理器A、B、C同时向总线发起总线事务,总线仲裁将对竞争做出裁决.这里我们假设总线在仲裁后决定处理器A赢得竞争(总线仲裁确保所有处理器都能公平地访问内存)。此时,处理器A继续它的总线事务,而另外两个处理器必须等待处理器A的总线事务完成,然后才能重新开始执行内存访问。假设在处理器A执行总线事务期间(不管总线事务是读事务还是写事务),处理器D向总线发起总线事务。这时处理器D的请求就会被总线禁止。总线的这些工作机制可以实现所有处理器对内存的串行访问;在任何时间点,最多一个处理器可以访问内存。此功能可确保单个总线事务中内存读/写操作的原子性。在一些32位处理器上,如果要求64位数据的写操作是原子的,会有比较大的开销。为了照顾这个处理器,java语言规范鼓励但不要求JVM在写64位long和double变量时是原子的。JVM运行在这样的处理器上时,会将一个64位的long/double变量的写操作拆分成两个32位的写操作来执行。这两个32位的写操作可能会分配给不同的总线事务,此时对这个64位变量的写就不会是原子的了。当单个内存操作不是原子操作时,可能会出现意想不到的后果。请看下图示意图:如上图所示,假设处理器A写了一个long变量,同时处理器B要读这个long变量。处理器A中的64位写操作被拆分为两个32位写操作,两个32位写操作分配给不同的写事务执行。同时,将处理器B中的64位读操作分配给单个读事务执行。当处理器A和B按上面显示的顺序执行时,处理器B将看到处理器A仅“写入一半”的无效值。请注意,在JSR-133之前的旧内存模型中,a的读/写操作64位的long/double类型的变量可以拆分成两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),只允许将一个64位的long/double变量的写操作拆分为两个32位的写操作,任何读操作都在JSR-133必须是原子的(也就是说,任何读取操作都必须在单个读取事务中执行)。本文由传智教育博学谷狂野建筑师教研团队发布。如果本文对您有帮助,请关注并点赞;有什么建议也可以留言或私信。您的支持是我坚持创作的动力。转载请注明出处!