java并发编程(三)——Java内存模型

上篇我们了解到并发编程的共享模型,即同一父进程下的其他子线程会共享父进程的资源,并且子线程在共享资源进行写操作时,可能导致不可预料的错误。那么为什么会导致这样的错误呢?有没有办法解决这样的问题呢?要解决这些问题,就要了解java的内存模型。在学习java内存模型之前,我们先要了解什么是内存模型。

内存模型(Memory Model)

为什么要有内存模型

我们知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。程序运行时的数据是存放在主存当中的,也就是计算机的物理内存。刚开始,大家相安无事,但是随着CPU技术的发展,CPU执行速度越来越快,而内存的技术变化不大,内存执行的速度已经远远落后于CPU,这样每次内存和CPU之间交换数据时,CPU都会耗费大量的时间来等待内存的读写操作。

为了解决CPU和内存执行速度的不同带来的资源浪费,人们想出了高速缓存的解决办法。即当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,CPU进行计算时直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。因为高速缓存的执行速度明显快于内存,这样就减少了CPU资源的浪费。

随着CPU能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L3),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。

缓存一致性的问题

为了提升计算机的执行效率,开始出现了单线程到多线程,单核CPU到多核CPU的转变。这就可能导致缓存一致性的问题,它们可能出现的情况如下:

单线程:始终只有一个线程占有CPU,一个线程执行完才会执行另一个线程,CPU与缓存之间的数据交换不会出现错误。

多线程:多个线程会竞争CPU,每个线程都会将需要的数据拷贝到缓存中,这样当主存中的数据被某个线程改变但是没有写入主存时,其他线程读取的数据仍然是旧值,从而造成了错误。这就是缓存一致性问题,即线程并不能及时的知道其它线程的工作内存变化。

缓存一致性的解决:

为了解决缓存不一致性问题,通常来说有以下2种解决方法:

1、通过在总线加LOCK#锁的方式。

2、通过缓存一致性协议。

在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

 

重排序

编译器和处理器在执行程序时并不一定按照代码的顺序执行,而是为了更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率,对指令进行了重排序。但是重排序必然不能在某些特定情况下进行,否则程序运行结果肯定会出现问题。我们根据数据的依赖性来区分这些情况,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

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

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

注意,指令重排序只保证单线程之间进行重排序时不会出现错误,但是在多线程的情况下进行指令重排就可能出现错误。

as-if-serial 语义:不管怎么进行指令重排序,单线程内程序的执行结果不能被改变。编译器,处理器进行指令重排序都必须要遵守as-if-serial语义规则。

为了遵守 as-if-serial 语义,编译器和处理器对存在依赖关系的操作,都不会对其进行重排序,因为这样的重排序很可能会改变执行的结果,但是对不存在依赖关系的操作,就有可能进行重排序。通过下面的程序来说明:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C  

上面三个操作的数据依赖关系如下图

A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种可能的执行顺序 

as-if-serial 语义将单线程程序保护了起来,使得单线程程序不用担心指令重排序带来的问题。

重排序对多线程的影响 

通过下面的程序我们可以看到指令重排序对多线程是否有影响

class ReorderExample {
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 …… } } }

因为操作1和操作2之间没有数据依赖关系,所以处理器可以对其重排序,同样,操作3和操作4之间没有数据依赖关系,也可以进行指令重排序。

如果操作1和操作2进行了重排序,可能出现下面的情况:

 

如上图所示,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量 flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了。

当操作3和操作4重排序后,可能出现下面的情况:在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。

从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义。

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

上面提到的缓存一致性问题,指令重排序问题,都是为了提高CPU的利用率,在多线程的情况下才出现的,这些问题总结下来就是原子性、可见性及有序性的问题。

原子性:一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性:程序执行的顺序按照代码的先后顺序执行。

为了解决并发编程中的原子性,可见性,有序性问题,人们提出来一种规范,这个规范就是内存模型。

什么是内存模型

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范,这些规范保证了指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、指令重排等导致的内存访问问题,保证了并发场景下的可见性、原子性和有序性。

