wittyResry / myIssue

My issue mark down^_^ 欢迎吐槽,讨论~~
https://github.com/wittyResry/myIssue/issues
The Unlicense
5 stars 1 forks source link

Java内存模型 #88

Open wittyResry opened 6 years ago

wittyResry commented 6 years ago

内存模型

Java内存模型基础

在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。 同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。 Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

20180703091522

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。 下面通过示意图来说明这两个步骤:

20180703091706

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

Java内存模型的抽象 在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

20180703120047

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。 从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

重排序 在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。 从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
20180703120146

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
wittyResry commented 6 years ago

Java内存模型作用? Java包含了几个语言级别的关键字,包括:volatile, final以及synchronized,目的是为了帮助程序员向编译器描述一个程序的并发需求。Java内存模型定义了volatile和synchronized的行为,更重要的是保证了同步的java程序在所有的处理器架构下面都能正确的运行。

没有正确同步含义?

  1. 一个线程中有一个对变量的写操作,
  2. 另外一个线程对同一个变量有读操作,
  3. 而且写操作和读操作没有通过同步来保证顺序。

synchronization的作用?

  1. 最广为人知的就是互斥 ——一次只有一个线程能够获得一个监视器,因此,在一个监视器上面同步意味着一旦一个线程进入到监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。
  2. 但是同步的含义比互斥更广。同步保证了一个线程在同步块之前或者在同步块中的一个内存写入操作以可预知的方式对其他有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了。 依据缓存来讨论同步,可能听起来这些观点仅仅会影响到多处理器的系统。但是,重排序效果能够在单一处理器上面很容易见到。对编译器来说,在获取之前或者释放之后移动你的代码是不可能的。当我们谈到在缓冲区上面进行的获取和释放操作,我们使用了简述的方式来描述大量可能的影响。
  3. 新的内存模型语义在内存操作(读取字段,写入字段,锁,解锁)以及其他线程的操作(start 和 join)中创建了一个部分排序,在这些操作中,一些操作被称为happen before其他操作。当一个操作在另外一个操作之前发生,第一个操作保证能够排到前面并且对第二个操作可见。这些排序的规则如下:
    • 线程中的每个操作happens before该线程中在程序顺序上后续的每个操作。
    • 解锁一个监视器的操作happens before随后对相同监视器进行锁的操作。
    • 对volatile字段的写操作happens before后续对相同volatile字段的读取操作。
    • 线程上调用start()方法happens before这个线程启动后的任何操作。
    • 一个线程中所有的操作都happens before从这个线程join()方法成功返回的任何其他线程。(注意思是其他线程等待一个线程的jion()方法完成,那么,这个线程中的所有操作happens before其他线程中的所有操作)

这意味着:任何内存操作,这个内存操作在退出一个同步块前对一个线程是可见的,对任何线程在它进入一个被相同的监视器保护的同步块后都是可见的,因为所有内存操作happens before释放监视器以及释放监视器happens before获取监视器。

指令重排

Java内存模型(JMM, Java Memory Model)主要是由禁止指令重排的规则所组成的,其中包括了字段(包括数组中的元素)的存取指令和监视器(锁)的控制指令。

能否重排 第二个操作
第一个操作 Normal Load/Normal Store Volatile load/MonitorEnter Volatile store/MonitorExit
Normal Load/Normal Store     No
Volatile load/MonitorEnter No No No
Volatile store/MonitorExit   No No

上表:空白的单元格代表在不违反Java的基本语义下的重排是允许的 术语说明: Normal Load指令包括:对非volatile字段的读取,getfield,getstatic和array load; Normal Store指令包括:对非volatile字段的存储,putfield,putstatic和array store; Volatile load指令包括:对多线程环境的volatile变量的读取,getfield,getstatic; Volatile store指令包括:对多线程环境的volatile变量的存储,putfield,putstatic; MonitorEnters指令(包括进入同步块synchronized方法)是用于多线程环境的锁对象; MonitorExits指令(包括离开同步块synchronized方法)是用于多线程环境的锁对象。

内存屏障

编译器和处理器必须同时遵守重排规则。由于单核处理器能确保与“顺序执行”相同的一致性,所以在单核处理器上并不需要专门做什么处理,就可以保证正确的执行顺序。但在多核处理器上通常需要使用内存屏障指令来确保这种一致性。

