volatile为什么能解决可见性和有序性问题

volatile为什么能解决可见性和有序性问题

1.关于线程可见性的问题分析

public class VolatileExample {

    public static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            int i = 0;
            while (!stop){ // main方法改过的stop变量对t1线程不可见
                i++;
            }
        });

        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop = true; // 更改了stop变量 

    }
}

1.1思考导致问题的原因

上述问题只在Server Compiler存在,Server Compiler编译器主要用于 无用代码消除(Dead Code Elimination) 循环展开(Loop Unrolling)循环表达式外提(Loop Expressing Hoisting)消除公共子表达式(Common Subexpression Elimination)等

Thread t1 = new Thread(()->{
            int i = 0;
           	if(!stop){
                while(true){
                   i++;  
                }
            }
        });

为了防止JIT(Just In Time Compiler —即时编译器)优化产生的问题,需要添加一个JVM的参数:

-Djava.compiler=NONE  // 不建议使用

为了禁止关闭JIT优化对全局代码的影响,推荐使用volatile关键字来解决可见性和有序性问题:

public volatile static boolean stop = false;

2.深入理解可见性问题的本质

实际上,除了编译器优化带来的可见性问题,还有许多因素会导致可见性的问题,比如CPU的高速缓存,CPU的指令重排序等

2.1 如何最大化提升CPU的利用率

CPU处理和内存I/O之间的速度差异瓶颈被称为冯·诺依曼瓶颈
在这里插入图片描述

为了解决冯·诺依曼瓶颈的问题,开发者在硬件设备、操作系统及编译器层面上做了很多的优化:

  • 在CPU层面上增加了寄存器,来保存一些关键变量和临时数据,还增加了CPU的高速缓存,以减少CPU和内存的I/O等待时间
  • 在操作系统层面上引入了进程和线程,也就是说在当前进程和线程处于阻塞状态时,CUP会把自己的时间片分配给别的线程或者进程使用,从而减少CPU的空闲时间,最大化的提升CPU的使用率
  • 在编译器层面上增加指令优化,减少与内存的交互次数

2.2 祥述CPU高速缓存

CPU高速缓存,用于存储与内存交互的数据。CUP在做读操作时,会从高速缓存中读取目标数据,如果目标数据不存在,就从内存中加载目标数据并保存到高速缓存中,在返回给处理器。
在这里插入图片描述

在这里插入图片描述

CPU的高速缓存为:L1和L2缓存是CPU核内的缓存,属于CPU私有的。L3是跨CPU核心共享的缓存。L1缓存又分为L1D一级数据缓存,L1I一级指令缓存。这三个访问速度L1>L2>L3

  • L1是CPU硬件上的一块缓存,分别用来存储数据缓存和指令缓存,容量最小但是速度最快,容量一般是在256KB左右,好一点的CPU能达到1MB以上
  • L2也是CPU硬件上的一块缓存,相比L1缓存来说,容量会大一点,但是速度相对来说会慢,容量通常在256KB到8MB之间
  • L3是CPU高速缓存中最大的一块,访问速度最慢的缓存,他的容量在4MB到50MB之间,它是所有CPU核心缓存的一块缓存
2.2.1 关于缓存行的实现

CPU的高速缓存是由若干缓存行组成的,缓存行是CPU高速缓存的最小存储单元,也是CPU和内存交换数据的最小单元。在x86架构中,每个缓存行大小为64位,即8字节,CPU没每次从内存中加载8字节作为一个缓存行存储到高速缓存中,这意味着存放的是连续位置的数据,这是基于空间局部性原理实现的。

空间局部性原理指,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用,这种缓存行读取的方式能减少与内存中的交互次数,提升CPU利用率,从而节省CPU读取数据的时间。

2.2.2 缓存行导致的伪共享问题

多个线程同时访问一个缓存行的数据,如果对数据中其中一部分进行更改,为了保证缓存一致性,会不断地令缓存失效,并重新加载到高速缓存。如果线程竞争非常强烈,就会导致缓存频繁失效,这就是典型的伪共享的问题

2.2.3 如何去解决缓存行导致的伪共享问题

通过牺牲空间换取时间(空间换时间概念)引出对其填充解决缓存行导致的伪共享问题,jdk1.8提供@Contended注解(在JVM运行参数添加:-XX:-RestrictContended)。

2.3 CPU缓存一致性问题

CPU高速缓存的设计极大的提升了CPU的运算性能,但是存在一个问题:在CPU的L1和L2缓存是CPU私有的,如果两个线程同时加载同一块数据并保存到高速缓存中,再分别进行修改,如何保证缓存的一致性?