上面我们已经知道了什么是内存模型,即一种保证多线程对共享内存操作时正确性的规范。由于java具有平台无关性的特点,java程序是在虚拟机上运行的,而内存模型与底层硬件有关,所以java虚拟机提供了一种满足java语言的内存模型,即java内存模型。

java内存模型(JMM)

java内存模型:Java虚拟机规范中定义的,来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果的一种规范。

那么java内存模型作了哪些规范呢?

Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存,线程之间的通信递需要经过主内存。

和内存模型一样,在多线程的环境下,java内存模型中同样存在原子性,可见性,有序性等问题,当然java内存模型也规范了解决的办法。

原子性:在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

x = 10; //语句1 
y = x; //语句2 
x++; //语句3 
x = x + 1; //语句4

上面代码中有哪些语句的操作具有原子性?语句1的操作是赋值操作,具有原子性。其实也只有语句1的操作具有原子性,语句2共有两步,第一步取出x的值,第二步将x的值赋给y。因此语句2不具有原子性。语句3共有三步,第一步取出x的值,第二步将x的值加1,第三部将修改后的值赋给x,所以语句3也不具有原子性。同理,语句4也不具有原子性。

那么在java中,除了基本数据类型的读取和赋值操作外,其它的操作如何保证原子性呢?

其实在上一篇文章中我们已经接触过了。如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

可见性:Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值,并且会让其它线程对于该变量的缓存失效。

在对volatile修饰的变量做写操作时,jvm会给CPU发送lock前缀指令,强制要求在写操作结束后将修改的值写回主存,然后根据缓存一致性协议,其他线程的工作内存中该变量的缓存失效,从而保证了可见性。

对于普通的共享变量,如果一个线程已经读取了这个变量的值,但是此时有其它线程对该值做修改,那么已经读取的线程并不知道,还是会使用之前已读取的值,从而无法保证可见性。

除了volatile可以保证可见性外,synchronized和Lock同样可以保证可见性。因为synchronized和Lock能保证同一时刻只有一个线程执行该代码块,并且释放锁之前会把修改的值刷新回主存,这样其他的线程读取的一直都是最新的值。

volatile底层机制——java内存屏障

内存屏障:内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。内存屏障包括两种:

  • 读屏障(Load Barrier):在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据。
  • 写屏障(Store Barrier):在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

内存屏障的作用有两个:

1、阻止屏障两侧的指令重排序。

2、强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

同样,Java虚拟机为java程序运行时提供了java内存屏障。java内存屏障有四种类型,其实就上面内存屏障两种类型的组合。

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad;Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1;StoreStore;Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1;LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

volatile语义中的内存屏障:

1、在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;

2、在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。

有序性:Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before(先行发生) 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。注意:A happens-before B并不要求A操作一定要在B操作之前,而是说A操作后的结果对B操作是可见的,即如果A操作将a的值修改为1,那么B操作在用到a的值的时候已经知道了a的值为1。

happens-before 原则共有8条,用来规定操作执行的顺序,它保证了单线程执行程序时的有序性,但是在多线程中会出现问题。在多线程中,我们可以用synchronized和Lock来保证有序性,因为synchronized和Lock能保证同一时刻只有一个线程执行同步代码,就相当于单线程。

除此之外,volatile在底层通过内存屏障来禁止指令重排序,因此在一定程度上也可以实现有序性。

volatile关键字

一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

1、保证了不同线程对该变量操作的内存可见性,即一个线程修改了某个变量的值,这个新值对其他线程来说是立即可见的。

2、禁止进行指令重排序。

volatile保证可见性

线程1
boolean stop = false;
while(!stop){
    doSomething();
}
 
//线程2
stop = true;

这是一段经典的代码,我们经常会通过一个标记来中断一个线程。但是线程1真的会被中断吗?如果线程2将stop的值修改为true后,还没有将值写回主存而去做其他事情了,那么线程1将不知道stop的值为true并且继续循环。

