Java内存模型(JMM)

线程安全问题

Java多线程编程的基本范式是:面向对象+共享内存。Java对象存储在JVM堆,所有线程共享堆。多线程访问对象的状态(共享变量)时,如果不加同步措施,就会产生线程安全问题。

Java提供了一系列工具帮助我们开发正确的多线程应用,例如锁、Volatile关键字、CAS等等,我们要做的就是根据特定的场景选择最适合的工具。那么首先要明白有哪些线程安全问题存在,以及它们存在的原因。

可见性

可见性是指当一个线程修改了某个共享变量的值时,其他线程能否立即知道这个修改。Java多线程编程存在可见性问题,一个线程修改了共享变量,其它线程可能还会读到它的旧值。可见性问题主要是由内存模型引起的,具体点说是由CPU缓存引起的。

x86架构的CPU有多核,且带有多级缓存,每个核上面有L1、L2缓存;L3缓存为所有核共用。CPU通过缓存一致性协议保证多个CPU之间的缓存同步,即保证内存可见性。

但是,缓存一致性协议对性能有很大损耗,CPU会在这个基础上进行各种优化。例如,在计算单元和L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer)。L1、L2、L3和主内存之间是同步的,由缓存一致性协议保证。但是Store Buffer、Load Buffer和L1之间却是异步的

对于操作系统来讲,多CPU,每个CPU多核,每个核上面可能还有多个硬件线程,就相当于一个个的逻辑CPU,每个逻辑CPU都有自己的缓存。这些缓存和主内存之间不是完全同步的。

具体到Java的编程模型是这样的:共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程已读/写共享变量的副本。本地内存是一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

一般情况下,线程各自为战,只读写本地内存内共享变量的副本,因此线程A对共享变量的修改对线程B不是立即可见的。如果要同步,必须通过主内存,而且要实现一套同步协议,保证数据一致。例如线程A修改共享变量之后,强制回写主内存,并且以某种方式强制线程B重新从主内存读取该变量,这样就实现了可见性。

Java中保证可见性的工具有两种:一种是锁,一种是volatile关键字。

原子性

原子性操作是指不可被中断的操作。从执行线程以外的任何线程来看,原子操作是不可分割的,看不到中间结果。Java中,除long和double以外的任何类型的变量的读写操作都是原子的。非原子性操作也叫原子性问题本质上还是可见性问题,即其它线程可能“看到了不该看的东西”。

CPU使用基于对缓存加锁总线加锁的方式来实现多处理器之间的原子操作。

所谓总线锁就是使用处理器提供的一个LOCK #信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

所谓缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK #信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。Java中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的,所以本质上是缓存锁定。

Java中实现原子性有两种方式,一种是锁,一种是CAS。另外volatile关键字可以保证对long和double类型变量的读写原子性。

java.util.concurrent.atomic包下的原子操作类都是基于CAS实现,其内部又依赖Unsafe类。Java 9以后,推荐用VarHandle替代atomic包和Unsafe的大部分功能。

CAS的三个问题:

  1. ABA问题。自旋CAS的过程是不断检查值有没有变化,所以A->B->A这种变化无法检查出来。AtomicStampedReference通过增加一个版本号检查解决了ABA问题。compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp);
  2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。所以CAS也称为乐观锁,适用于竞争不是很激烈的场景。
  3. 只能保证一个共享变量的原子操作。可以使用AtomicReference类封装多个共享变量,保证它们的原子性。

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。编译器和处理器在重排序时,会遵守数据依赖性规则,不会改变存在数据依赖关系的两个操作的执行顺序。但这都是针对单个线程内的操作而言的,即重排序只保证单线程内的串行语义,即as-if-serial语义:不管怎么重排序,单线程程序的执行结果不能被改变。

除了指令重排序,CPU还有内存重排序,Store Buffer的延迟写入就是内存重排序。因此,一共有三类重排序:

  • 编译器重排序。对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
  • CPU指令重排序。在指令级别,让没有依赖关系的多条指令并行。
  • CPU内存重排序。CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。

