并发编程二:深入理解JMM和并发三大特性(下)

并发编程二:深入理解JMM和并发三大特性(下)

上篇回顾

上篇说了并发发生问题的根源以及并发的三大特性都是:原子性、可见性、有序性。分析了JMM模型对内存交互的操作实现以及相关规则。分析了出现可见性问题的原因和过程,以及相关的解决方案。
上篇分析了出现可见性问题是因为:当前线程对共享变量的操作会存在读不到,或者不能立即读到另一个线程对此变量的写操作。解决方式归纳为两种:

  1. 在jvm层面使用了storeLoad内存屏障,在x86Linux下,lock替代了内存屏障。上篇说lock前缀指令可以理解为内存屏障,但lock并不是内存屏障,只是lock前缀指令实现了和内存屏障相同的效果,但是性能要比内存屏障要好。本篇还会对lock进行深入的分析。
  2. 上下文切换 Thread.yield();

本篇接着深入分析可见性,同时引出有序性、原子性并进行分析。同时会对JMM映射到硬件上的内存交互过程做一个详细分析。会涉及到很多硬件上的概念。
首先接着分析lock前缀指令在硬件层面的扩展,为什么要分析这个,这与JMM模型有关,为什么JMM选择共享内存模型,这又与处理器架构有关。所以分析硬件层面是有必要的。

JMM在硬件层面上的分析

先看下处理器的架构模型
在这里插入图片描述
我们的执行程序放在磁盘上,比如双击xxx.exe程序,就会在把有关的数据加载到内存当中。例如一个执行x=3;y=x+5的程序,先把yx等数据加载到内存当中,通过cpu中的ALU进行计算,ALU计算需要的值存在register(寄存器)当中,然后回写到内存当中。其他线程要拿到这些值要从内存当中获取。
来看先整个流程:
当要执行y=x+5这个指令的时候,ALU从寄存器中获取x=3的数据,第一次寄存器中没有就会从cache(cpu高速缓存)中获取,第一次cache中没有就会从缓存中获取x=3,从缓存中加载x=3到cache再到register最后计算的过程,就是JMM的read、load、use。然后ALU进行计算得出y=8然后写到寄存器、写到cache在写到内存,对应JMM就是assign、store、write。其他线程就能在内存中获取到y=8
这就是硬件上的大致流程,整个过程的内存交互通过总线进行。这里有个疑问就是为什么内存还要加载到cache而不是直接加载到寄存器中。这里涉及到cpu高速缓存的概念

cpu高速缓存

先来看下概念:CPU缓存即高速缓冲存储器,是位于CPU与主内存间的一种容量较小但速度很高的存储器。由于CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,Cache中 保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调 用,减少CPU的等待时间,提高了系统的效率。
在这里插入图片描述
上图说明了主内存的速度远远比不上cpu的高速缓存。
在这里插入图片描述
一般来说电脑都是单cpu多核架构,这三级缓存以及L1到L3逐渐增大,速度逐渐下降。对于L3的缓存是多核共享的缓存。就是通过高速缓存减少cpu的等待时间。
当然还有其他的用处:
在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就是局部性原理。

时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。 比如循环、递归、方法的反复调用等。
空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。 比如顺序执行的代码、连续创建的两个对象、数组等。

在mysql也有类型概念,例如mysql每次从磁盘读取都是以数据页为单位,一个数据页就是16KB。
多CPU多核缓存架构

物理CPU:物理CPU就是插在主机上的真实的CPU硬件,在Linux下可以数不同的physical id 来确认主 机的物理CPU个数。
核心数:我们常常会听说多核处理器,其中的核指的就是核心数。在Linux下可以通过cores来确认主机 的物理CPU的核心数。
逻辑CPU:逻辑CPU跟超线程技术有联系,假如物理CPU不支持超线程的,那么逻辑CPU的数量等于核心数的数量;如果物理CPU支持超线程,那么逻辑CPU的数目是核心数数目的两倍。在Linux下可以通 过 processors 的数目来确认逻辑CPU的数量。

在这里插入图片描述
上图多cpu多核缓存架构图:如果多个cpu那么共享主内存。在单个cup中L3的高速缓存被多核共享。L1和L2是每个核独有的高速缓存。其中L1有两个,一个存储指令,一个存储数据。