内存屏障指令仅仅直接控制CPU与其缓存之间,CPU与其准备将数据写入主存或者写入等待读取、预测指令执行的缓冲中的写缓冲之间的相互操作。这些操作可能导致缓冲、主内存和其他处理器做进一步的交互。但在JAVA内存模型规范中,没有强制处理器之间的交互方式,只要数据最终变为全局可用,就是说在所有处理器中可见,并当这些数据可见时可以获取它们。

栅栏(Fence):它保证在栅栏前初始化的load和store指令,能够严格有序的在栅栏后的load和store指令之前执行。无论在何种处理器上,这几乎都是最耗时的操作之一(与原子指令差不多,甚至更消耗资源),所以大部分处理器支持更细粒度的屏障指令。

  1. LoadLoad 屏障

序列:Load1,Loadload,Load2

确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。 而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。

  1. StoreStore 屏障

序列:Store1,StoreStore,Store2

确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。

  1. LoadStore 屏障

序列: Load1; LoadStore; Store2

确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。

  1. StoreLoad 屏障

序列: Store1; StoreLoad; Load2

确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。StoreLoad屏障可以防止一个后续的load指令 不正确的使用了Store1的数据,而不是另一个处理器在相同内存位置写入一个新数据。正因为如此,所以在下面所讨论的处理器为了在屏障前读取同样内存位置存过的数据,必须使用一个StoreLoad屏障将存储指令和后续的加载指令分开。Storeload屏障在几乎所有的现代多处理器中都需要使用,但通常它的开销也是最昂贵的。它们昂贵的部分原因是它们必须关闭通常的略过缓存直接从写缓冲区读取数据的机制。这可能通过让一个缓冲区进行充分刷新(flush),以及其他延迟的方式来实现。

需要的屏障 第二步      
第一步 Normal Load Normal Store Volatile LoadMonitorEnter Volatile StoreMonitorExit
Normal Load       LoadStore
Normal Store       StoreStore
Volatile LoadMonitorEnter LoadLoad LoadStore LoadLoad LoadStore
Volatile StoreMonitorExit     StoreLoad StoreStore

如下例子解释如何放置屏障:

class X {
    int a, b;
    volatile int v, u;

    void f() {
        int i, j;

        i = a;// load a
        j = b;// load b
        i = v;// load v
        // LoadLoad
        j = u;// load u
        // LoadStore
        a = i;// store a
        b = j;// store b
        // StoreStore
        v = i;// store v
        // StoreStore
        u = j;// store u
        // StoreLoad
        i = u;// load u
        // LoadLoad
        // LoadStore
        j = b;// load b
        a = i;// store a
    }
}

屏障在不同处理器上还需要与MonitorEnter和MonitorExit实现交互。锁或者解锁通常必须使用原子条件更新操作CompareAndSwap(CAS)指令或者LoadLinked/StoreConditional (LL/SC),就如执行一个volatile store之后紧跟volatile load的语义一样。CAS或者LL/SC能够满足最小功能,一些处理器还提供其他的原子操作(如,一个无条件交换),这在某些时候它可以替代或者与原子条件更新操作结合使用。

在所有处理器中,原子操作可以避免在正被读取/更新的内存位置进行写后读(read-after-write)。(否则标准的循环直到成功的结构体(loop-until-success )没有办法正常工作)。但处理器在是否为原子操作提供比隐式的StoreLoad更一般的屏障特性上表现不同。一些处理器上这些指令可以为MonitorEnter/Exit原生的生成屏障;其它的处理器中一部分或者全部屏障必须显式的指定。

为了分清这些影响,我们必须把Volatiles和Monitors分开:

需要的屏障 第二步      
第一步 Normal Load Normal Store Volatile Load Volatile Store MonitorEnter MonitorExit
Normal Load       LoadStore   LoadStore
Normal Store       StoreStore   StoreExit
Volatile Load LoadLoad LoadStore LoadLoad LoadStore LoadEnter LoadExit
Volatile Store     StoreLoad StoreStore StoreEnter StoreExit
MonitorEnter EnterLoad EnterStore EnterLoad EnterStore EnterEnter EnterExit
MonitorExit     ExitLoad ExitStore ExitEnter ExitExit

