之前提到的原子性、可见性、有序性都与Java内存模型(JMM)密不可分。在Java内存模型中定义了主内存和线程的工作内存的概念,还有8个原子性操作。这些概念稍后会介绍,我现在想说的是,为什么会出现JMM
从硬件的角度看并发问题
当程序在运行的时候,从硬件的执行上来看,就是CPU从内存中取数据 => 计算 => 把数据写回内存
CPU的执行速度是很快的,可是内存比CPU慢了好几个数量级,为了提升效率,就在CPU和内存之间,加了缓存。(实际的结构比我描述的要复杂的多,CPU访问寄存器的速度是最快的,其次是缓存,缓存又分为多级缓存,最慢的是内存。那有人可能会问,把缓存做的跟内存一样大,替换掉内存不就好了?为什么不这么做呢,因为缓存太贵!)
硬件结构.png
从图中我们能看到,每个CPU都有自己的一块缓存。比如对一个变量做操作,首先会查看缓存中是不是存在这个变量,如果存在,就直接从缓存中取;如果不存在,去内存中取。当CPU修改了某个变量之后,不会把这个变量的值立刻写会内存,会在一个合适的时候写回到内存(这个合适的时候是不可控的,下面要讲的缓存一致性协议可以强制要求写回到内存)。这其中的并发问题应该就很明显了。两个CPU的缓存中同时缓存了同一个变量 i ,其中一个把 i 的值改为5,但这时另一个CPU不知道有人已经修改了 i 的值,依旧用的缓存中变量 i 的值来做计算。
缓存一致性协议
如何解决这种问题呢?主要有两种方案来解决缓存一致性问题
总线加锁
缓存一致性协议(MESI)
首先来看第一种加锁的方案就比较简单粗暴了,简单来说就是CPU(1) 在对内存中的一个变量进行操作的过程中,其他的CPU如果也想对这个变量进行某些操作,只能等到CPU(1) 操作完成之后才有机会操作这个变量(期间可能会有多个CPU在等待,这些等待的CPU只会有一个能得到操作这个变量的机会,其他的CPU则继续等待)。这样看来,效率会非常的低下。
第二种方案是通过缓存一致性协议来解决。缓存一致性协议有很多种,MESI是比较常用的一种,其中定义了缓存行的四种状态(M:修改,E:独占,S:共享,I:失效;其底层原理可以查阅相关文章)。作用就是,多个CPU的缓存中都存有变量 x ,当其中一个CPU修改了变量 x 的值之后,其他CPU缓存中的值就失效了,再次使用变量 x 就去内存中取值。
缓存一致性.png
Java内存模型
看过了硬件的模型之后,与下图中的Java内存模型相比较,几乎差不多。
Java内存模型是Java虚拟机规范中定义的,用来屏蔽掉各种硬件和操作系统的内存访问差异。
我来解释一下这句话。首先,Java虚拟机规范,就是规定Java虚拟机能够做哪些事情,至于通过什么方式来做这些事情,要交给具体的虚拟机厂商来实现了(虚拟机的是实现由很多,我们常用的是HotSpot)。后半句话的意思是,我们目前使用最多的有三种操作系统,Windows、Linux、Unix,CPU也分为很多种,它们之间交互操作的命令和访问内存的方式也有区别,Java作为一种跨平台的编程语言,自然要屏蔽掉这些差异(可以类比一下接口和实现类。Java内存模型就相当于接口,底层的各种硬件和操作系统相当于实现类。不管有多少个实现类,总归会提供接口中定义的方法。作为用户,只需要了解接口中的方法如何使用即可,不需要关心底层的实现)。
所以说,Java只管定义出自己的一套内存交互的方式,适用于各种的硬件和操作系统,具体怎么样屏蔽掉这些差异就交给虚拟机厂商来实现。
Java内存模型.png
从图中我们可以看出,Java内存模型的定义与真实的物理模型类似。
在Java内存模型中,定义了8种原子操作,规定了这8种操作一定是原子性操作。为什么要定义这8种操作呢?我们知道,Java内存模型是对于物理机底层的抽象,对于同一个操作来说,不同的物理机的实现可能会有差异。所以,Java内存模型才会定义这8种操作来达到统一性。
再谈原子性
Java内存模型中定义了8种原子操作,跟我们在代码中强调的原子性有什么区别呢?
从图中可以看出,从内存中取值,赋值,再写回内存被拆分成了6个操作,所以,我们想要保证这6个操作具有原子性,就需要用lock,unlock来保证。不过Java没有直接提供给我们这种使用方式,而是提供了synchronized关键字,接下来的文章我会再来分析synchronized的用法。
可见性
现在回过头来看可见性的问题,是不是跟硬件中的缓存一致性问题很像啊。Java提供了volatile关键字来保证被修饰变量的可见性。那么现在就有个问题了,既然在硬件上都已经保证了可见性了,那为什么还要在代码中提供一种保证可见性的方式呢?
我们来分析一下,是不是每个变量都需要保证可见性呢?还有就是,从硬件角度上来看,为了保证缓存一致性会损失掉多少性能呢?
第一个问题,显然是不需要保证每个变量的可见性的,比如非并发的场景下,或者局部变量。
第二个问题,缓存的出现,就是为了避免CPU频繁的和内存直接交互,因为CPU的执行速度比内存的读写速度要快上百倍。现象一下,如果每个变量都要保证可见性的话,那么缓存的作用就很小了,CPU与内存频繁交互,会损失掉大量的计算资源。
所以,硬件的缓存一致性协议是通过某些条件触发或者会对某个具有特殊标记的符号修饰的变量进行缓存一致性的处理。那么,Java提供的volatile关键字就会给被修饰的变量加上特殊的标记,在CPU执行的过程中,就会对这个变量做特殊处理。