对于上述cpu架构有这么一种情况,如下图
在这里插入图片描述
在主内存中有x=5,线程一执行x+3操作,线程二执行x+5操作。最后主内存中x结果是多少呢?对于这个x的结果,如果按照之前的理论去理解的话就会有多种肯能,x可能等于8,线程一回写了,可能等于10线程二回写了,可能线程一覆盖线程二,可能线程二覆盖线程一,也可能线程一和线程二都没回写,那么x就等于5。但是对于这种结果肯定不是我们想要的。我们预期是x+3+5应该是等于13。那么这个架构就有问题了。那么在硬件层面上是怎么解决 这个问题的呢。这又牵扯到另一个概念缓存一致性

缓存一致性

计算机体系结构中,缓存一致性是共享资源数据的一致性,这些数据最终存储在多个本地缓存中。当系统中的客户机维护公共内存资源的缓存时,可能会出现数据不一致的问题,这在多处理系统中的cpu中尤其如此。
在共享内存多处理器系统中,每个处理器都有一个单独的缓存内存,共享数据可能有多个副 本:一个副本在主内存中,一个副本在请求它的每个处理器的本地缓存中。当数据的一个副本发 生更改时,其他副本必须反映该更改。缓存一致性是确保共享操作数(数据)值的变化能够及时地 在整个系统中传播的规程。
缓存一致性的要求

  • 写传播(Write Propagation)
    对任何缓存中的数据的更改都必须传播到对等缓存中的其他副本(该缓存行的副本)。
  • 事务串行化(Transaction Serialization)
    对单个内存位置的读/写必须被所有处理器以相同的顺序看到。理论上,一致性可以在加载/ 存储粒度上执行。然而,在实践中,它通常在缓存块的粒度上执行。
  • 一致性机制(Coherence mechanisms)
    确保一致性的两种最常见的机制是窥探机制(snooping )和基于目录的机制(directory- based),这两种机制各有优缺点。如果有足够的带宽可用,基于协议的窥探往往会更快,因为所有事务都是所有处理器看到的请求/响应。其缺点是窥探是不可扩展的。每个请求都必须广播 到系统中的所有节点,这意味着随着系统变大,(逻辑或物理)总线的大小及其提供的带宽也必须增加。另一方面,基于目录的机制,目录往往有更长的延迟(3跳 请求/转发/响应),但使用更少的带宽,因为消息 是点对点的,而不是广播的。由于这个原因,许多较大的系统(>64处理器)使用这种类型的缓存一致性

主要来了解窥探机制(snooping )。
总线窥探
总线窥探(Bus snooping)是缓存中的一致性控制器(snoopy cache)监视或窥探总线事务的 一种方案,其目标是在分布式共享内存系统中维护缓存一致性。包含一致性控制器(snooper)的 缓存称为snoopy缓存。该方案由Ravishankar和Goodman于1983年提出。
工作原理:当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。数据变更的通知可以通过总线窥探来完成。所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本。如果缓存中有共享块的副本,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效。它还涉及到缓存块状态的改变,这取决于缓存一致性协议(cache coherence protocol)
缓存一致性协议
根据管理写操作的本地副本的方式,有两种窥探协议:

  • Write-invalidate
    当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种 方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型
  • Write-update 当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。

这两种协议说的简单点就是Write-invalidate 机制会导致其他副本中的缓存失效,Write-update机制会是其他副本直接更新修改的值。
一致性协议在多处理器系统中应用于高速缓存一致性。为了保持一致性,人们设计了各种模型和协议,如MSI、MESI(又Illinois)、MOSI、MOESI、MERSI、MESIF、write-once、 Synapse、Berkeley、Firefly和Dragon协议。下面一个MESI为例子说明下如何实现的。
MESI协议
MESI协议是一个基于写失效的缓存一致性协议,是支持回写(write-back)缓存的最常用协议。对于MESI有一个缓存行(cache line)的概念。这个缓存行有大小为64个字节。怎么去理解这个缓存行。缓存行有下面这几种状态:

  • 已修改Modified (M)
    缓存行是脏的,与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).
  • 独占Exclusive (E)
    缓存行只在当前缓存中,但是干净的–缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。
  • 共享Shared (S)
    缓存行也存在于其它缓存中且是未修改的。缓存行可以在任意时刻抛弃。
  • 无效Invalid (I)
    缓存行是无效的
    在Linux下查看缓存行大小:cat /proc/cpuinfo
    在这里插入图片描述

缓存行会存储特定的值,什么是特定的值,下面在举例子说明,这个案例会说明MESI协议如何解决缓存一致性的问题。

缓存一致性案例说明