另外,特殊的final字段规则需要一个StoreLoad屏障。

在这张表里,”Enter”与”Load”相同,”Exit”与”Store”相同,除非被原子指令的使用和特性覆盖。特别是: EnterLoad 在进入任何需要执行Load指令的同步块/方法时都需要。这与LoadLoad相同,除非在MonitorEnter时候使用了原子指令并且它本身提供一个至少有LoadLoad属性的屏障,如果是这种情况,相当于没有操作。 StoreExit在退出任何执行store指令的同步方法块时候都需要。这与StoreStore一致,除非MonitorExit使用原子操作,并且提供了一个至少有StoreStore属性的屏障,如果是这种情况,相当于没有操作。 ExitEnter和StoreLoad一样,除非MonitorExit使用了原子指令,并且/或者MonitorEnter至少提供一种屏障,该屏障具有StoreLoad的属性,如果是这种情况,相当于没有操作。

下面这个例子说明如何使用这些指令类型:

class X {
    int a;
    volatile int v;

    void f() {
        int i;
        synchronized (this) { // enter EnterLoad EnterStore
            i = a;// load a
            a = i;// store a
        }// LoadExit StoreExit exit ExitEnter

        synchronized (this) {// enter ExitEnter
            synchronized (this) {// enter
            }// EnterExit exit
        }// ExitExit exit ExitEnter ExitLoad

        i = v;// load v

        synchronized (this) {// LoadEnter enter
        } // exit ExitEnter ExitStore

        v = i; // store v
        synchronized (this) { // StoreEnter enter
        } // EnterExit exit
    }

}

多处理器

通过多处理器所支持的屏障和原子操作

Processor LoadStore LoadLoad StoreStore StoreLoad Datadependencyorders loads? AtomicConditional OtherAtomics Atomicsprovidebarrier?
sparc-TSO 不执行操作 不执行操作 不执行操作 membar(StoreLoad) CAS:casa swap,ldstub 全部
x86 不执行操作 不执行操作 不执行操作 mfence orcpuid orlocked insn CAS:cmpxchg xchg,locked insn 全部
ia64 combine和st.rel 或者ld.acq ld.acq st.rel mf CAS:cmpxchg xchg,fetchadd 部分 +acq/rel
arm dmb dmb dmb-st dmb 只能间接 LL/SC:ldrex/strex   仅针对部分
ppc lwsync lwsync l wsync hwsync 只能间接 LL/SC:ldarx/stwcx   仅针对部分
alpha mb mb wmb mb LL/SC:ldx_l/stx_c   仅针对部分
pa-risc 不执行操作 不执行操作 不执行操作 不执行操作 buildfromldcw ldcw

指南(Recipes)

插入屏障(Inserting Barriers) 当程序执行时碰到了不同类型的存取,就需要屏障指令。编译器不知道指定的load或store指令是先于还是后于需要一个屏障操作的另一个load或store指令。最简单保守的策略是为任一给定的load,store,lock或unlock生成代码时,都假设该类型的存取需要“最重量级”的屏障:

  1. 在每条volatile store指令之前插入一个StoreStore屏障。(在ia64平台上,必须将该屏障及大多数屏障合并成相应的load或store指令。)
  2. 如果一个类包含final字段,在该类每个构造器的全部store指令之后,return指令之前插入一个StoreStore屏障。
  3. 在每条volatile store指令之后插入一条StoreLoad屏障。注意,虽然也可以在每条volatile load指令之前插入一个StoreLoad屏障,但对于使用volatile的典型程序来说则会更慢,因为读操作会大大超过写操作。或者,如果可以的话,将volatile store实现成一条原子指令(例如x86平台上的XCHG),就可以省略这个屏障操作。如果原子指令比StoreLoad屏障成本低,这种方式就更高效。
  4. 在每条volatile load指令之后插入LoadLoad和LoadStore屏障。在持有数据依赖顺序的处理器上,如果下一条存取指令依赖于volatile load出来的值,就不需要插入屏障。特别是,在load一个volatile引用之后,如果后续指令是null检查或load此引用所指对象中的某个字段,此时就无需屏障。
  5. 在每条MonitorEnter指令之前或在每条MonitorExit指令之后插入一个ExitEnter屏障。(根据上面的讨论,如果MonitorExit或MonitorEnter使用了相当于StoreLoad屏障的原子指令,ExitEnter可以是个空操作(no-op)。其余步骤中,其它涉及Enter和Exit的屏障也是如此。)
  6. 在每条MonitorEnter指令之后插入EnterLoad和EnterStore屏障。
  7. 在每条MonitorExit指令之前插入StoreExit和LoadExit屏障。
  8. 如果在未内置支持间接load顺序的处理器上,可在final字段的每条load指令之前插入一个LoadLoad屏障。(此邮件列表和linux数据依赖屏障的描述中讨论了一些替代策略。)

