Java并发编程之Java内存模型(一)

一、并发的起源

为了提高计算机处理数据的速度。现代的计算机都支持多任务处理。在32位windows操作系统中 ,多任务处理是指系统可同时运行多个进程,而每个进程也可同时执行多个线程。一个线程是指程序的一条执行路径,它在系统指定的时间片中完成特定的功能。系统不停地在多个线程之间切换,由于时间很短,看上去多个线程在同时运行。或者对于在线程序可并行执行同时服务于多个用户称为多任务处理。

二、物理计算机的内存模型

在理解java内存模型之前,我们先来了解一下,物理计算机的内存模型,其对Java内存模型有着很大的参考意义。
在物理计算机中,我们需要处理的数据都在内存中,处理器处理数据,需要从内存中获取相应的数据,然后存入内存中,为了提高计算机的处理速度(读取数据,存储数据有IO消耗),我们常常会在CPU(处理器)中加入高速缓存(Cache Memory),也就是将数据缓存到处理器中,当处理器处理完数据后,再将处理的数据结果存储在内存中。具体如下图所示:

当CPU(处理器)要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。一般来说,每级缓存的命中率大概都在80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。

高速缓存(Cache Memory)是位于CPU与内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。高速缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快很多,这样会使CPU花费很长时间等待数据到来或把数据写入内存。在缓存中的数据是内存中的一小部分,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先缓存中调用,从而加快读取速度。

2.1 物理计算机的数据缓存不一致的问题

虽然高速缓缓冲提高了CPU(处理器)处理数据的速度问题。在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。这时CPU缓存中的值可能和缓存中的值不一样,这就会出现缓存不一致的问题。为了解决该问题。物理机算计提供了两种方案来解决该问题。具体如下图所示:

2.1.1 通过总线加LOCK#锁的方式

总线(Bus)是计算机各种功能部件之间传送信息的公共通信干线,它是由导线组成的传输线束,在计算机中数据是通过总线,在处理器和内存之间传递。

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

2.1.2 通过缓存一致性协议

但是由于在锁住总线期间,其他CPU无法访问内存,会导致效率低下。因此出现了第二种解决方案,通过缓存一致性协议来解决缓存一致性问题。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

2.2 CPU(处理器)的乱序执行(out-of-orderexecution)

除了使用高速缓存来提高CPU(处理器)的数据处理速度,CPU(处理器)还采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理的技术。在这期间不按规定顺序执行指令,然后由重新排列单元将各执行单元结果按指令顺序重新排列。采用乱序执行技术的目的是为了使CPU内部电路满负荷运转并相应提高了CPU的运行程序的速度。有可能大家不好理解。下面这个例子帮助大家理解。

假如请A、B、C三个名人为晚会题写横幅“春节联欢晚会”六个大字,每人各写两个字。如果这时在一张大纸上按顺序由A写好”春节”后再交给B写”联欢”,然后再由C写”晚会”,那么这样在A写的时候,B和C必须等待,而在B写的时候C仍然要等待而A已经没事了。

但如果采用三个人分别用三张纸同时写的做法, 那么B和C都不必须等待就可以同时各写各的了,甚至C和B还可以比A先写好也没关系(就象乱序执行),但当他们都写完后就必须重新在横幅上(自然可以由别人做,就象CPU中乱序执行后的重新排列单元)按”春节联欢晚会”的顺序排好才能挂出去。

三、Java的内存模型

看到这里大家一定会发现,我们所讨论的CPU高速缓存、指令重排序等内容都是计算机体系结构方面的东西,并不是Java语言所特有的。事实上,很多主流程序语言(如C/C++)都存在缓存不一致的问题,这些语言是借助物理硬件和操作系统的内存模型来处理缓存不一致问题的,因此不同平台上内存模型的差异,会影响到程序的执行结果。Java虚拟机规范定义了自己的内存模型JMM(Java Memory Model)来屏蔽掉不同硬件和操作系统的内存模型差异,以实现让Java程序在各种平台下都能达到一致的内存访问结果。所以对于Java程序员,无需了解底层硬件和操作系统内存模型的知识,只要关注Java自己的内存模型,就能够解决这些问题啦。

Java内存模型如下图所示:

  • 主内存:主要存储变量(包括。实例字段,静态字段和构成对象的元素)
  • 工作内存:每个线程都有自己的工作内存,存储了对应的引用,方法参数。