本案例一MESI协议为例子说明如何解决缓存一致性。在来看下这张图
在这里插入图片描述
主内存存在x=5然后两个线程分别对x进行+3和+8操作。
首先要想达到缓存一致性就必须让缓存一致性协议(这里指MESI)生效。那么怎么让MESI生效呢?这就涉及到上篇讲到的lock前缀指令。lock前缀指令对应到java层面来说的话,volatile底层就有lock前缀指令。也就是说如果volatile int x = 5那么此时从主内存加载到cpu高速缓存Cache当中MESI就生效了。之前说过MEAI有个缓存行cache line 大小为64字节。int 类型字节为4个,那么这个x=5会被加载到缓存行当中,并且也会加载到cpu高速缓存当中。

假设Tread1加载了x=5到cpu高速缓存中,那么缓存行里也会有这个x=5,由于总线窥探机制,知道当前x=5只有Thread的缓存中有,此时缓存行的状态为E(独占)。如果Thread2此时加载了x=5,那么缓存行的状态变成S(共享)。如果Thread1修改x的值,那么总线窥探机制会窥探到Thread1执行了写操作,那么此时缓存行的状态变成M(修改),当状态变成M会发生什么,在回过头了看下缓存行状态的含义,如果状态为M该缓存行必须回写到主存,状态变为共享(S)并且会把其他副本的缓存行状态变成I(无效),也就是Thread2的缓存行状态无效,那么x=5也就无效了,Thread1的缓存行状态变为S。此时主内存的x=8,Thread2由于缓存失效需要从新从主内存中读取x=8,然后Tread2在执行x+5的操作那么x就会等于13,然后重复上述步骤写会主存。这也就是lock前缀指令为什么能够保证了可见性的原因。
volatile能够保证可见性是因为在x86Linux下有lock前缀指令的实现,而lock前缀指令保证可见性是因为MESI缓存一致性这种硬件上的解决方案。
当然了以上过程还是会有问题的。如果说Thread1在执行写操作的时候,Thread2也在执行写操作,那么如果Thread1先回写了,就会导致Thread2的缓存行失效那么,Thread2的值就会丢失,此时x=8,同理,反过来Thread2先回写了,就会导致Thread1回写的值丢失。当然这也会有对应硬件上的处理措施,就不在深入分析。但是像这种情况在java层面来说就涉及到了原子性的问题。因为Tread1和Thread2对于x的操作不是原子性。这也就是为什么像i++等操作会导致数据丢失,因为i++也不具备原子性。之前说过导致并发问题的根本原因是:原子性,可见性,有序性。下面来分析下原子性和有序性问题

在分析原子性问题前再来看看其他问题,虽然不能保证MESI不能保证原子性,但是能够保证缓存一致性。那么什么情况下导致缓存一致性协议失效呢?这分为两种情况

  • 跨缓存行
    缓存行大小64个字节,如果缓存的内容超过了64个字节,就会导致缓存一致性失效
  • 在早期没有实现缓存一致性协议

早期没有实现缓存一致性协议是如何保证缓存一致性的呢?这是涉及到另一个概念:总线锁定。
还有一个问题是Thread1和Thread2能不能同时加载x呢?这是不能的,必须有一个先后顺序,这里也有另一个概念总线仲裁

总线仲裁和总线锁定

总线仲裁机制
在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都 是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(WriteTransaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。 这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写
在这里插入图片描述
假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁(Bus Arbitration)会对竞争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其他两个处理器则要等待处理器A的总线事务完成后才能再次执行内存访问。假设在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的请求会被总线禁止。 总线的这种工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
总线仲裁机制也有对应的局限性。原子操作是指不可被中断的一个或者一组操作。处理器会自动保证基本的内存操作的原子性,也就是一个处理器从内存中读取或者写入一个字节时,其他处理器是不能访问这个字节的内存地址。最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。还有就是像long、double类型是八个字节也就是64位,在64位的系统上处理没有问题,但是在32为的处理器上处理的话,cpu会把它分成两个32的高低位来处理,相当于两个总线事务,这样就不能保证原子性。
针对这些情况处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
总线锁定
总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
总线锁定就是说把整个主内存都锁定了,只能有一个总线在访问这个内存。总线锁定的非常影响性能,相当于回到了单核时代。
缓存锁定
由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大。 缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不会在总线上声言LOCK#信号(总线锁定信号),而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。
缓存锁定不能使用的特殊情况: 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定。 有些处理器不支持缓存锁定。
缓存锁定就是前面说的缓存一致性协议。

伪共享的问题

说到缓存一致性就会有一个伪共享的问题。
如果多个核的线程在操作同一个缓存行中的不同变量数据,那么就会出现频繁的缓存失效,即使在代码层面看这两个线程操作的数据之间完全没有关系。这种不合理的资源竞争情况就是伪共享 (False Sharing)。直接上例子

package demo;
import sun.misc.Contended;
/**
 * 伪共享
 */
public class FalseSharingDemo {

    public static void main(String[] args) throws InterruptedException {
        testPointer(new Pointer());
    }

    private static void testPointer(Pointer pointer) throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.x++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.y++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(pointer.x+","+pointer.y);
        System.out.println(System.currentTimeMillis() - start);
    }
}
class Pointer {
    long x;
    long y;
}

