阅读 151

Java并发篇(2)解读JMM内存模型

JMM内存模型概述

在了解Java虚拟机的内存模型之前,我们要先了解为什么会出现内存模型这一概念。在物理计算机中,并发地让计算机执行任务也会出现并发问题。由于CPU与主内存之间的运算速度差距太大,所以不得不在它们之间添加一层高速缓存作为缓冲,高速缓存的读写速度接近于处理器,CPU对数据的操作都在缓存上执行,操作结束后再从高速缓存同步回主存中,所以CPU的执行效率大大提升。但是与此同时出现的另外一个问题是如何保证缓存一致性。计算机共享主内存数据,如果多个高速缓存同时往主内存中写入数据,以哪一个缓存为标准是一个很重要的问题,为了解决这个问题,在读写缓存时需要遵循一些特定的协议,保证缓存数据的一致性。同样,为了解决Java中并发产生的缓存一致性问题,模仿计算机的解决方法,产生了JMM内存模型。

物理计算机的内存访问模型:

Java中的内存访问模型:

上面两幅图是非常类似的,所以类比性非常地强,Java内存模型解决并发问题的思想本质上是依照计算机解决并发问题的思想是一致的。

JMM内存模型的区域剖析

JMM内存模型的区域分为主内存和工作内存,JMM解决了Java应用线程并发执行时出现的并发问题:1. 线程之间如何通信;2. 线程之间如何完成同步。通信是指线程之间以哪一种机制交换信息,主要有两种:共享内存和消息传递。JMM是通过读-写主内存中的共享变量进行通信的,所以理解JMM是对正确并发编程的必经之路。

主内存与工作内存

  1. 有哪些变量属于共享变量?

实例变量、静态变量和数组对象。

共享变量都是存储在主内存(等价于物理硬件的主内存)中的,而每个线程拥有自己的工作内存(对应物理硬件的高速缓存),工作内存中拥有主内存中共享变量的副本,线程对变量的读-写操作都需要在工作内存中完成,由工作内存去更新主内存的值,线程之间的通信需要通过主内存来完成。

  1. 线程之间如何通过主内存通信?

可以看到工作内存依靠JMM控制向主内存更新共享变量,而JMM就是决定一个线程在什么时候将变量副本写入能够对其它线程可见。

上面图中如果线程A与线程B要完成通信,需要两步:

  1. 线程A从主内存读取共享变量到工作内存,然后更新变量副本的值,再重新写入主内存中;
  2. 线程B从主内存中读取最新的共享变量A

从横向去看,线程A和线程B就好像通过共享变量去通信,如果线程A更新变量副本后没有即时写到主内存中,线程B读取了旧的共享变量值,此时线程B读到的就是过期数据,称为脏数据。可以通过同步机制或者使用volatile关键字确保线程读取的共享变量是最新值。

重排序

重排序分为三种:

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去是在乱序执行。

上述的1属于编译器重排序,2和3属于处理器重排序,这些重排序会导致多线程程序出现内存可见性问题。针对编译器重排序,JMM的编译器重排序规则会禁止一些特定类型的编译器重排序针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序

什么时候不能进行重排序?这里提到一个数据依赖性

int a = 1;  // 1
int b = 2;  // 2
int res = a + b; // 3
复制代码

其中1和2不存在数据依赖性,所以1和2这两条语句可以被重排序,执行顺序可以为:1 - 2 - 3;2 - 1 - 3,不影响最终结果res = 3。数据依赖性具体的定义为:如果两个操作同时针对同一个变量,如果其中一个操作是写操作,那么这两个操作是存在数据依赖的。存在三种情况:1. 读后写;2. 写后读;3. 写后写。如果对这三种情况的两条语句进行重排序,那么会影响最终结果。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。

as-if-serial原则

as-if-serial的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。as-if-serial原则将单线程程序很好地封装起来,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的as-if-serial使程序员无需担心重排序会干扰它们,也无需担心内存可见性问题。例如上面的三行代码,在单个线程中,会让人觉得它们是按顺序一行一行执行的,实际上操作1和操作2没有数据依赖,所以可能会进行重排序,但是最终结果不会改变。所以程序员无需关系这一段代码是否被重排序,因为最终结果都不会改变:res = 3。

happens-before定义

1)在JMM中,如果一个操作对另一个操作可见,那么这两个操作之间一定满足happens-before原则,这两个操作既可以在一个线程内,也可以在不同线程之间。

// 线程A
i = 1;
// 线程B
j = i;
复制代码

加入A happens-before B,那么线程A的操作必定对线程B可见,所以j的值为1。因为1)此时线程C还没出现;2)根据happens-before原则,i = 1这个操作对线程B是可见的。

2)如果A happens-before B,但是在重排序之后的执行结果不变,那么JMM会允许这种重排序。

上面的1)是JMM对程序员的保证,如果A happens-before B,那么JMM会向程序员保证A操作对B操作是可见的。

上面的2)是JMM对编译器和处理器重排序约束的规则。JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。happens-before可以看作是多线程版的as-if-serial原则。

happens-before具体规则

程序员相关的规则:

  1. 程序顺序规则:一个线程中的每个操作,都happens-before于该线程中的任意后续操作。
  2. 监视器锁原则:对一个锁的unlock操作happens-before于下一个lock操作。
  3. volatile变量原则:对一个volatile的写操作happens-before于后续对这个变量的读操作
  4. 传递性:如果A操作happens-before于B操作,B操作happens-before于C操作,那么A操作happens-before与C操作。

在日常开发中遇到的最常见的情况就是这几种,另外还有四种情况是:线程启动原则、线程终止原则、线程中断原则、对象终结原则。作者认为它们并不常见,所以没有列出来,以减轻大家的学习压力。

as-if-serial与happens-before对比

  1. as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变
  2. as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的
  3. as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度

总结

这篇文章我们了解了JMM内存模型产生的原因,它与物理计算机硬件之间的类比。JMM内存模型是为了解决并发当中出现的线程间数据不可见性问题产生的一种模型。在并发编程当中出现数据不可见的原因在于重排序,以及多线程之间的操作没有进行正确的同步,那么JMM模型通过规定happens-before原则来判断前一个操作是否对后一个操作可见。

对JMM内存模型的理解,要做到以下几点:

  1. 为什么会出现JMM内存模型?
  2. 什么是JMM内存模型,它的组成部分是什么?
  3. 什么情况下可以进行重排序?什么情况下又不应该进行重排序?
  4. happens-before原则是什么?它有什么作用?

巨人的肩膀:

《Java并发编程的艺术》

《深入理解Java虚拟机》

github.com/CL0610/Java…