Java并发11:Java内存模型、指令重排、内存屏障、happens-before原则

本章主要对Java并发中非常重要的概念Java内存模型、指令重排和happens-before原则进行学习。

1.内存模型

如果想要设计表现良好的并发程序,理解Java内存模型是非常重要的。

Java线程之间的通信Java内存模型(Java Memory Model,简称JMM)控制
JMM决定一个线程对共享变量的写入何时对另一个线程可见。

JMM把JVM内部划分为线程栈(Thread stack)和堆(Heap),这张图演示了JMM的逻辑视图:
Java并发11:Java内存模型、指令重排、内存屏障、happens-before原则

 

说明:

  1. 每个线程都拥有自己的线程栈。
  2. 线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。
  3. 线程栈还包含了当前方法的所有本地变量信息。
  4. 一个线程只能读取自己的线程栈,每个线程中的本地变量都会有自己的版本,线程中的本地变量对其它线程是不可见的。
  5. 所有原始类型(8种)的本地变量都直接保存在线程栈当中,线程可以传递原始类型的副本给另一个线程,线程之间无法共享原始类型的本地变量。
  6. 堆区包含了Java应用创建的所有对象信息。
  7. 堆区包含了所有线程创建的对象信息。
  8. 堆区包含了原始类型的封装类(如Byte、Integer、Long等等)的对象信息。

下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区:

Java并发11:Java内存模型、指令重排、内存屏障、happens-before原则

 

 

 

  • 局部的基本类型:完全存储于Thread Stack中。
  • 局部的对象引用:引用会被存储于Thread Stack中,对象本身会被存储于Heap中。
  • 对象的成员方法中的局部变量:如果方法中包含本地变量,则会存储在Thread Stack中。
  • 对象的成员变量:不管它是原始类型还是包装类型,都会被存储到Heap区。
  • Static类型的变量:存储于Heap中。
  • 类本身相关信息:存储于Heap中。

 

堆中的对象可以被多线程共享:

一个线程如果要使用一个共享变量,则首先需要从Heap中加载这个共享变量,并在Thread Stack中形成这个共享变量的副本。
一个线程使用完这个共享变量之后,还需要将Thread Stack*享变量的副本更新到Heap中。
竞争:

如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。

举例:
Obj.count的初始值是0,线程A和线程B同时对Obj.count做自增操作。

串行情况之一:

  • 线程A在Thread Stack中创建Obj.count的副本,线程A的栈区中的Obj.count=0。
  • 线程A在Thread Stack中对Obj.count执行自增操作,线程A的栈区中的Obj.count=1。
  • 线程A将Thread Stack中Obj.count的副本更新到Heap中,这时,Heap中的Obj.count=1。
  • 线程B在Thread Stack中创建Obj.count的副本,线程B的栈区中的Obj.count=1。
  • 线程B在Thread Stack中对Obj.count执行自增操作,线程B的栈区中的Obj.count=2。
  • 线程B将Thread Stack中Obj.count的副本更新到Heap中,这时,Heap中的Obj.count=2。
  • 经过两次自增操作,最终Obj.count=2。

并行情况之一:

 

  • 线程A在Thread Stack中创建Obj.count的副本,线程A的栈区中的Obj.count=0。
  • 线程B在Thread Stack中创建Obj.count的副本,线程B的栈区中的Obj.count=0。
  • 线程A在Thread Stack中对Obj.count执行自增操作,线程A的栈区中的Obj.count=1。
  • 线程B在Thread Stack中对Obj.count执行自增操作,线程B的栈区中的Obj.count=1。
  • 线程A将Thread Stack中Obj.count的副本更新到Heap中,这时,Heap中的Obj.count=1。
  • 线程B将Thread Stack中Obj.count的副本更新到Heap中,这时,Heap中的Obj.count=1。
  • 经过两次自增操作,最终Obj.count=1。

经过学习JMM,可以加深对并发会安全问题的理解。

2.指令重排

CPU执行单元的速度要远超主存访问速度。

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。

  • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
  • 通过内存屏障禁止重排序:JMM通过插入特定类型的内存屏障,来禁止特定类型的编译器重排序和处理器重排序。

2.1.单线程程序语义

编译器、runtime和处理器都必须遵守as-if-serial语义。

不管怎么重排序,单线程的执行结果不会改变。

2.2.数据依赖性

如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。

编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

2.3.内存屏障

内存屏障或内存栅栏(Memory Barrier),是让一个CPU处理单元中的内存状态对其它处理单元可见的一项技术。

内存屏障有两个能力:

  1. 就像一套栅栏分割前后的代码,阻止栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性。
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性。


内存屏障有三种类型和一种伪类型:

  1. lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
  2. sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。
  3. mfence,即全能屏障,具备ifence和sfence的能力。
  4. Lock前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。

在Java中:

实现了内存屏障的技术有volatile。

volatile就是用Lock前缀方式的内存屏障伪类型来实现的。

学习指令重排,让我们明白,因为指令重排,多线程开发中,是存在很多有序性和可见性问题的。

3.happens-before原则

从jdk5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。

happens-before定义:

Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.

 

如果一个操作 happens-before 第二个操作,则第一个操作对第二个操作是可见的,并且一定发生在第二个操作之前。

重要的happens-before原则:

  • If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).
  • If an action x synchronizes-with a following action y, then we also have hb(x, y).
  • If hb(x, y) and hb(y, z), then hb(x, z).
  • An unlock on a monitor happens-before every subsequent lock on that monitor.
  • A write to a volatile field happens-before every subsequent read of that field.
  • A call to start() on a thread happens-before any actions in the started thread.
  • All actions in a thread happen-before any other thread successfully returns from a join() on that thread.

理解如下:

  • 线程内部规则:在同一个线程内,前面操作的执行结果对后面的操作是可见的。
  • 同步规则:如果一个操作x与另一个操作y在同步代码块/方法中,那么操作x的执行结果对操作y可见。
  • 传递规则:如果操作x的执行结果对操作y可见,操作y的执行结果对操作z可见,则操作x的执行结果对操作z可见。
  • 对象锁规则:如果线程1解锁了对象锁a,接着线程2锁定了a,那么,线程1解锁a之前的写操作的执行结果都对线程2可见。
  • volatile变量规则:如果线程1写入了volatile变量v,接着线程2读取了v,那么,线程1写入v及之前的写操作的执行结果都对线程2可见。
  • 线程start原则:如果线程t在start()之前进行了一系列操作,接着进行了start()操作,那么线程t在start()之前的所有操作的执行结果对start()之后的所有操作都是可见的。
  • 线程join规则:线程t1写入的所有变量,在任意其它线程t2调用t1.join()成功返回后,都对t2可见。
  • 学习happens-before原则,让我们了解了JVM本身提供的关于并发安全(主要是有序性和可见性)的保障规则。

参考文献
[1] 聊聊高并发(三十五)Java内存模型那些事(三)理解内存屏障
[2] 内存屏障或内存栅栏【转】
[3] 全面理解Java内存模型
[4] happens-before俗解
[5] Chapter 17. Threads and Locks

 

上一篇:C++链表几个问题


下一篇:每日三道面试题,通往*的道路10——JMM篇