2.3.1 总线锁和缓存锁机制

为了解决缓存一致性问题,硬件开发者在CPU层面引入了总线锁和缓存锁机制

  • 总线:就是CPU与内存,输入/输出的设备传递信息的公共通道(前端总线),当CPU访问内存进行数据交互时,必须通过总线来传输。
  • 总线锁:总线锁就是在总线上声明一个Lock#信号,这个信号能够保证共享变量只有当前CPU可以访问,其他的处理器请求就会被阻塞,这使得同一时刻只有一个处理能够访问共享内存,从而解决了缓存不一致的问题,但是导致CPU的利用率直线下降,所以从P6系列的处理器开始增加了缓存锁的机制。
  • 缓存锁:如果当前CPU访问的数据已经缓存在其他CPU的高速缓存中,那么CPU不会在总线上声明Lock#信号,而是采用缓存一致性协议来保证多个CPU的缓存一致性

CPU最终使用那种锁来解决缓存一致性问题取决于当前CPU是否支持缓存锁,如果不支持就会采用总线锁。另外一种情况是当前操作的数据不能被缓存在处理器内部或者操作的数据跨多个缓存行时,也会使用总线锁。

2.3.2 缓存一致性协议

缓存锁通过缓存一致性协议保证缓存的一致性,不同的CPU类型支持的缓存一致性协议也不一样,比如MSI、MESI、MOSI、MESIF协议等,比较常见的是MESI(Modified Exclusive Shared or Invalid)

具体来说,MESI协议表示缓存行的四种状态,分别是:

  • M(Modify),表示共享数据只缓存在当前的CPU缓存中,并且是被修改状态,缓存的数据和主内存中的数据不一致
  • E(Exclusive),表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  • S(Shared),表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
  • I(Invalid),表示缓存已经失效

这四种状态会基于CPU对缓存行的操作而产生转移,所以MESI协议针对于不同的状态添加了不同的监听任务:

  • 如果一个缓存行处于M状态,则必须监听所有视图读取该缓存行对应的主内存地址的操作,如果监听到有这类操作发生,则必须在该操作执行之前把缓存行的数据写回主内存中
  • 如果一个缓存行处于S状态,那么它必须监听使该缓存行状态设置为Invalid或者对缓存行执行Exclusive操作的请求,如果存在,则必须要把当前缓存行状态设置为Invalid。
  • 如果一个缓存行处于E状态,那么它必须要监听其他视图读取该缓存行对应的主内存地址的操作,一旦有这种操作,那么该缓存行需要设置为Shared。

这个监听使基于CPU中的Snoopy嗅探协议完成的,该协议要求每个CPU的缓存都可以监听到总线锁上的数据事件并做出相关反应。

2.4 总结可见性问题的本质

为了降低冯·诺依曼瓶颈带来的影响,设计了高速缓存导致了缓存一致性问题,为了解决缓存一致性问题,设计了总线锁和缓存锁。

总线锁和缓存锁是通过Lock#信号触发,如果当前CPU不支持缓存锁则使用总线锁,如果支持则基于缓存一致性协议使用缓存锁保证了缓存一致性。

3.volatile如何解决可见性问题?

添加了volatile关键字之后在汇编指令上会给修改指令前面加上一个Lock#信号,这使得基于缓存锁/总线锁的方式达到一致,从而保证了结果可见

除了高速缓存带来的可见性问题,指令重排序也会导致可见性问题

4.指令重排序导致的可见性问题

什么是指令重排序?

public class MemoryReorderingExample {

    private static int x=0,y=0;
    private static int a=0,b=0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;

        while (true){
            i++;
            x=0;y=0;
            a=0;b=0;
            Thread t1 = new Thread(()->{
                a=1;
                x=b;
            });

            Thread t2 = new Thread(()->{
                b=1;
                y=a;
            });

            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("第"+i+"次("+x+","+y+")");
            if (0==x&&0==y){
                System.out.println("第"+i+"次("+x+","+y+")");
                break;
            }

        }
    }

}

结果显示:

2782228(0,1)2782229(0,1)2782230(0,1)2782231(0,1)2782232(0,1)2782233(0,1)2782234(0,1)2782235(0,1)2782236(0,1)2782237(0,1)2782238(0,1)2782239(0,1)2782240(0,1)2782241(0,1)2782242(0,1)
// 在运行287万次左右时出现了0,0的结果    2782243(0,0)2782243(0,0)