代码的意思是开了两个线程对Pointer 的成员变量进行累加一亿次,然后计算消耗的时间
先来看下这是不是线程安全的。
首先这是线程安全的。然后来看下结果
在这里插入图片描述
这里耗时65毫秒。
再来看下如果加上voleite会发生什么。
在这里插入图片描述
在这里插入图片描述
耗时3014毫秒。为什么耗时变长了好几倍。加入了volatile对性能的损耗是有影响的,这也就是缓存一致性协议在lock前缀指令的情况才会生效,并不是默认就生效的。其次这里涉及到一个伪共享的问题。就是说这两个线程虽然对同一个类进行操作,但是操作的是不同的变量,每个线程都在做它自己的事情,本身不存在线程安全问题。如果加上volatile,开启了缓存一致性协议,导致Pointer 类的x和y缓存共享了。就导致了这么一种情况:由于x,y是long总共16个字节,没有超过缓存行的大小
在这里插入图片描述
左边线程对x进行操作,导致右边线程缓存行失效,有边线程操作导致左边缓存行失效,不断需要从主内存中获取数据。这不合理,凭什么左边对x操作,导致了右边线程y的缓存失效。这就是一个伪共享问题。所以这种情况就不适合加上volatile

JMM可见性的保证

按程序类型,Java程序的内存可见性保证可以分为下列3类:

  • 单线程程序。
    单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线 程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。
    正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  • 未同步/未正确同步的多线程程序。
    JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入值,要么是默认值。未同步程序在JMM中执行时,整体上是无序的,其执行结果无法预知。 JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。

未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异。

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行,比如正确同步的多线程程序在临界区内的重排序。
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的 操作执行顺序。
  • 顺序一致性模型保证对所有的内存读/写操作都具有原子性,而JMM不保证对64位的long型和 double型变量的写操作具有原子性(32位处理器)。 JVM在32位处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这 两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性

深入分析原子性和有序性

原子性和有序性从根本来说也是为可见性服务的。根据上面的JMM可见性的保证的概念来分析下原子性和有序性。
从jvm和硬件角度来说,会在不影响结果的前提下进行重排序。比如

x=5;
y=6
z=x+3

程序在运行的时候不一定是按照上述代码个顺序来执行,有可能进行重排序,重排序成:

y=6;
x=5;
z=X+3

这样重排序后z的结果是不影响的。但是这样重排序有什么好处呢?按照原来的排序,先通过load加载x,在通过load加载y,最后执行z=x+3的时候还要再load加载一次x。如果按照下面的重排序后执行,在load加载x后马上就利用到了x就不需要再次load加载x。像这种重排序的优化不仅仅在jvm层面。
我们写的java代码,先从.java编译成.class在编译成jvm的指令序列再编译成汇编代码最后编译成二进制的机器码。在这个过程中在jvm指令序列和编译二进制的这两个过程中都有可能发生重排序优化。
像这种重排序在单线程的情况下是不会发生可见性的问题。
那么上述重排序在正确同步的多线程程序中时如何保证可见性。比如说通过锁或者volatile会禁止重排序保证可见性。怎么去理解这个正确同步呢?在这里插入图片描述
这是上一篇的案例部分代码截图。这个正确同步就是说一个线程falg=false是一个写操作,另一个线程while(flag)是一个读操作,通过volatile保证了读操作在写操作之后。这里有个语义叫做happens-before就是说threadB对flag的写操作会 happens-before threadA对flag的读操作

对于未正确同步或者不同步是保证不了的。像上面JMM可见性的保证说的那样:
顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行,比如正确同步的多线程程序在临界区内的重排序。怎么理解这句话。举个例子