总结:这些屏障中的有一些通常会简化成空操作。实际上,大部分都会简化成空操作,只不过在不同的处理器和锁模式下使用了不同的方式。最简单的例子,在x86或sparc-TSO平台上使用CAS实现锁,仅相当于在volatile store后面放了一个StoreLoad屏障。

移除屏障(Removing Barriers)

上面的保守策略对有些程序来说也许还能接受。volatile的主要性能问题出在与store指令相关的StoreLoad屏障上。这些应当是相对罕见的 —— 将volatile主要用于避免并发程序里读操作中锁的使用,仅当读操作大大超过写操作才会有问题。但是至少能在以下几个方面改进这种策略:

Origina => Transformed
1st ops 2nd => 1st ops 2nd
LoadLoad [no loads] LoadLoad =>   [no loads] LoadLoad
LoadLoad [no loads] StoreLoad =>   [no loads] StoreLoad
StoreStore [no stores] StoreStore =>   [no stores] StoreStore
StoreStore [no stores] StoreLoad =>   [no stores] StoreLoad
StoreLoad [no loads] LoadLoad => StoreLoad [no loads]  
StoreLoad [no stores] StoreStore => StoreLoad [no stores]  
StoreLoad [no volatile loads] StoreLoad =>   [no volatile loads] StoreLoad

重排代码(在允许的范围内)以更进一步移除LoadLoad和LoadStore屏障,这些屏障因处理器维持着数据依赖顺序而不再需要。 移动指令流中屏障的位置以提高调度(scheduling)效率,只要在该屏障被需要的时间内最终仍会在某处执行即可。 移除那些没有多线程依赖而不需要的屏障;例如,某个volatile变量被证实只会对单个线程可见。而且,如果能证明线程仅能对某些特定字段执行store指令或仅能执行load指令,则可以移除这里面使用的屏障。但是所有这些通常都需要作大量的分析。

杂记(Miscellany)

JSR-133也讨论了在更为特殊的情况下可能需要屏障的其它几个问题:

  1. Thread.start()需要屏障来确保该已启动的线程能看到在调用的时刻对调用者可见的所有store的内容。相反,Thread.join()需要屏障来确保调用者能看到正在终止的线程所store的内容。实现Thread.start()和Thread.join()时需要同步,这些屏障通常是通过这些同步来产生的。
  2. static final初始化需要StoreStore屏障,遵守Java类加载和初始化规则的那些机制需要这些屏障。 确保默认的0/null初始字段值时通常需要屏障、同步和/或垃圾收集器里的底层缓存控制。
  3. 在构造器之外或静态初始化器之外神秘设置System.in, System.out和System.err的JVM私有例程需要特别注意,因为它们是JMM final字段规则的遗留的例外情况。
  4. 类似地,JVM内部反序列化设置final字段的代码通常需要一个StoreStore屏障。
  5. 终结方法可能需要屏障(垃圾收集器里)来确保Object.finalize中的代码能看到某个对象不再被引用之前store到该对象所有字段的值。这通常是通过同步来确保的,这些同步用于在reference队列中添加和删除reference。
  6. 调用JNI例程以及从JNI例程中返回可能需要屏障,尽管这看起来是实现方面的一些问题。
  7. 大多数处理器都设计有其它专用于IO和OS操作的同步指令。它们不会直接影响JMM的这些问题,但有可能与IO,类加载以及动态代码的生成紧密相关。