?? 面试官:
前两天看到同学和我显摆他们公司配的电脑多好多好,我默默打开了自己的电脑,酷睿 i7-4770
,也不是不够用嘛,4 核 8 线程的 CPU,也是杠杠的。
扯这玩意干啥,Em~~~~
介绍 Java 内存模型之前,先温习下计算机硬件内存模型
计算机在执行程序的时候,每条指令都是在 CPU 中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存。
计算机硬件架构简易图:
我们以多核 CPU 为例,每个CPU 核都包含一组 「CPU 寄存器」,这些寄存器本质上是在 CPU 内存中。CPU 在这些寄存器上执行操作的速度要比在主内存(RAM)中执行的速度快得多。
因为CPU速率高, 内存速率慢,为了让存储体系可以跟上CPU的速度,所以中间又加上 Cache 层,就是我们说的 「CPU 高速缓存」。
由于CPU的运算速度远远超越了1级缓存的数据I\O能力,CPU厂商又引入了多级的缓存结构。通常L1、L2 是每个CPU 核有一个,L3 是多个核共用一个。
Cache又是由很多个「缓存行」(Cache line) 组成的。Cache line 是 Cache 和 RAM 交换数据的最小单位。
Cache 存储数据是固定大小为单位的,称为一个Cache entry,这个单位称为Cache line或Cache block。给定Cache 容量大小和 Cache line size 的情况下,它能存储的条目个数(number of cache entries)就是固定的。因为Cache 是固定大小的,所以它从主内存获取数据也是固定大小。对于X86来讲,是 64Bytes。对于ARM来讲,较旧的架构的Cache line是32Bytes,但一次内存访存只访问一半的数据也不太合适,所以它经常是一次填两个 Cache line,叫做 double fill。
这里的缓存的工作原理和我们项目中用 memcached、redis 做常用数据的缓存层是一个道理。
当 CPU 要读取一个数据时,首先从缓存中查找,如果找到就立即读取并送给CPU处理;如果没有找到,就去内存中读取并送给 CPU 处理,同时把这个数据所在的数据块(就是我们上边说的 Cache block)调入缓存中,即把临近的共 64 Byte 的数据也一同载入,因为临近的数据在将来被访问的可能性更大,可以使得以后对整块数据的读取都从缓存中进行,不必再调用内存。
这就增加了CPU读取缓存的命中率(Cache hit)了。
计算机存储系统是有层次结构的,类似一个金字塔,顶层的寄存器读写速度较高,但是空间较小。底层的读写速度较低,但是空间较大
既然每个核中都有单独的缓存,那我的 4 核 8 线程 CPU 处理主内存数据的时候,不就会出现数据不一致问题了吗?
为了解决这个问题,先后有过两种方法:总线锁机制和缓存锁机制。
总线锁就是使用 CPU 提供的一个LOCK#
信号,当一个处理器在总线上输出此信号,其他处理器的请求将被阻塞,那么该处理器就可以独占共享锁。这样就保证了数据一致性。
但是总线锁开销太大,我们需要控制锁的粒度,所以又有了缓存锁,核心就是“缓存一致性协议”,不同的 CPU 硬件厂商实现方式稍有不同,有MSI、MESI、MOSI等。
为了使得处理器内部的运算单元尽量被充分利用,提高运算效率,处理器可能会对输入的代码进行「乱序执行」(Out-Of-Order Execution),处理器会在计算之后将乱序执行的结果重组,乱序优化可以保证在单线程下该执行结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。
乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。在单核时代,处理器保证做出的优化不会导致执行结果远离预期目标,但在多核环境下却并非如此。
多核环境下, 如果存在一个核的计算任务依赖另一个核的计算任务的中间结果,而且对相关数据读写没做任何防护措施,那么其顺序性并不能靠代码的先后顺序来保证,处理器最终得出的结果和我们逻辑得到的结果可能会大不相同。
除了上述由处理器和缓存引起的乱序之外,现代编译器同样提供了乱序优化。之所以出现编译器乱序优化其根本原因在于处理器每次只能分析一小块指令,但编译器却能在很大范围内进行代码分析,从而做出更优的策略,充分利用处理器的乱序执行功能。
尽管我们看到乱序执行初始目的是为了提高效率,但是它看来其好像在这多核时代不尽人意,其中的某些”自作聪明”的优化导致多线程程序产生各种各样的意外。因此有必要存在一种机制来消除乱序执行带来的坏影响,也就是说应该允许程序员显式的告诉处理器对某些地方禁止乱序执行。这种机制就是所谓内存屏障。不同架构的处理器在其指令集中提供了不同的指令来发起内存屏障,对应在编程语言当中就是提供特殊的关键字来调用处理器相关的指令,JMM里我们再探讨。
Java 内存模型即 Java Memory Model
,简称 JMM。
这里的内存模型可不是 JVM 里的运行时数据区。
「内存模型」可以理解为在特定操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
不同架构的物理计算机可以有不一样的内存模型,Java虚拟机也有自己的内存模型。
Java虚拟机规范中试图定义一种「 Java 内存模型」来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量与我们写 Java 代码中的变量不同,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为他们是线程私有的,不会被共享。
JMM 与 Java 内存区域中的堆、栈、方法区等并不是同一个层次的内存划分,两者基本没有关系。如果一定要勉强对应,那从变量、主内存、工作内存的定义看,主内存主要对应 Java 堆中的对象实例数据部分,工作内存则对应虚拟机栈的部分区域(与上图对应着看哈)。
Java 内存模型和硬件内存体系结构也没有什么关系。硬件内存体系结构不区分栈和堆。在硬件上,线程栈和堆都位于主内存中。线程栈和堆的一部分有时可能出现在高速缓存和CPU寄存器中。如下图所示:
当对象和变量可以存储在计算机中不同的内存区域时,这就可能会出现某些问题。两个主要问题是:
如果两个或多个线程共享一个对象,则一个线程对共享对象的更新可能对其他线程不可见(当然可以用 Java 提供的关键字 volatile)。假设共享对象最初存储在主内存中。在 CPU 1上运行的线程将共享对象读入它的CPU缓存后修改,但是还没来得及即刷新回主内存,这时其他 CPU 上运行的线程就不会看到共享对象的更改。这样,每个线程都可能以自己的线程结束,就出现了可见性问题,如下
这个其实就是我们常说的原子问题。
如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量,则可能出现竞争条件。
想象一下,如果线程A将一个共享对象的变量读入到它的CPU缓存中。此时,线程B执行相同的操作,但是进入不同的CPU缓存。现在线程A执行 +1 操作,线程B也这样做。现在该变量增加了两次,在每个CPU缓存中一次。
如果这些增量是按顺序执行的,则变量结果会是3,并将原始值+ 2写回主内存。但是,这两个增量是同时执行的,没有适当的同步。不管将哪个线程的结构写回主内存,更新后的值只比原始值高1,显然是有问题的。如下(当然可以用 Java 提供的关键字 Synchronized)
JMM 就是用来解决如上问题的。JMM是围绕着并发过程中如何处理可见性、原子性和有序性这 3 个 特征建立起来的
单线程环境里确保程序最终执行结果和代码顺序执行的结果一致; 处理器在进行重排序时必须要考虑指令之间的数据依赖性; 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
关于主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了 8 种 操作来完成,虚拟机实现必须保证每一种操作都是原子的、不可再拆分的(double和long类型例外)
如果需要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。注意,Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说 read 与 load 之间、store 与write 之间是可插入其他指令的,如对主内存中的变量 a、b 进行访问时,一种可能出现顺序是 read a、read b、load b、load a。
除此之外,Java 内存模型还规定了在执行上述 8 种基本操作时必须满足如下规则
Java 内存模型要求 lock,unlock,read,load,assign,use,store,write 这 8 个操作都具有原子性,但对于64 位的数据类型( long 或 double),在模型中定义了一条相对宽松的规定,允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的load,store,read,write 这 4 个操作的原子性,即 long 和 double 的非原子性协定。
如果多线程的情况下double 或 long 类型并未声明为 volatile,可能会出现“半个变量”的数值,也就是既非原值,也非修改后的值。
虽然 Java 规范允许上面的实现,但商用虚拟机中基本都采用了原子性的操作,因此在日常使用中几乎不会出现读取到“半个变量”的情况,so,这个了解下就行。
先行发生(happens-before)是 Java 内存模型中定义的两项操作之间的偏序关系,如果操作A 先行发生于操作B,那么A的结果对B可见。happens-before关系的分析需要分为单线程和多线程的情况:
为了方便程序开发,Java 内存模型实现了下述的先行发生关系:
Thread.join()
方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。finalize()
方法的开始上边的一系列操作保证了数据一致性,Java中如何保证底层操作的有序性和可见性?可以通过内存屏障。
内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性。
eg:
Store1;
Store2;
Load1;
StoreLoad; //内存屏障
Store3;
Load2;
Load3;
对于上面的一组 CPU 指令(Store表示写入指令,Load表示读取指令),StoreLoad 屏障之前的 Store 指令无法与StoreLoad 屏障之后的 Load 指令进行交换位置,即重排序。但是 StoreLoad 屏障之前和之后的指令是可以互换位置的,即 Store1 可以和 Store2 互换,Load2 可以和 Load3 互换。
常见的 4 种屏障
Java 中对内存屏障的使用在一般的代码中不太容易见到,常见的有 volatile 和 synchronized 关键字修饰的代码块,还可以通过 Unsafe 这个类来使用内存屏障。(下一章扯扯这些)
Java 内存模型就是通过定义的这些来解决可见性、原子性和有序性的。