如果使用volatile,那么在线程2改变stop的值后,会强制立刻写回内存,并使其它线程中的缓存失效,使得其它线程想要读取stop时,必须从内存中读取,这就解决了内存可见性的问题。

volatile保证有序性

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
do(context);

当context初始化成功后,线程2会进行do(),但如果发生了指令重排,语句2先于语句1执行,这时候,context并没有被初始化,这样执行do()时必然会出现错误。

如果使用volatile修饰inited,那么由于内存屏障,并不会对语句1和语句2进行重排序,这样就能保证程序的有序性。

注意,volatile只能保证volatile前面的操作在volatile后面的操作之前,但是,前面和后面的操作可以重排序。

//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

语句3不会在语句1和语句2之前执行,同样不能在语句3和语句4之后执行。但是语句1和语句2以及语句3和语句4之间是可以重排序的。所以说volatile在一定程度上保证程序的有序性。

volatile不能保证原子性

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

可能我们觉得上面的程序运行结果应该是1000*10=10000,但事实上,每次运行结果都不同而且都会比10000小。

我们再来好好看看代码,共享变量 inc 被 volatile 修饰,保证了inc别修改时其它线程可以立刻知道,并且线程的工作内存中的inc值会失效,其它线程想要读取inc的值必须从主存中获取,这样每次线程获取的inc的值都是最新的,每次执行加1操作,最后的结果不是10000吗,怎么会出错呢!

之所以会出错,是因为inc++本身不是jvm定义的原子操作,我们上面说过,在java中,只有基本数据类型的赋值和读取操作才是原子操作,而inc++共有三步,取值,加1,赋值,那么这三步就有可能被其它线程打断。我们考虑下面的情况:

1、线程1从将主存中的 inc 拷贝到自己的工作内存,然后对其进行加1操作,此时temp=inc+1,即加1后的值存储在临时变量temp中,由于线程1的时间片到了,所以线程1还没有把temp的值赋值给inc;

2,、这时线程2抢夺到时间片,开始将主存中的 inc 拷贝到自己的工作内存,执行加1操作,将temp=inc+1的值赋值给inc;

3、因为inc是由volatile修饰的,所以线程2会立刻把inc的值写回主存,并且让线程1工作内存中 inc 的值失效,线程1下次就会从主存中读取inc的值;

上面的操作看起来一切正常,那么问题在哪呢?

线程2在把inc的值写回到主内存时,线程1中的inc确实失效了,但是线程1的下一步是执行赋值操作,即把temp的值赋值给inc,线程1并不需要从主存中读取inc的值,这样,线程1在赋值完后,由于inc是volatile修饰的,所以会立刻写回主存中,这样就覆盖了线程2的操作结果。这就解释了为什么上面程序运行结果每次都比10000小。

使用volatile的场景

在某些情况下,使用volatile的性能要优于synchronized,但volatile是无法代替synchronized的,因为volatile不能保证原子性。使用volatile需要具备两个条件:

1、对变量的写操作不依赖该变量的当前值(避免上面 inc++ 的问题)

2、该变量没有包含在具有其他变量的不变式中。

使用volatile最多的地方是用于修饰标志变量,从而中断某个线程

volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}

volatile和synchronized的区别

volatile被称作轻量级的synchronized,但是很多volatile和synchronized之间有很多的不同:

1、volatile本质是告诉虚拟机,某个变量是易变的,每次需要从主存中读取;而synchronized则是给一个变量加锁,始终保证只有一个线程操作这个变量。

2、volatile只能修饰变量,但是synchronized块中既可以包括变量,也可以包括方法。

3、volatile不会造成线程阻塞,而synchronized会造成线程阻塞。

4、volatile禁止指令重排,编译器和处理器不会对其优化,而synchronized保证了同一时刻只有一个线程执行同步代码,编译器和处理器仍然可以对其优化。

参考资料

再有人问你Java内存模型是什么,就把这篇文章发给他

Java并发编程:volatile关键字解析

指令重排序

java内存屏障

posted @ 2020-06-27 16:42  路半_知风  阅读(208)  评论(0编辑  收藏  举报