1. 缓存一致性问题
在我们学习java内存模型之前,先来了解一下多核硬件架构。 我们都知道计算执行程序实际上是cpu在执行一条条指令,在执行指令时常常需要和内存打交道,比如去读或者存储数据。在初期,cpu和内存直接打交道的方式还没什么问题,但是随着cpu技术的发展,cpu执行速度越来越快,而内存技术并没有太大的变化,这导致从内存中读取和写入数据的速度和cpu的执行速度出现了巨大的差距,这样cpu每次的内存操作都要耗费很多等待时间。可是不能因为内存的读写速度慢,就不发展cpu技术了吧。
cpu的时间是非常宝贵的,怎么可以如此奢侈地浪费在等待内存的操作上呢? 怎么办? 解决方法是在cpu和内存之间增加高速缓存,高速缓存速度快,内存小并且昂贵。这样程序的执行过程变为:
在程序运行时会将运算需要的数据会先从高速缓存中寻找,找不到再去内存中找并把内存的数据复制到cpu的高速缓存,后面使用时就可以直接从高速缓存中读取,当运算结束后,再将高速缓存中的数据刷新到主存中去。随着cpu能力的不断提升,一层缓存慢慢的无法满足要求,就衍生出了多级缓存。按照cpu读取顺序,分为一级、二级、三级缓存,每一级缓存中存储的全部数据都是下一级缓存的一部分,这三级缓存技术难度和制造成本递减,所以容量也是相对递增的。这样程序的执行过程就变成了:当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。再随着市场对cpu计算能力的需要,慢慢出现了多核cpu,每个核都有各自的缓存:
上图是常用的三层缓存架构:
- L1 cache 最接近cpu,容量小(64k, 256k),速度最高,每个核上都有一个L1 Cache。
- L2 cache容量更大,速度比L1低,也是每个核独有。
- L3 cache容量最大(比如24MB)速度最低,在同一个cpu插槽之间的核共享一个L3 Cache。
缓存大大缩小了高速cpu和低速内存的差距,但是也带来了问题, 在多线程多核cpu环境中,当一个核修改主存后,其他核心并不知道缓存的数据已经失效,继续傻傻的使用,导致计算错误、数据不一致等问题。
2. 缓存一致性协议
多核cpu硬件架构厂商,设计之初就想到了多线程操作带来数据不一致问题,因此,他们定了一个协议来解决这个问题,这个协议就是缓存一致性协议。
不同的cpu硬件厂商,具体的实现不一样,Intel的MESI协议最出名,在MESI协议中,每个Cache line有四个状态
状态 | 描述 |
---|---|
M(modified) | 这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本cache中 |
E(Exclusive) | 这行数据有效,数据和内存一致,数据只存在本Cache中 |
S(Shared) | 这行数据有效,数据和内存中的数据一致,数据存在于很多cache种 |
I(Invalid) | 这行数据无效 |
在MESI协议中,每个Cache的Cache控制器不仅知道自己的读写操作,还会监听其它Cache的读写操作,每个cache line所处的状态根据本核和其它核的读写操作在这四个状态间切换。
下面通过上图来简单说下MESI是如何在多核环境下确保缓存一致性的:
两个核执行两个线程,都执行count++,它们先从主内存获取count=0,都将count=0从主存拷贝到各自的缓存区,在缓存区该cache line状态设置为Shared。一个线程先执行了count+=1,按照MESI缓存协议规范,在该线程中count变量对应的cache line为M(已修改)状态,而其他拥有count变量的线程,count对应cache line比较为I(失效状态),即需要再去主内存读取最新值。 我们常说的可见性就是依赖缓存一致性协议,而java中volatile关键字能够避免编译器优化成纯寄存器操作而丧失缓存一致性。, 对于缓存一致性协议,可见楼主的另一篇文章volatile关键字
3. 处理器优化和指令重排
除了缓存一致性问题,还有一种问题影响程序在多线程环境下的执行,为了使得处理器内部的运算单元能够尽量的被充分利用,处理器可能会对执行的指令进行乱序执行处理,这就是处理器优化。很多编程语言的编译器也会进行类似的优化,比如java虚拟机即时编译器也会做指令重排。如果任由处理器优化和编译器对指令重排,在多线程环境下可能会到各种各样的问题。
在java并发编程中,我们常常会遇到原子性、可见性、和有序性问题为了保证数据的安全,需要满足这三个特性:
- 原子性:是指在操作中,cpu不可以中途暂停然后再调度其他线程,如果操作不被中断,要么就是执行完成,要么就是不执行。
- 可见性: 指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改。
- 有序性: 程序执行的顺序按照代码的先后顺序执行。
其实这三个问题是人们抽象定义出来的,其底层问题就是前面提到的缓存一致性问题、处理器优化、指令重排问题。可见性问题对应缓存一致性问题,指令重排和处理器优化会导致有序性问题和原子性问题。
4. Java Memory Model
所以为了保证并发编程中可以满足原子性、可见性以及有序性,就引入了一个很重要的概念:内存模型。为了保证共享内存的正确性(原子性、可见性、有序性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。对于这些规范,不同的编程语言在实现上可能有所不同。
而java内存模型就是一种符合内存模型规范,屏蔽了各种硬件和操作系统的访问差异,保证java程序在各种平台下对内存的访问都能保证效果一致的机制和规范 。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
java内存模型本身是种抽象的概念,并不像硬件架构一样真实存在,它描述的是一组规则或者规范,它作用于工作内存和主存之间的数据同步过程,它规定了如何做数据同步,以及什么时候做数据同步。目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
我们知道java虚拟机的运行时数据区域被划分为:方法区、java堆、java虚拟机栈、PC寄存器、本地方法栈、还有常量池。这些区域可以分为线程共享区域和线程私有区域。线程共享区域包括:java堆、方法区、常量池,它们会随着虚拟机启动而创建,随着虚拟机退出而销毁。线程私有数据区包括:pc寄存器,jvm栈,本地方法区,这些区域随着线程开始和结束而创建和销毁。本质上讲,java内存模型和运行时数据区没有任何关系,他们是不同维度上的东西,如果硬要扯上关系,可以把java运行时数据区的jvm栈看做内存模型中的”工作内存“。
但是对于硬件架构来说,只有寄存器、缓存、主内存,并没有工作内存(线程私有数据)和主内存(堆内存)之分,也就是说java内存模型对内存的划分对硬件内存并没有任何影响,JMM是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都是存储在计算机物理内存中,当然也可能存储到cpu缓存或者寄存器中,java内存模型和硬件内存架构是一个相互交叉的关系,是一种抽象概念划分和物理硬件的交叉,对于java内存区域的划分也是同样的道理
5 java内存模型的实现
java内存模型除了定义了一套规范,还提供了一系列原语,这些原语封装了底层实现,可以直接让程序员使用。在java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurrent包等,其实这些就是java内存模型封装了底层实现后提供给程序员使用的一些关键字。通过使用java提供的这些关键字程序员就不需要关心底层的编译器优化、缓存一致性等问题。前面提到内存模型就是为了解决原子性、有序性和一致性问题,我们来看下java提供的关键字是如何解决的:
- 原子性: 在jvm中提供了两个高级的字节码指令,monitorenter和monitorexist两个字节码指令来保证原子性,在java语言层面上对应的关键字就是synchronized。对于synchronized的原理,这里暂且跳过,后续再介绍。在这我们需要记住的是使用synchronized关键字可以保证方法和代码块内的操作是原子性的。
- 可见性:java提供了volatile关键字,使得被其修饰的变量在被修改后可以立即同步到主内存,其他线程能够感知该变量被修改,使用时会从重新从主内存获取。除了volatile外,synchronized和final关键字也可以实现可见性。
- 有序性: synchronized和volatile都可以来保证有序性,他们的实现方式有所区别: volatile关键字会禁止指令重排,而synchronized保证同一时刻只允许一个线程操作。
不难发现,synchronized关键字貌似是万能的,他可以同时满足这三种特性,这也是很多人滥用synchronized的原因。