第三种重排序会造成内存可见性问题。虽然指令没有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,造成了内存可见性问题。

Java中重排序的细节由happen-before规则定义。

Java内存模型(JMM)与happen-before

多线程编程既要保证正确性,还要保证性能,给开发者带来了很大的挑战。例如,可以给任何访问共享变量的方法加上synchronized关键字保证线程安全,但这种互斥同步方式非常影响性能。

为了平衡开发效率和系统运行效率,引入了Java内存模型(JMM)。JMM明确规范了在多线程场景下,什么时候可以重排序,什么时候不能重排序。在系统运行效率方面,要让编译器和CPU可以灵活地重排序;而在开发效率方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。

为了描述这个规范,JMM引入了happen-before,用于描述两个操作之间的内存可见性。如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:

  • 程序顺序原则:单线程中的每个操作,happen-before对应该线程中任意后续操作(也就是as-if-serial语义保证)。
  • volatile规则:对volatile变量的写入,happen-before对应后续对这个变量的读取,保证了volatile变量的可见性。JSR-133增强了volatile语义,禁止volatile变量的写入和非volatile变量的读取或写入重排序。
  • 锁规则:对锁的解锁,happen-before后续对这个锁的加锁。
  • 传递性:A先于B,B先于C,那么A必然先于C。

除了上述四条核心规则,还有:

  • 线程的start()方法先于它的每一个动作。
  • 线程的所有操作先于线程的终结(Thread.join())。
  • 线程的中断(interrupt())先于被中断线程的代码。
  • 对象的构造函数的执行、结束先于finalize()方法。
  • final关键字:构造函数内部对final域的写,happen-before后续对final域所在对象的读;对final域所在对象的读,happen-before后续对final域的读。保证了final域的赋值,一定在构造函数之前完成,避免了构造函数溢出的问题。

内存屏障

禁止编译器重排序和CPU重排序都是使用内存屏障(Memory Barrier)来实现的。编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。

在理论层面,可以把基本的CPU内存屏障分成四种:

  1. LoadLoad:禁止读和读的重排序。
  2. StoreStore:禁止写和写的重排序。
  3. LoadStore:禁止读和写的重排序。
  4. StoreLoad:禁止写和读的重排序。

但JDK中Unsafe类中提供的内存屏障有点不同。首先是loadFence,相当于LoadLoad+LoadStore,保证该屏障之后的读写不会重排序到该屏障之前的读操作。

 public native void loadFence();

其次是storeFence,相当于StoreStore+LoadStore,保证该屏障之前的读写不会重排序到该屏障之后的写操作。

 public native void storeFence();

最后是fullFence,相当于loadFence+storeFence+StoreLoad,保证该屏障之前的读写不会重排序到该屏障之后的读写。

volatile的内存语义

以下示例线程安全:

class VolatileExample {
    int i = 0;
    volatile boolean flag = false;
    public void write(){
        i = 1;  //1
        flag = true; //2
    }
    public void read(){
        if(flag){  //3
            i = a;  //4
        }
    }
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,这个过程建立的happens-before关系可以分为3类:

  1. 根据程序次序规则,1 happens-before 2;3 happens-before 4
  2. 根据volatile规则,2 happens-before 3
  3. 根据happens-before的传递性规则,1 happens-before 4

因此read()方法中总能读到最新的a,保证了可见性。

从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。

volatile写的内存语义:

当写一个volatile变量时,JMM会把该线程对应的本地内存刷新到主内存。

volatile读的内存语义:

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

结合起来,就可以用volatile变量的写-读实现线程之间的通信:

线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

volatile内存语义的实现

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

  1. 在每个volatile写操作的前面插入一个StoreStore屏障。
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障。
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障。
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

那么,volatile写的过程如下:

volatile读的过程如下:

posted on 2020-08-11 20:56  caffebabe  阅读(191)  评论(0编辑  收藏  举报