/**
*  DCL为什么要使用volatile
*/
public class SingletonFactoryDemo {

   private static SingletonFactoryDemo myInstance;

   public static SingletonFactoryDemo getMyInstance() {
       if (myInstance == null) {
           synchronized (SingletonFactoryDemo.class) {
               if (myInstance == null) {

                   myInstance = new SingletonFactoryDemo();
               }
           }
       }
       return myInstance;
   }

   public static void main(String[] args) {
       SingletonFactoryDemo.getMyInstance();
   }
}

上述代码是一个单例模式,利用了双重检查锁机制DCL(double check lock)。为什么上述代码需要加上volatile
在这里插入图片描述
不加会有什么问题?
首先来看下上述代码的流程:第一个线程访问myInstance 肯定是空的,通过synchronized 持有了锁,然后第二个线程进来先判断if (myInstance == null);myInstance 如果!=null 就直接返回了 如果等于null ,就在 synchronized (SingletonFactoryDemo.class)等待。那么第一个线程myInstance = new SingletonFactoryDemo();后返回了释放了锁,第二个线程就能够进来在判断 if (myInstance == null);此时myInstance != null 就返回不会在创建第二次。
上述流程看起来没什么问题。实际上呢 坑点在于myInstance = new SingletonFactoryDemo();,在java上创建一个对象并不是原子操作会经历以下步骤
在这里插入图片描述
如果说在初始化的时候比较复杂(构造方法中各种骚操作,各种静态属性初始化),那么有可能会发生这种临界点的重排序 比如下面这样
在这里插入图片描述
2和3的指令重拍,2和3调换影响对象的创建吗,jvm在堆中已经开辟了空间了,我先指向这个内存地址在做复杂的初始化,和先做复杂的初始化在指向内存地址,有影响吗,对于对象的创建来说并没有影响。但是对于整个代码流程来说就有影响。
比如说第一线程在执行myInstance = new SingletonFactoryDemo();先执行了3 在执行2,在执行2的过程中,又有一个线程进来。
这个线程会先判断第一个if (myInstance == null)。那么此时这个线程还会进到synchronized (SingletonFactoryDemo.class)这步吗,就不会了因为myInstance 已经有了内存地址,就不为null 就会直接返回myInstance 。但是此时的myInstance 还在执行初始化的过程,或者说返回myInstance 的对象是没有初始化的。那么就会出现问题。
所以需要加上volatile,禁止了临界区的重排序。

在看JMM可见性的保证说的顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。怎么理解这句话。接着看例子

package demo;


public class ReOrderDemo {
    private static  int x = 0, y = 0;
    private static  int a = 0, b = 0;
    public static void main(String[] args) throws InterruptedException {
        int i=0;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    shortWait(20000);
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次(" + x + "," + y + ")");
            if (x==0&&y==0){
                break;
            }
        }
    }
    public static void shortWait(long interval){
        long start = System.nanoTime();
        long end;
        do{
            end = System.nanoTime();
        }while(start + interval >= end);
    }

}

解释下上述代码:在while循环中定义几个变量为0,开启两个线程,上面的线程延迟一段时间后执行a=1,x=b。下面的线程执行b=1,y=a,如果x和y都等于0则跳出循环。先来看下xy有哪些可能,然后它能不能跳出循环。
如果上面的线程执行就会有10,反之有01,上面线程执行到a=1的时候下面线程执行到b=1,此时就会有11。那么什么时候会出现00的情况呢?当发生指令重排的情况,对于上面的线程来说 a = 1; x = b;和x = b;a = 1;重排后执行结果并没有区别。下面线程也一样,重排序并不会对单个线程有影响,但是整体代码就会有影响,就会出现00的情况。
来看下运行结果:
在这里插入图片描述
说明发生指令重排确实对程序有影响。所以在未正确同步的情况下JMM不保证所有线程能看到一致的操作执行顺序。如果加上volatile会怎样呢?
在这里插入图片描述
在这里插入图片描述
就会一直循环下去。因为volatile禁止了重排序。
最后再来看看volatile的语义

volatile的语义

volatile的特性

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有
    原子性(基于这点,我们通过会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。 64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。
  • 有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性。 在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与 普通变量重排序。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义: 严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义

** volatile写-读的内存语义**

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中 读取共享变量。

volatile可见性实现原理

  • JMM内存交互层面实现
    volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立 即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。
  • 硬件层面实现 通过lock前缀指令
    会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机 制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值