如何才能出现这种结果?假设上述代码通过指令重排序,才会编程下面的这种结果。

            Thread t1 = new Thread(()->{
                x=b; // 指令重排序
                a=1;
                
            });

            Thread t2 = new Thread(()->{
                y=a;// 指令重排序
                b=1;
               
            });

4.1 什么是指令重排序

指令重排序是指编译器或CPU为了优化程序的执行性能而对指令进行重新排序的一种手段,重排序会带来可见性问题。

从源码到最终执行的指令,一共会经历两个阶段的重排序

第一个阶段,编译器重排序,主要减少CPU和内存的交互

第二个阶段,处理器重排序,处理器重排序分为两个部分:

  • 并行指令集重排序,这个是处理器优化的一种,处理器可以改变指令的执行顺序
  • 内存系统重排序,这是处理器引入Store Buffer缓冲区延时写入产生的指令执行顺序不一致的问题

并行指令集:在处理器内核中一般会有多个执行单元,比如算术执行单元,位移执行单元等。在引入并行指令集之前,CPU在每个时钟周期内只能执行单条指令,也就是说只有一个执行单元在工作,其他执行单元处于空闲状态;在引入并行指令集之后,CPU在一个时钟周期内可以同时分配多条指令在不同的执行单元中执行

4.2 as-if-serial(as-if-serial:好像是连续的)语义

as-if-serial表示所有的程序指令都可以因为优化而被重排序,但是在优化的过程中需要保证实在单线程环境下,重排序之后的运行结果和程序代码本身预期的执行结果一致,Java编译器,CPU指令重排序都需要保证在单线程环境下的as-if-serial语义是正确的。

JSR-133规范中

The compiler, runtime, and hardware are supposed to conspire to create the illusion of as-if-serial semantics, which means that in a single-threaded program, the program should not be able to observe the effects of reorderings. However, reorderings can come into play in incorrectly synchronized multithreaded programs, where one thread is able to observe the effects of other threads, and may be able to detect that variable accesses become visible to other threads in a different order than executed or specified in the program.

as-if-serial语义运行重排序,CPU层面的指令优化依然存在。在单线程中,这些优化不会影响整体的执行结果,在多线程中,重排序还是会带来可见性问题。

5.从CPU(Central-Processing Unit)层面深度剖析指令重排序的本质

CPU通过引入高速缓存来提升利用率,并且基于缓存一致性协议来保证缓存的一致性,但是缓存一致性又会影响CPU的使用率

假设存在一个S状态的缓存行,CPU0对缓存行进行修改,CPU0需要发送一个Invalidate消息到CPU1,在等待CPU1返回Acknowledgement(收到告知)消息之前,CPU0一直处于空闲状态。

为了减少这种缓存一致性协议带来的CPU闲置问题,硬件工程师在CPU层面上设计了一个Store Buffers。

5.1CPU优化——StoreBuffers(存储缓冲区)

StoreBuffer的出现是为了防止缓存一致性协议导致的不必要的CPU阻塞,所以在每个CPU缓存行之间增加了一个Store Buffers

a = 1;
b = a + 1;
assert(2 == b)

根据上述例子,只考虑Store Buffer的情况下。会导致软件的实际执行结果与预期执行结果不一致的现象,所以引入了Store Forwarding机制

5.2CUP优化——Store Forwarding

Store Forwarding是指每个CPU在加载数据之前,会先引用CPU的Store Buffers,也就是说支持将CPU存入Store Buffers的数据传递给后续的加载操作,而不需要经过Cache。

// a 存在CPU1的高速缓存中   b存在CPU0的高速缓存中  都是独占状态

int a = 0,b=0;

executeToCPU0(){
    a = 1;
    b = 1;
}

executeToCPU1(){
    while(1 == b){
        assert(1 == a);
    }
}

Store Forwarding会导致Read操作的指令重排序,Read操作重排序之后,在多线程环境下还是会产生可见性问题

5.3CPU优化——Invalidate Queues

增加了Invalidate Queues的优化之后,CPU发出的Invalidate消息能够很快得到其他CPU发送的Invalidate Acknowledge消息,从而加快了StoreBuffers中指令的处理效率,减少了CPU因此导致的阻塞问题。但是Invalidate Queues存在会导致CPU内存系统的Write操作的重排序问题

// a在CPU0,CPU1中均为Shared状态,b在CPU0中属于Exclusive状态,CPU0执行executeToCPU0,CPU1执行executeToCPU1
int a = 0,b=0;

