Java内存模型中的顺序一致性

1,450 阅读13分钟

目录


1、Java内存模型的基础
2、Java内存模型中的顺序一致性
3、happens-before
4、同步原语(volatile、synchronized、final)
5、双重检查锁定与延迟初始化
6、Java内存模型综述

重排序


重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下面这三种类型:

名称 代码示例 说明
写后读 a=1 b=a 写一个变量之后,再读这个变量
写后写 a=1 a=2 写一个变量之后再次写这个变量
读后写 a=b b=1 读一个变量之后再写这个变量

对于这三种情况,只要重排序两个操作的执行顺序,那么程序的执行结果就会被改变。编译器和处理器可能会对操作做重排序,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。(这里说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑)

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,程序的执行结果都不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。示例如下:

double a = 3.14;        // A
double b = 1.0;         // B
dounle c = a * b * b;   // C

如上述代码,A和C之间存在依赖关系,B和C之间也存在依赖关系,因此在最终执行的指令序列内,C不能被重排序到A和B前,因为这样程序的结果会被改变,但是A和B之间没有依赖关系,编译器和处理器可以重排序A和B之间的执行顺序:

程序顺序规则

根据happens-before的程序顺序规则,上面的代码存在3个happens-before关系:

1、A happens-before B
2、B happens-before C
3、A happens-before C

这里的第3个happens-before关系,是根据happens-before的传递性推导出来的。

这里A happens-before B,但实际执行时B却可以排在A之前执行,(因为他们之间没有依赖关系),如果A happens-before B,Java内存模型并不要求A一定要在B之前执行,仅仅要求前一个操作对后一个操作可见,且前一个操作按顺序排在第二个操作之前,这里操作A的执行结果不需要对操作B可见;并且重排序操作A和操作B后的执行结果与不重排序的操作结果是一致的,这种情况下,Java内存模型会认为这种重排序不非法,Java内存模型允许这种重排序。

在计算机中,软件计数和硬件计数有一个共同的目标;在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器遵循这一目标,从happens-before的定义来看,Java内存模型同样也遵守这一目标。

重排序对多线程的影响

如下代码:

class example{
    int a = 0;
    boolean flag = false;
    
    public void writer(){
        a = 1;              // 1
        flag = true;        // 2
    }
    
    public void reader(){
        if(flag){           // 3
            int i = a * a;  // 4
            .....
        }
    }
}

假设有两个线程A和B,A执行writer方法,B执行reader方法,B在执行到操作4的时候,能否看到A在操作1对共享变量a的写入呢?

答案:不一定

原因:操作1和操作2没有依赖关系,编译器和处理器可以对1和2进行重排序,线程A先执行了2操作,然后线程B读到的flag=true,就会进入,此时A还没有对a变量进行写入,这里多线程程序的语义就被重排序破坏了。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因)。但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

顺序一致性


顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。

数据竞争与顺序一致性

当程序未正确同步时,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义如下:

1、在一个线程中写一个变量。
2、在另一个线程中读同一个变量。
3、写和读没有通过同步来排序。

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(上面的示例就是这样),如果一个多线程程序能正确同步,那么这个程序将是一个没有数据竞争的程序。

Java内存模型对正确同步的多线程程序的内存一致性做了如下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性--即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同,这里的同步是广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。

顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特征:

1、一个线程中所有操作必须按照程序的顺序来执行。
2、(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型视图如下:

在概念上,顺序一致性内存模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时每一个线程必须按照程序的顺序来执行内存读/写操作。从上图可知,在任意时间点最多只有一个线程可以连接到内存,当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)

可以看下面内容再理解:假设有两个线程,A和B,两个线程并发执行,其中A线程有三个操作,他们在程序中的执行顺序为:A1->A2->A3,同样,B线程也有三个操作,他们在程序中的执行顺序为:B1->B2->B3。假设这两个线程使用监视器锁来正确同步,A线程三个操作之后释放监视器锁,随后B线程获取同一个监视器锁,那么程序在顺序一致性内存模型中的执行顺序如下:

如果两个线程没有做同步,下图为未同步程序在顺序一致性内存模型中的执行示意图:

未同步程序在顺序一致性内存模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。如上图,线程A和B看到的执行顺序都是A1->B1->B2->A2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是在Java内存模型中没有这个保证,未同步程序在Java内存模型中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。

同步程序的顺序一致性效果

这里修改一下之前的程序,用锁来做同步,看正确同步的程序如何保持顺序一致性:

class example{
    int a = 0;
    boolean flag = false;
    
    public synchronized void writer(){
        a = 1;            
        flag = true;       
    }
    
    public synchronized void reader(){
        if(flag){          
            int i = a;  
            .....
        }
    }
}

上面示例中,假设A线程执行writer方法,B线程执行reader方法,这是一个正确同步的多线程程序。根据Java内存模型规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:

在Java内存模型中,临界区内的代码可以重排序(但是不允许临界区的代码“溢出”到临界区之外,这样会破坏监视器的语义)。Java内存模型会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有有顺序一致性内存模型的内存视图。虽然线程A在临界区内做了重排序,由于监视器互斥执行的特性,这里的线程B根本无法观察到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

这里可以看到,Java内存模型的具体实现上的基本方针为:在不改变(正确同步的)程序结果前提下,尽可能的为编译器和处理器的优化打开了方便之门。

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,Java内存模型只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),Java内存模型保证线程读操作读取到的值不会是无中生有的。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间分配对象时,域的默认初始化已经完成了。

Java内存模型不保证未同步程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(本该你做的事情,不能等到我来帮你做)。因为如果想要保证执行结果一致,Java内存模型要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。而且,保证未同步程序在这两个模型中的执行结果一致没有什么意义。

未同步程序在两个模型(顺序一致性内存模型、Java内存模型)中的执行特性有如下几个差异:

1、顺序一致性模型保证单线程内的操作会按照程序的顺序执行,而Java内存模型不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
2、顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而Java内存模型不保证所有线程能看到一致的操作执行顺序。
3、Java内存模型不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有内存读/写操作都具有原子性。

在一些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规范之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆分两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许一个64位long和double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性(即任意读操作必须要在单个事务中执行)。

如果有需要的话可以关注一下我的公众号,会即时更新Java相关技术文章,公众号内还有一些实用资料,如Java秒杀系统视频教程、黑马2019的教学资料(IDEA版)、BAT面试题汇总(分类齐全)、MAC电脑常用安装包(有一些是淘宝买的,已PJ的)。