Java多线程之Java内存模型
在介绍Java内存模型之前,我们先介绍一下计算机硬件的内存模型,因为JVM的并发和物理机器的并发很相似,甚至JVM并发操作中很多设计都是因为计算机系统的设计引发的。
硬件的内存模型
大家都知道计算机系统处理任务主要是靠处理器(CPU)来进行运算的,而运算中又会涉及到数据,数据在哪呢,数据自然是存储在计算机内存中,所以处理器在运算过程中不可避免的会涉及到与内存的读写交互,比如读取运算所需的数据,存储运算得到的数据结果等。而处理器的运算速度相比物理内存的读写速度要快得多,所以会出现处理器要等待内存数据读写结束后才能进行下一步的运算,因此为了提高计算机的运算速度,现在的计算机系统为处理器添加了一层读写速度尽量接近处理器的高速缓存来缓解内存与处理器之间的性能差异。这样在处理任务时将运算需要的数据复制到缓存中,运算结束后再将数据从缓存中同步写回到内存,这样处理器在运算时就不需要等待内存数据读写结束了。
处理器、高速缓存、内存之间的交互关系图如下:
如上图所示,在多处理器系统中因为每个处理器都有自己的高速缓存,所以这就引发了一个新的问题,如果多个处理器的运算任务都涉及同一块内存区域,就可能导致各自的高速缓存数据不一致,那这个时候从高速缓存写回主内存的数据以谁为准呢?这就是引入高速缓存引发的新问题,我们称之为:缓存一致性。
为了解决缓存一致性的问题,现代计算机系统需要各个处理器读写缓存时遵循一些协议(MSI、MESI、MOSI、Synapse、Firefly、DragonProtocal,这些都是缓存协议),按照协议来进行读写访问缓存。
既然这里说的是“硬件的内存模型”,那什么是内存模型呢?
内存模型可以理解为在特定的操作协议下,对特定的内存和高速缓存进行读写访问的抽象。不同的物理机器,可能有着不同的“内存模型”。
除了为处理器增加高速缓存之外,处理器还会对输入的代码程序进行乱序执行优化,保证该乱序执行之后的结果和顺序执行的结果一致。举个例子:
int a = 1;
int b = 2;
int c = a + b;
上面的这段代码将第一行和第二行调换顺序对最终的结果没有任何的影响。而处理器在实际运算过程中为了优化性能,也会对代码的执行顺序进行类似的调换(保证结果不变的前提下),这种执行顺序的调换称之为指令重排序,而JVM中也存在类似的指令重排序优化功能。至于为什么指令重排序会优化性能,它是如何优化性能的,这就涉及到汇编指令的知识,我也不懂汇编指令,这里就不介绍了,有兴趣的可以自己去了解了解。
Java内存模型
前面说过不同的物理机器,可能有着不同的“内存模型”,而Java虚拟机中定义的内存模型可以屏蔽不同的硬件内存模型,这样就可以保证Java程序在各个平台都能达到一致的内存访问效果,也就是常说的一次编写到处运行,因为内存模型为我们屏蔽掉了不同硬件平台之间的差异。
主内存和工作内存
Java内存模型中规定所有变量都存储在主内存(虚拟机内存的一部分)中,主要对应Java的堆内存。这里提到的变量实际上是指共享变量,存在线程间竞争的变量,如:实例变量、静态变量和构成数组对象的元素,而局部变量和方法参数因为是线程私有的,所以不存在线程间共享和竞争关系,所以也就不在前面提到的变量范围内。
每个线程有着自己独有的工作内存,工作内存中保存了被该线程使用到的变量,这些变量来自主内存变量的副本拷贝。线程对变量的所有读写操作都必须在工作内存中进行,不能直接读写主内存中的变量。而不同线程间的工作内存也是独立的,一个线程无法访问其他线程的工作内存中的变量。
线程工作时,把需要的变量从主内存中拷贝到自己的工作内存,线程运行结束之后再将自己工作内存中的变量写回到主内存中,而多个线程间对变量的交互只能通过主内存来间接实现。具体的线程、工作内存、主内存的交互关系图如下:
通过上面的图和前面的介绍,我们就很容易明白我们平常所说的多线程编程时遇到数据状态不一致的问题是怎么产生的。例如:线程1和线程2都需要操作主内存中的共享变量A,当线程1已经在工作内存中修改了共享变量A副本的值但是还没有写回主内存,这时线程2拷贝了主内存*享变量A到自己的工作内存中,紧接着线程1将自己工作内存中修改过的共享变量A的副本写回到了主内存,很明显线程2加载的共享变量A是之前的旧状态的数据,这样就产生了数据状态不一致的问题。
Java内存模型和硬件内存模型的关系
大家看前面的Java内存模型交互图和硬件内存模型交互图可以发现两种内存模型其实是很相似的,实际上Java程序在运行过程中,最终还是会映射到具体的硬件处理器内核上,但java内存模型和硬件的内存模型并不完全一致。
对于硬件内存来说只有寄存器、高速缓存、主内存的概念,并没有工作内存(线程私有数据区)和主内存(JVM堆内存)之分,它们只是java内存模型的一种抽象概念并不是实际存在的,因此java内存模型对内存的划分对硬件内存并没有任何影响。
在java内存模型中,无论是工作内存还是主内存,它们都有可能存储到硬件的主内存、高速缓存或者是寄存器中,所以java内存模型和硬件内存模型是是一种抽象概念和真实物理硬件的交叉关系。关系图如下:
内存交互
前面说到工作内存与主内存会进行数据读写交互,这个读写交互具体实现细节则是由Java内存模型来控制的,Java内存模型为主内存和工作内存间的变量拷贝及同步写回定义了具体的实现协议,该协议主要由8种操作来完成,不同虚拟机在实现时必须保证每一个基本数据类型的操作都是原子性不可再分的(long,double类型的变量在某些平台可以例外,虽然在JVM规范中没有强制要求long,double类型具有原子性,但是规范建议各JVM实现成具有原子性的,实际上市面上的JVM也基本都实现了原子性),具体8种操作如下:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把通过read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作使用。
- write(写入):作用于主内存的变量,它把通过store操作从工作内存中得到的变量的值放入主内存的变量中。
线程、工作内存、主内存对应这8种操作的交互关系图如下:
按照上面的8种内存交互操作,如果要把一个变量从主内存复制到工作内存,就需要顺序的执行read和load操作,而如果要把一个变量从工作内存同步回主内存,则需要顺序执行store和write操作,这里说的是顺序执行,而不是连续执行,这也就意味着两个操作之间可以插入其他操作,例如对主内存中的变量1和变量2访问时,一种可能的顺序是read 1, read 2, load 2, load 1。
除此之外,Java内存模型对这8中操作还存在着其他的约束:
- 只允许read和load、store和write这两对操作成对出现。
- 不允许线程丢弃它的最近的assign操作,即变量在工作内存中改变之后,必须同步回写到主内存。
- 不允许线程把没有经过assign操作的变量,同步回写到主内存。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中使用未经初始化的变量,即对一个变量进行use、store操作之前,必须先执行过load、assign操作。
- 一个变量在同一时刻只能被一条线程执行lock操作,一旦lock成功,可以被同一线程重复lock多次,多次执行lock之后,只有执行相同次数的unlock操作,变量才会被解锁。
- 对一个变量执行lock操作,将会清空工作内存中该变量的值,所以在执行引擎使用这个变量前,需要重新执行load或assign操作对其进行初始化。
- 对一个变量执行unlock操作之前,必须先把该变量同步回主内存(执行store、write操作)。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许unlock一个被其他线程lock的变量。
Java内存模型的3个特征
Java内存模型其实一直是围绕着并发过程中的如何处理原子性、可见性和有序性这三个特征建立的。
原子性(Atomicity)
什么是原子性呢,原子性是指一个操作不可中断,不可分割,在多线程中就是指一旦一个线程开始执行某个操作,就不能被其他线程干扰。
Java内存模型直接用来保证原子性变量的操作包括use、read、load、assign、store、write,我们大致可以认为Java基本数据类型的访问都是原子性的(long,double除外,前面已经介绍过了),如果用户要操作一个更大的范围保证原子性,Java内存模型还提供了lock和unlock来满足这种需求,但是这两种操作没有直接开放给用户,而是提供了两个更高层次的字节码指令:monitorenter 和 moniterexit,这两个指令对应到Java代码中就是synchronized关键字,所以synchronized代码块之间的操作具有原子性。
可见性(Visibility)
可见性是指当一个线程修改了变量之后,其他线程能立刻得知这个修改。
Java内存模型通过将变量修改后将新值同步写回主内存,在读取前从主内存刷新变量值,所以JVM内存模型是通过主内存作为传递介质来实现可见性的。无论是普通变量还是volatile修饰的变量都是这样的,唯一的区别就是volatile变量在被修改之后会立刻写回主内存,而在读取时都会重新去主内存读取最新的值,而普通变量则在被修改后会先存储在工作内存,之后再从工作内存写回主内存,而读的时候则是从工作内存中读取该变量的副本拷贝。
除了volatile可以实现可见性之外,synchronized和final关键字也能实现可见性。synchronized同步块的可见性是因为对一个变量执行unlock操作之前,必须将变量的改动写回主内存来(store、write两个操作)实现的。而final字段则是因为一旦final字段初始化完成,其他线程就可以访问final字段的值,而且final字段初始化完成之后就不再可变。
有序性(Ordering)
前面说过处理器在执行运算的时候,会对程序代码进行乱序执行优化,也叫做重排序优化。同样的,在JVM中也存在指令重排序优化,这种优化在单线程中是不会存在问题的,但如果这种优化出现在多线程环境中,就可能会出现多线程安全的问题,因为线程1的指令优化可能影响线程2中某个状态。
Java提供了volatile和synchronized关键字来保证线程间操作的有序性。volatile是因为其本身的禁止指令重排序语义来实现的,而synchronized则是由“同一个变量在同一时刻只能有一个线程对其进行lock操作”这条规则来实现的,这也就是synchronized代码块对同一个锁只能串行进入的原因。
上面介绍了Java内存模型的3中特性,我们可以发现synchronized可以说是万能的,它能实现Java多线程中的这3大特性,所以这也早就了很多人在遇到多线程并发操作事都是直接使用synchronized完成,但使用synchronized内置锁会阻塞需要而又没有获取该内置锁的线程,而Java中的线程与操作系统中的原生线程是一一对应的,所以当synchronized内置锁导致某个线程阻塞后,会导致系统从用户态切换到内核态执行阻塞操作,这个操作是非常耗时的。
关于Java内存模型就暂时介绍到这里,接下来的一篇文章会接着介绍更加轻量级的同步实现:volatile关键字,同时还会介绍volatile实现中涉及到的内存屏障。
下面是我的个人公众号,欢迎关注交流