如果对应与Java内存中堆与栈的概念的话,主内存对应Java内存中的堆,工作内存对应Java虚拟机的栈。

3.1 内存之间交互

主内存与工作内存之间的内存交互,也就是从线程的私有内存数据同步到主内存中,从主内存的读取数据到线程的私有内存中。Java内存模型定义了8种操作来完成。虚拟机在实现时保证下面提到的每一种操作都是原子的,不可再分的

  • lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程访问。
  • read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,一遍随后的load动作使用。
  • load:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入到工作内存变量副本中。
  • use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时会执行这个操作。
  • assign:作用于工作内存的变量,它把一个从执行引擎收到的值赋给工作内存的变量。每当虚拟机遇到给变量赋值的字节码指令时会执行这个操作。
  • store:作用于工作内存的变量,它把工作内存中一个变量值传送到主内存中。以便随后的write操作。
  • write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值,放入主内存的变量中。

3.2 八种原子操作规则

既然Java内存模型规定了内存之间交互的一些操作。那么我们来看看,它到底拥有哪些规则呢。

  • 不允许read和load、store和write操作之一单独出现。即不允许一个变量从主内存读取了但工作内存不接受。或者从工作内存发起回写了但主内存不接受的情况
  • 不允许一个线程丢弃它的最近的assign操作。即变量在工作内存改变了后必须把该变化同步到主内存中。
  • 不允许没有发生任何的assign操作就把数据同步到主内存中。
  • 一个新的变量只能在主内存中诞生,工作内存要使用或者赋值。必须要经过load或assign操作。
  • 一个变量在同一时刻只允许一条线程进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量进行lock操作后,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它进行unlock操作。也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unLock操作之前,必须要把次变量同步到主内存中(执行store,write操作)。

上述规则规定了Java内存之间交互的流程。保证了数据在单线程情形下传输过程中的准确性与数据一致性。

四、重排序

前面提到过,CPU(处理器)为了提高处理数据的速度,会进行乱序执行(out-of-orderexecution)。也就是重排序。但是CPU不会对任务操作进行重排序,编译器与处理器只会对没有数据依赖性的指令进行重排序。这里提到了一个关键词数据依赖性。什么是数据依赖呢?

4.1 数据依赖

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

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

上述三种情况,a与b存在着“数据依赖性”,同时大家也要注意。这里所说的数据依赖性是指单个处理器执行的指令序列和单个线程中执行的操作。多处理器和不同线程之间是没有数据依赖性这种关系的。

4.2 重排序规则(as-if-serial)

既然我们已经知道了CPU在处理数据时候会出现重排序。那重排序的规则是什么呢?重排序规则:不管怎么重排序(编译器和处理器为了提高并行度),单线程(程序)执行结果不能被改变。编译器、runtime和处理器都必须遵守。那么我们三角形面积示例代码说明:

1
2
3
double a = 3;//底
double h = 10;//高
double s = a*h/2//面积

其中上述代码的依赖关系如下图所示:

如上图所示:a与s存在数据依赖关系,同时h与s也存在依赖关系。因此在程序的最终指令执行时。s是不能排在a与h之前。因为a与h不存在着数据依赖关系。所以处理器可以对a与h之前的执行顺序重排序。

经过处理器的重排序后,执行的结果并没有发生改变。

五、Java内存模型的需要解决的问题

前面我们已经了解了Java内存模型的大致结构与操作方式,那么我们来看看Java内存模型需要解决的问题。

5.1 工作内存的可见性问题

工作内存的可见性问题(这里和计算机硬件的缓存不一致是一样的道理)。从上文的Java内存模型分析。我们已经知道了当多个线程操作同一个共享变量时,如果一个线程修改了其中的变量的值(如果通过Java内存模型的原子操作来表达,一个线程多次use与assign 操作,而另一个线程经过read、load之后,另一线程任然保持着之前从主内存中获取的值),另一个线程怎么感知呢?

5.2 重排序带来的问题

CPU(处理器)的重排序会对多线程带来问题。具体问题我们用下列伪代码来阐述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo {
private int a = 0;
private boolean isInit = false;
private Config config;

public void init() {
config = readConfig();//1
isInit = true;//2
}
public void doSomething() {
if (isInit) {//3
doSomethingWithconfig();//4
}
}
}

isInit用来标志是否已经初始化配置。其中1,2操作是没有数据依赖性,同理3、4操作也是没有数据依赖性的。那么CPU(处理器)可能对1、2操作进行重排序。对3、4操作进行重排序。现在我们加入线程A操作Init()方法,线程B操作doSomething()方法,那么我们看看重排序对多线程情况下的影响。