executeToCPU0(){
    a = 1;
    b = 1;
}

executeToCPU1(){
    while(1 == b){
        assert(1 == a);
    }
}

断言失败的原因是:CPU1在读取a的缓存行时,没有先处理Invalidate Queues中的缓存行的失效操作。Invalidate Queues的优化和Store Buffer的优化会分别到来Store和Load指令的内存系统重排序,最终导致可见性问题。

6.通过内存屏障解决内存系统重排序问题

6.1内存屏障详解

大多数的处理器都会提供以下内存屏障指令,在x86指令中的内存屏障如下:

  • 读屏障指令(lfence(全小写)),将Invalidate Queues中的指令立即处理,并且强制读取CPU的缓存行。执行lfence指令之后的读操作不会被重排序到执行lfence指令之前。这意味着其他CPU暴漏出来的缓存行状态对当前CPU可见
  • 写屏障指令(sfence),他会把Store Buffers中的修改刷新到本地缓存中,使得其他CPU能够看到这些修改,而且在执行sfence指令之后的写操作不会被重排序到执行sfence指令之前,这意味着执行sfence指令之前的写操作一定要全局可见(内存可见性及禁止重排序)
  • 读写屏障指令(mfence),相当于Ifence和sfence的混合体,保证mfence指令执行前后的读写操作的顺序,同时要求执行mfence指令之后的写操作的结果全局可见,执行mfence指令之前的写操作度全局可见

7.Java Memory Mode

在多线程环境中导致可见性问题的根本原因是CPU的高速缓存及指令重排序,虽然CPU层面上提供了内存屏障及锁的机制来保证有序性,然而在不同的CPU类型中,又存在不同的内存屏障指令。Java最为一种跨平台语言,必须针对不同的底层操作系统和硬件提供统一的线程安全性保障,而Java Memory Mode就是这样一个模型。

Java Memory Mode也是一种规范,该规范定义了线程和主内存之间访问规则的抽象关系。

  • 每一个线程有一个用来存储数据的工作内存(CPU寄存器/高速缓存的抽象),工作内存保存主内存(共享内存)中的变量副本,现成对所有变量的操作都是在工作内存中完成的
  • 每个线程之间的工作内存是相互隔离的,数据的变量需要通过主内存来完成
  • 所有变量都是存储在主内存中。

注意:Java Memory Mode和JVM的内存结构不是一样的,Java Memory Mode并不像JVM的内存结构一样真实存在,它只是描述了一个线程对共享变了的写操作何时对另外一个线程可见。在JSR-133: JavaTM Memory Model and Thread Specification规范中的描述如下:

A high level, informal overview of the memory model shows it to be a set of rules for when writes by one thread are visible to another thread. Informally, a read r can usually see the value of any write w such that w does not happen-after r and w is not seen to be overwritten by another
write w 0 (from r’s perspective).

简单翻译:如果没有一个统一的Java Memory Mode规范,那么不同的JVM实现可能导致同一个程序的不正确性

7.1从JVM和硬件层面理解Java Memory Mode

public class JvmExample {


    private int count;

    public static void main(String[] args) throws InterruptedException {
        JvmExample example = new JvmExample();
        
        Thread t1 = new Thread(new RunnerTask(example),"t1");
        Thread t2 = new Thread(new RunnerTask(example),"t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        

    }

    static class RunnerTask implements Runnable{

        private  JvmExample jvmExample;

        public RunnerTask(JvmExample jvmExample) {
            this.jvmExample = jvmExample;
        }



        @Override
        public void run() {

            for (int i = 0; i < 1_000_000; i++) {
                jvmExample.count++;
            }

            System.out.println(Thread.currentThread().getName()+":"+jvmExample.count);

        }
    }

}
  • 现成对共享变量修改的可见性问题(CPU高速缓存/指令重排序)
  • count++指令在竞争情况下的原子性问题
    在这里插入图片描述

在这里插入图片描述

为了针对于不同的操作系统和硬件提供统一的线程安全性保障。JSR133中定义了新的Java Memory Mode 规范,原因有如下两个:

  • 为了在不同的JVM实现上,都保证唯一的可见性保障
  • 为了让开发者知道在什么情况下存在可见性问题,以及如何不保证程序的正确执行

7.2 JVM提供的内存屏障指令

JVM中定义了禁止指令重排序的方法及监视器锁的规则解决了原子性、可见性、有序性问题

导致指令重排序的问题有编译器重排序、CPU内存系统重排序和CPU并行指令,JVM提供内存屏障方法来解决这些问题

屏障类型示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2确保在执行Load2及后续加载指令访问数据之前,先加载Load1的数据,也就是说Load1和Load2不允许重排序
StoreStore BarriersStore1;StoreStore;Store2确保在执行Store2及后续存储指令之前,Store1的数据对其他处理器可见,也就是说把Store1的数据刷新到内存中
LoadStore BarriersLoad1;LoadStore;Store2确保在执行Store2及后续存储指令被刷新之前,Load1的数据先被加载
StoreLoad BarriersStore1;StoreLoad;Load2确保在执行Load2访问数据及加载后续所有Load指令之前,Store1的数据对
其他处理器可见,也就是把Store1的数据刷新到内存中

在Linux系统的X86架构的CPU中对访问顺序的具体实现

inline void OrderAccess::loadload()   { acquire(); }
inline void OrderAccess::storestore() { release(); }
inline void OrderAccess::loadstore()  { acquire(); }
inline void OrderAccess::storeload()  { fence(); }

inline void OrderAccess::acquire() {
  volatile intptr_t local_dummy;
#ifdef AMD64
  __asm__ volatile ("movq 0(%%rsp), %0" : "=r" (local_dummy) : : "memory");
#else
  __asm__ volatile ("movl 0(%%esp),%0" : "=r" (local_dummy) : : "memory");
#endif // AMD64
}

inline void OrderAccess::release() {
  // Avoid hitting the same cache-line from
  // different threads.
  volatile jint local_dummy = 0;
}

inline void OrderAccess::fence() {
   // 如果是多核
  if (os::is_MP()) {
    
#ifdef AMD64
    // __asm__:用于指示编译器在此插入汇编语句
   // volatile 禁止编译器的指令重排序
   // lock:有2个作用,第一解决缓存一致性问题。第二实现内存屏障
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

总的来说,fence()方法通过编译器层面的防止重排序指令volatile及CPU提供的内存屏障指令来彻底解决指令重排序导致的可见性问题

8.揭秘volatile实现原理

// 不加volatile 字节码如下
public static boolean stop;
descriptor: Z
flags: ACC_PUBLIC, ACC_STATIC


// 加volatile 字节码如下
public static volatile boolean stop;
descriptor: Z
flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE


bool is_volatile    () const         { return (_flags & JVM_ACC_VOLATILE    ) != 0; }
int field_offset = cache->f2_as_index();
          if (cache->is_volatile()) {
            if (tos_type == itos) {
              obj->release_int_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == atos) {
              VERIFY_OOP(STACK_OBJECT(-1));
              obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
              OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
            } else if (tos_type == btos) {
              obj->release_byte_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ltos) {
              obj->release_long_field_put(field_offset, STACK_LONG(-1));
            } else if (tos_type == ctos) {
              obj->release_char_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == stos) {
              obj->release_short_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ftos) {
              obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
            } else {
              obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
            }
            OrderAccess::storeload();
          } else {
            if (tos_type == itos) {
              obj->int_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == atos) {
              VERIFY_OOP(STACK_OBJECT(-1));
              obj->obj_field_put(field_offset, STACK_OBJECT(-1));
              OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
            } else if (tos_type == btos) {
              obj->byte_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ltos) {
              obj->long_field_put(field_offset, STACK_LONG(-1));
            } else if (tos_type == ctos) {
              obj->char_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == stos) {
              obj->short_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ftos) {
              obj->float_field_put(field_offset, STACK_FLOAT(-1));
            } else {
              obj->double_field_put(field_offset, STACK_DOUBLE(-1));
            }
          }

          UPDATE_PC_AND_TOS_AND_CONTINUE(3, count);
        }

9.Happens-Before模型

Javase 8 的官方文档中原文描述

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.

If we have two actions x and y, we write hb(x, y) to indicate that x happens-before y.

介绍一下常见的Happens-Before规则

9.1程序顺序规则

int a = 2;
int b = 2
int c = a+b;

9.2传递性规则

9.3volatile变量规则

在这里插入图片描述

9.4监视器规则

9.5.start规则

public class StartRuleExample {

    public static void main(String[] args) {
        int x = 0;
        Thread t1 = new Thread(()->{
            if (x==10){
                System.out.println("done");
            }
        });

        x=10;
        t1.start();
    }
}

9.6.join规则

对象终结规则、初始化规则,final规则

  • 19
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

DougLiang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值