1.深入理解juc-硬件缓存模型及java内存模型对可见性,有序性,一致性的支持

前言

本系列博文梳理一下java并发编程整个体系结构,从底层硬件并发原理开发一直到最上层的应用,本系列博客主要分析java并发的原理,不涉及并发的使用,因为使用起来都是很简单的,都是几行代码的事。本系列博文遵循着从下到上的顺序,力求能形成一套完整的并发编程知识体系。

 

说到计算机硬件,就不得不从古老的冯诺依曼计算机体系结构说起,冯·诺伊曼体系结构是现代计算机的基础,现在大多计算机仍是冯·诺伊曼计算机的组织结构,只是作了一些改进而已,并没有从根本上突破冯体系结构的束缚。冯·诺伊曼也因此被人们称为“计算机之父”。根据冯·诺伊曼体系结构构成的计算机,必须具有如下功能:把需要的程序和数据送至计算机中。必须具有长期记忆程序、数据、中间结果及最终运算结果的能力。能够完成各种算术、逻辑运算和数据传送等数据加工处理的能力。能够根据需要控制程序。将指令和数据同时存放在存储器中,是冯·诺伊曼计算机方案的特点之一。计算机由控制器、运算器、存储器、输入设备、输出设备五部分组成。

引发并发血案的因素:缓存与多核

1.缓存:在整个硬件体系中,程序从磁盘,usb设备,键盘等输入数据放入内存,经过总线交给cpu,cpu将数据进行计算后,写回到主内存再回到输出设备。但是这里面有个矛盾始终存在,就是cpu的运算速度太快了,以至于内存的速度根本跟不上cpu的运算速度,cpu多数时间都是在空转等待内存数据。

在大家的开发中经常使用redis做mysql的缓存,以便加速程序的访问,在大数据平台中hadoop经常将计算好的指标放在hive的ads层,该层的数据经常会导入到mysql中,这里mysql也可以看做是hadoop的缓存。由此可见缓存的特点是速度更快,到时存储空间更小,同等容量价格也更贵。对于一台计算机也一样,为了弥补内存与cpu中间的速度鸿沟,人们在cpu内集成了缓存,将内存中读入的数据缓存在cpu的高速缓存中,这样只要高速缓存中持有该数据,就不需要再去主内存中读取该数据了,而且,随着硬件的发展,高速缓存也发展出了多级结构,如L1,L2,L3缓存,L1最靠近CPU核心,L2其次,L3再次,运行速度方面:L1最快、L2次快、L3最慢;容量大小方面:L1最小、L2较大、L3最大。CPU会先在最快的L1中寻找需要的数据,找不到再去找次快的L2,还找不到再去找L3,L3都没有那就只能去内存找了。如图所示,越是接近cpu core的缓存速度越快,容量越小,价格也更昂贵。

2.多核:在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,每个cpu操作的数据会可能会从自己缓存中直接获取而不是从主内存中获取,同样的,计算后的结果也不一定能立即刷新到主内存里,导致当多个线程在不同的 CPU 上执行时,各自有一份自己的缓存数据,而这份缓存数据的更改对于其他cpu来说是不能立即可见的,这些线程操作的是不同的 CPU 缓存。

先来看一段程序:

public class Test {
  private long i= 0;
  private void add() {
    while(i++ < 100) {
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行 add() 操作
    Thread th1 = new Thread(()->{
      test.add();
    });
    Thread th2 = new Thread(()->{
      test.add();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}

如下图所示,加入两个线程同时对i++操作100次,正确的结果应该是200,但是实际上却是一个100-200的随机数。这是由于cpu1和cpu2分别从主内存读入自己的高速缓存后,一直到100次自增的操作期间内,不一定将这些临时结果写入主内存,而是写在自己的高速缓存内,而且期间读数据也只是从自己的高速缓存中读取,不一定会读主内存的数据,所以各自运算完毕写回主内存,后运算完的cpu会将结果写回主内存时覆盖前面的结果,导致结果出现问题。

由此产生了并发编程三大问题之一:可见性,即一个cpu改变了一个变量的值,另一个cpu不能马上感知到。

在操作系统的设计中,由于多进程的存在,我们可以同时执行多个任务,任务之间的切换依靠的是时间片机制,比如操作系统每隔1ms将任务切换给另一个进程来使用cpu。在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。这里的进程在等待 IO 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待时间里可以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 IO 的使用率也上来了。早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。Java 并发程序都是基于多线程的,自然也会涉及到任务切换。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如这段代码:i+=1

  1. 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  2. 指令 2:之后,在寄存器中执行 +1 操作;
  3. 指令 3:最后,将结果写入内存

操作系统做任务切换,可以发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 i=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 i+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

这里我们可能认为 count+=1 这个操作是一个原子操作,,然后高级语言中的一个操作在底层cpu指令执行的时候,并非是一条指令,有可能切分成多个指令,CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。因此,很多时候我们需要在高级语言层面保证操作的原子性。

由此产生了并发编程三大问题之二:原子性,即一个高级语言中看似不可分割的操作在底层cpu上是多个指令完成的,在cpu时间片切换的时候有可能产生并非问题。

再来看一个栗子:经典的double check实现单例模式,该单例暂时不使用volatile去修饰,看看可能产生的问题。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。看起来是没什么问题,但是这个getInstance() 是可能出现npe的,为什么呢?我们来看new操作的执行顺序:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。由此可见,优化后的重排序导致了线程安全问题,实际上在java体系中,如果程序员没有特别的控制,那么有多个过程都可能发生重排序,比如在java的编译期,运行期,指令执行期间是都可能发生重排序问题的,这种问题往往非常诡异,难以复现。

由此产生了并发编程三大问题之三:有序性,由于java编译,运行期间的重排序导致诡异的并发安全问题出现。

上面分别就java并发三大问题:可见性,原子性,有序性问题的场景做了简单阐述,那么java体系中是如何通过操纵底层硬件来实现线程安全的呢?回到前面的i=i+1在多线程情况下的图解:

cpu在使用数据的时候会去L1缓存中去读取,如果没有则依次从L2,L3中去寻找,如果还是没有,那么会通过总线读取主内存。主内存中的i=0是被两个线程所共享的变量,那么两个线程分别将读取到的i=0读取到自己的寄存器计算后,先后将i=1写回主内存。为了解决这里的问题,cpu有两种方案来处理该问题,一是总线加锁的方式,就是第一个cpu在访问i这个变量的时候,通过总线,对该内存地址进行加锁,其他cpu对该快内存地址访问的时候是不能访问的,直到第一个cpu释放锁,大概就是下图这样。这种方式实现起来简单粗暴,然后性能堪忧,不能发挥多核cpu的优势,该块区域是不能真正的并行执行,因此现代计算机基本已经不适用这种方式。

第二种方式是采用缓存一致性协议。缓存一致性协议有很多种,如mesi,msi,mosi,moesi等,其中最广泛使用的就是mesi。单核Cache中每个Cache line有2个标志:dirty和valid标志,它们很好的描述了Cache和Memory(内存)之间的数据关系(数据是否有效,数据是否被修改),而在多核处理器中,多个核会共享一些数据,MESI协议就包含了描述共享的状态。在MESI协议中,每个Cache line有4个状态,可用2个bit表示,mesi四个字母分别代表了一个状态,它们代表的含义如下图:

CPU中的高速缓存内部结构是一个拉链散列表,和HashMap的底层结构以及原理十分相似。它分为若干桶,每个桶是一个链表,包含若干缓存条目,每个缓存条目就是一个cache line。一个cache line一般是64 bytes,假设程序中读取某一个int变量,CPU并不是只从主存中读取4个字节,而是会一次性读取64个字节,然后放到cpu cache中。因为往往紧挨着的数据,更有可能在接下来会被使用到。比如遍历一个数组,因为数组空间是连续的,所以并不是每次取数组中的元素都要从主存中去拿,第一次从主存把数据放到cache line中,后续访问的数据很有可能已经在cache中了。结构如下图:

 

cacheline是缓存中的一个最小单位,结构如下:

CPU访问内存时,会通过内存地址解码的三个数据:index(桶编号)、tag(缓存条目的相对编号)、offset(变量在缓存条目中的位置偏移)来获取高速缓存中对应的数据。这时候可以看到Flag这个标识了,然后就可以引出了状态值的概念,然后就可以引出我们所说的MSEI协议了。

mesi协议中flag有四种:M(Modified)和E(Exclusive)状态的Cache line,数据是独有的,不同点在于M状态的数据是dirty的(和内存的不一致),也就是该状态的数据在一个cpu内被修改过了,但是没有同步到主内存,E状态的数据是clean的(和内存的一致)。(Shared)状态的Cache line,数据和其他Core的Cache共享。只有clean的数据才能被多个Cache共享。

下面以X+=1来说明mesi的用法:加入两个线程同时操作x变量。首先两个cpu分别读取x变量并放入本地缓存中,并对总线进行嗅探,此时都是S状态。 当cpu1要对x做计算的时候,会对x的值进行修改,本地缓存中的x变量的标记由S改为M(修改过了),cpu2由于会对总线嗅探,当cpu2嗅探到x变量改变了的时候,就把该变量由S修改为I(不可用)状态,如图:

此时,cpu1中x变量为M状态,cpu2中为I状态,如果此时cpu2需要对x+1操作,发现本地的X变量是个I状态,即不可用状态,那么会对总线发起读取操作,那么cpu1此时嗅探到cpu2的读取请求后,就会将x写回主内存,同时将本地x设置为E状态。cpu2读取到主内存中cpu1所刷回的最新数据x=2,将其读取到本地缓存,并将状态设为S,此时Cpu1嗅探到cpu2中数据的改变,将本地缓存的x状态设为S,如下图,此时两个cpu中缓存的x状态都是S。

上面是针对两个cpu不是同时修改的情况,会不会两个cpu同时修改变量x呢,如果两个cpu同时对x状态修改,想要将状态由s变为M,那么在一个指令周期内,会有一个裁决机制,只有一个cpu能够将状态修改为M,裁决失败就会变成I状态,所以不会出现同时修改冲突的问题。

缓存一致性协议MESI看起来很好的解决了一致性的问题,但是mesi并不是所有情况都适用,如果一个变量太大,一个缓存行放不下该变量,那么就无法锁住该变量,只能采用总线加锁的方式。或者cpu较老,本身不支持MESI协议。

JAVA内存模型
内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象,不同架构下的物理机拥有不一样的内存模型,Java虚拟机也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。在C/C++语言中直接使用物理硬件和操作系统内存模型,导致不同平台下并发访问出错。而JMM的出现,能够屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性,是的Java程序能够“一次编写,到处运行”。

JMM主要解决的问题: 解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题

  • 缓存一致性问题其实就是可见性问题。
  • 处理器优化是可以导致原子性问题
  • 指令重排即会导致有序性问题

内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障

参考:

在jmm中,堆空间和方法区是线程共享的,可以理解为硬件模型中对应的主内存,而工作内存为私有区域,包括程序计数器,本地方法栈,虚拟机栈可以对应为硬件的高速缓存。这样jmm屏蔽了硬件的差异,实现了软件与硬件的对应关系。下图更为清楚的描述了硬件与jmm规范的对应关系:

一个对象都会有成员方法和成员变量,对于一个成员方法内的变量,如果是一个基本变量,那么直接存在方法的栈帧里,如果是个引用类型,则引用存放在栈帧里,实际的对象存在主内存中;对于一个类本身信息或者静态变量则都会存在主内存中。具体的对应关系如下图所示:

jmm模型跟cpu缓存模型结构类似,是基于cpu缓存模型建立起来的,jmm模型是标准化的,屏蔽掉了底层不同计算机的区别,对于硬件内存来说只有寄存器,缓存内存,主内存的概念,并没有工作内存(线程私有数据区域)和主内存之分,也就是说jmm堆内存的划分对硬件内存并没有任何影响。

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

主内存中存在一个共享变量x,现在有A和B两条线程分别对该变量x=1进行操作,A/B线程各自的工作内存 中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取 到的值是A 线程更新后的值2还是更新前的值1呢?答案是,不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取 到A线程更新后的值2,这是因为 工作内存是每个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主 内,而对于B线程的也是类似的, 这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线 程此时正在读取 主内存,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线 程读取到的就是x=2,但到底是哪种情况先发生呢?这是不确定的,这也就是所谓的线程安全问题。

为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线 程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开的,下 面我们看看这三个特性。

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内 存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作 都是原子的、不可再分的。

  1. lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其 他线程锁定。
  3. read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后 的load动作使用。
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副 本中。
  5. use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇 到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当 虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  8. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量 中。

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回 主内存,就要顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保 证是连续执行。也就是说,read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、b进 行访问时,一种可能出现顺序是read a、read b、load b、load a。除此之外,Java内存模型还规定了在执行上述8 种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受, 或者从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内 存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的 变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多 次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要 重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其 他线程锁定住的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

这8种内存访问操作以及上述规则限定,再加上稍后介绍的对volatile的一些特殊规定, 就已经完全确定了Java程序中哪些内存访问操作在并发下是安全的。由于这种定义相当严谨但又十分烦琐,实践起来很麻烦,所以这种定义的一个等效判断原则——先行发生原则,用来确定一个访问在并发环境下是否安全。

对于上面的八个原子操作,不一定是连续执行的,但是一定是有顺序的执行的,比如use之前必须有load操作才行,而不能相反。

为什么mesi不能保证原子性?

以count++为例,假如初始状态下,CPU1读取到了count=1,cpu2也读取到了count=1。此时两个线程同时对count执行+1操作,假设线程1先完成,导致状态由s变为m,此时,线程2嗅探到了线程1修改了count值,将本地的count由S变为I,但是此时线程2已经对count执行+1的操作了,此轮指令已经执行完成,但是此次指令的结果是无效的,循环次数减了一次,导致浪费了一次循环,导致count少增加了一次1.所以线程变得不安全。

最后,关于有序性问题,在JMM中,提供了以下三种方式来保证有序性:

  • happens-before原则
  • synchronized机制
  • volatile机制

happens-before原则

happens-before原则是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到。“影响”包括修改了内存中共享变量的值、 发送了消息、 调用了方法等。

如何理解 Happens-Before 呢?如果望文生义(很多网文也都爱按字面意思翻译成“先行发生”),那就南辕北辙了,Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。所以比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。Happens-Before 规则应该是 Java 内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的。

1. 程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的,也比较符合单线程里面的思维:按照程序的顺序,第 6 行代码 “x = 42;” Happens-Before 于第 7 行代码 “v = true;”,这就是规则 1 的内容,程序前面对某个变量的修改一定是对后续操作可见的。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里 x 会是多少呢?
    }
  }
}

 

2. volatile 变量规则

这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

3. 传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

从图中,我们可以看到:

  1. “x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;
  2. 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。

再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?

如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的。

4. 管程中锁的规则

这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

要理解这个规则,就首先要了解“管程指的是什么”。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。

管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) { // 此处自动加锁
  // x 是共享变量, 初始值 =10
  if (this.x < 12) {
    this.x = 12; 
  }  
} // 此处自动解锁

所以结合规则 4——管程中锁的规则,可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,应该不难理解。

5. 线程 start() 规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。换句话说就是,如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。

Thread B = new Thread(()->{
  // 主线程调用 B.start() 之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();

 

6. 线程 join() 规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。

Thread B = new Thread(()->{
  // 此处对共享变量 var 修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66

Java 内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向 JVM 的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是 Happens-Before 规则。

另外还有两项:

对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

  • 每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。
  • 用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。

硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。

内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

java中有四种内存屏障:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

对于volatile中的内存屏障语义:

  1. 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
  2. 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

对于final中的内存屏障语义:

  1. 新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;
  2. 初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(意思就是先赋值引用,再调用final值)

总之上面规则的意思可以这样理解,必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:

写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。

读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。

X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。

Java 内存模型底层实现可以简单的认为:通过内存屏障 (memory barrier)禁止重排序,即时编译器根据具体的底层 体系架构,将这些内存屏障替换成具体的 CPU指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。 而对于处理器而言,内存屏障将会导致缓存的刷新操作。 比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存屏障.

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值