上图中2操作排在了1操作前面。当CPU时间片转到线程B。线程B判断 if (isInit)为true,接下来接着执行 doSomethingWithconfig(),但是我们Config还没有初始化。所以在多线程的情况下。重排序会影响程序的执行结果。

六、Happens-Before 原则

上面我们讨论了Java内存模型需要解决的问题,那Java有不有一个良好的解决办法来处理以上出现的情况呢?答案是当然的。为了方便程序员开发,将底层的烦琐细节屏蔽掉,JMM定义了Happens-Before原则。只要我们理解了Happens-Before原则,无需了解Java内存模型的内存操作,就可以解决这些问题(避免工作内存的不可见与重排序带来的问题)。

Happens-Before原则是一组偏序关系:对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的。那么有哪些满足Happens-Before原则的呢?下面是Java内存模型规定的一些规则。

6.1 程序次序规则

在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。这是因为Java语言规范要求Java内存模型在单个线程内部要维护类似严格串行的语义,如果多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。

6.2 锁定规则

对一个unlock操作先行发生于后面对同一个锁的lock操作。

1
2
3
4
5
6
7
8
9
public class Demo {
private int value;
public synchronized void setValue(int value) {
this.value = value;
}
public synchronized int getValue() {
return value;
}
}

上面这段代码,setValue与getValue拥有同一个锁(也就是当前实例对象),假设setValue方法在线程A中执行,getValue方法在线程B中执行。线程A调用setValue方法会先对value变量赋值,然后释放锁。线程B调用getValue方法会先获取到同一个锁后,再读取value的值。那么B线程获取的value的值一定是正确的。

6.3 volatlie变量规则

对一个volatile变量的写操作先行发生于后面这个变量的读操作。

1
2
3
4
5
6
7
8
9
public class Demo {
private volatile boolean flag;
public void setFlag(boolean flag) {
this.flag = flag;
}
public boolean isFlag() {
return flag;
}
}

上面这段代码,假设setFlag方法在线程A中执行,isFlag方法在线程B中执行。线程A调用setFlag方法会先对value变量赋值,然后释放锁。线程B调用isFlag方法再读取value的值。那么B线程获取的flag的值一定是正确的。这里我们先不对volatlie进行讲解,后面系列文章会描述。

6.4 线程启动规则

Thread对象的start()方法先行发生于此线程的每个动作。

start方法和新线程中的动作一定是在两个不同的线程中执行。线程启动规则可以这样去理解:调用start方法时,会将start方法之前所有操作的结果同步到主内存中,新线程创建好后,需要从主内存获取数据。这样在start方法调用之前的所有操作结果对于新创建的线程都是可见的。

6.5 线程终止规则

线程中的所有操作都先行发生于对此线程的终止检测。

这里理解比较抽象。举个例子,假设两个线程s、t。在线程s中调用t.join()方法。则线程s会被挂起,等待t线程运行结束才能恢复执行。当t.join()成功返回时,s线程就知道t线程已经结束了。在t线程中对共享变量的修改,对s线程都是可见的。类似的还有Thread.isAlive方法也可以检测到一个线程是否结束。也就是说当一个线程结束时,会把自己所有操作的结果都同步到主内存。而任何其它线程当发现这个线程已经执行结束了,就会从主内存中重新刷新最新的变量值。所以结束的线程A对共享变量的修改,对于其它检测了A线程是否结束的线程是可见的。

6.6 线程中断规则

对线程interrupt()方法的调用先与被中断线程的代码检查到中断事件的发生。

假设两个线程A和B,A先做了一些操作operationA,然后调用B线程的interrupt方法。当B线程感知到自己的中断标识被设置时(通过抛出InterruptedException,或调用interrupted和isInterrupted),operationA中的操作结果对B都是可见的。

6.7 对象终结规则

一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

6.8 传递性规则

如果操作A先行与发生于操作B,操作B先行发生于操作C,那么就可以得出A先行发生于操作C的结论。

总结

  • 在物理计算机中CPU为了提高处理速度,添加了高速缓存与CPU乱序执行
  • Java定义了自身的内存模型是为了屏蔽掉不同硬件和操作系统的内存模型差异
  • Java为了处理内存的不可见性与重排序的问题,定义了Happens-Before 原则
  • Happens-Before 原则的理解:对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的。