并发编程线程可见性的底层原理

Posted qkxh320

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程线程可见性的底层原理相关的知识,希望对你有一定的参考价值。

一、一段代码引发的思考

??首选,看下面这段代码会输出什么结果?

import java.util.concurrent.TimeUnit;

public class VolatileTest {

//    private static volatile Boolean stop = false;
    private static Boolean stop = false;

    public static void main(String[] args) {

        Thread thread = new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
            System.out.println("result " + i);
            System.out.println(Thread.currentThread().getName() + " 关闭");
        });

        System.out.println(thread.getName() + " 开始运行!");
        thread.start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("主线程休眠结束,启用关闭" + thread.getName() + "线程的开关");
        stop = true;

    }

}

??我们在主线程休眠1s后将子线程用到的stop开关设为了true,这个时候子线程是不是应该停止while循环,输出i的最终结果呢?
??运行结果:

Thread-0 开始运行!
主线程休眠结束,启用关闭Thread-0线程的开关

??最终结果是程序会在后台一直运行.. 这说明了我们在主线程中修改的内容对子线程是不可见的。
??如果我们为stop变量添加关键字volatile修饰,再运行一次,会发现结果可以正常执行。这说明Java中volatile关键字可以使得不同线程之间的数据修改可见。

Thread-0 开始运行!
主线程休眠结束,启用关闭Thread-0线程的开关
result 1448014212
Thread-0 关闭

至于刚开始为什么不可见,后来加上volatile后就可见了,还有从硬件层面的CPU高速缓存和软件层面的JMM内存模型说起。

二、从硬件层面了解可见性本质

1. CPU高速缓存

??一台计算机最核心的组件是CPU、内存、以及IO设备(比如磁盘),这三者CPU处理速度最快、内存次之、最后是IO。在计算机演化的过程中,为了提升性能CPU从单核提升至了多核甚至超线程来提升CPU的处理性能,但是木桶仅仅提升最高的一块板是没有用的,后两者仍存在性能差异。为了平衡三者的速度差异,最大化的利用CPU性能,从硬件、操作系统、编译器等方面都做了很多优化。

1. CPU增加了高速缓存
2. 操作系统增加了进程、线程。通过时间片的切换来最大化的提升CPU的利用效率
3. 编译器的指令优化,更合理的利用CPU的高速缓存

??首先多核CPU会共用一块主内存,但CPU每次处理数据与内存交互时,CPU处理速度很快而内存较慢,该怎么解决这个问题呢?
如果我们在CPU中开辟一片区域来缓存从主内存中拿到的需要处理的数据,在进行数据操作时直接取用,操作完成写入到缓存中去。在整个运算过程完成后,再将数据从CPU高速缓存同步至主内存中。这便是CPU的高速缓存,减少了CPU与主内存的交互,提升了性能。
??通过高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。

2. 缓存一致性

??在引入了CPU高速缓存后,因为每个CPU高速缓存是只针对每个CPU可见的,多核CPU中每个线程又是可以运行在不同的CPU内,这样的话同一份数据可能会被缓存到多个CPU高速缓存中。由于不同CPU高速缓存内修改的数据互相不可见,不同的CPU中运行不同的线程看到的同一份主内存中的缓存不一样,也就有了缓存不一致的问题。
技术图片
??为了解决缓存不一致的问题,CPU层面做了很多事情,主要提供了两种解决方案:总线锁缓存锁
??总线锁
总线锁,简单来说就是:在多CPU下,当其中一个处理器要对主内存的数据进行操作的时候,在总线上发出一个lock信号,使得其它处理器无法通过总线访问到主内存中的数据。这种锁无疑开销比较大,是不合适的。
??缓存锁
针对总线锁的优化就是要控制锁的粒度,只需要保证被多个CPU缓存的同一份数据是一致的就可以。这就引出了缓存一致性协议。

3. 缓存一致性协议

??CPU的高速缓存导致了缓存不一致的问题,为了保证一致性,在进行修改操作时,如果CPU之间互相通知是不就好了。此时就定义了一些协议,需要各个处理器在访问缓存时遵循,在读写时根据这些协议来操作。常见的协议有MSIMESIMOSI等,目的都是一致的。
??以MESI协议为例,MESI表示缓存行的四种状态,分别是

  1. M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改
  3. S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
  4. I(Invalid) 表示缓存已经失效
    ??在MESI 协议中,每个缓存的控制器不仅知道自己的读写操作,而且也监听其它 Cache 的读写操作。比如在某个CPU高速缓存中进行了写操作,需要先通知其它缓存内该数据失效Invalid,再将其自身内标记为Modify等。对于MESI协议,从CPU读写角度来说会遵循以下原则
    CPU读请求:缓存处于 M 、 E 、 S 状态都可以被读取, I 状态 CPU 只能从主存中读取数据
    CPU写请求:缓存处于 M 、 E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写
    技术图片

4. 指令重排序

??MESI协议虽然可以实现缓存的一致性,但是也会存在一些问题。就是各个CPU缓存行的状态是通过消息传递来进行的。如果CPU0要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的CPU,并且要等到他们的确认回执ack。CPU0在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费。在CPU中引入了Store Bufferes。
技术图片
CPU0只需要在写入共享数据时,直接把数据写入到 storebufferes 中 同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他所有CPU发送了 invalidate acknowledge 消息时再将 store bufferes 中的数据存储至 cache line中。最后再从缓存行同步到主内存。
技术图片
但是这种优化存在两个问题

  1. 数据什么时候提交是不确定的,因为需要等待其他cpu给回复才会进行数据同步。这里其实是一个异步操作
  2. 引入了 storebufferes 后,处理器会先尝试从store buffer中读取值,如果store buffer中有数据,则直接从storebuffer 中读取,否则就再从缓存行中读取。

??我们来看一个例子

value = 3;
  
void exeToCPU0() {
  value = 10;  //共享状态,cpu0的写操作,需要通知到其他CPU,为了提升性能异步执行等待收到其它CPU的回执ack后,写入主内存
  isFinish = true;  //独占状态,直接执行写操作并写入主内存
}

void exeToCPU1() {
  if(isFinish) {   // 从主内存中读取到为true
    assert value == 10;  // 从主内存中读取到的是3 
  }
}

??exeToCPU0和exeToCPU1分别在两个独立的CPU上执行。假如CPU0的缓存行中缓存了isFinish这个共享变量,并且状态为(E)独占、而Value可能是(S)共享状态。那么这个时候,CPU0在执行的时候,会先把value=10的指令写入到 store buffer 中。并且通知给其他缓存了该value变量的CPU,这个操作可以看做是异步执行。在等待其他CPU通知结果的时候,CPU0会继续执行isFinish=true这个指令。而因为当前CPU0缓存了isFinish并且是Exclusive状态所以可以直接修改isFinish=true。这个时候CPU1发起read操作去读取isFinish的值可能为true,但是value的值不等于10。这种情况我们可以认为是CPU的乱序执行,也可以认为是一种指令重排序,而这种重排序会带来可见性的问题。
??但是从硬件层面来看,这些代码是我们在软件层面来开发的,硬件层面怎么知道你代码里的前后依赖关系呢?不知道的话就不能直接优化了,于是硬件层面决定提供工具让软件层面自己来决定怎么优化。 所以在CPU层面提供了memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是CPU flush store bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。

5. CPU层面的内存屏障

??什么是内存屏障?
从前面的内容基本能有一个初步的猜想,要防止指令重排序,在单个CPU高速缓存进行写操作时,不仅要通过storebuffer通知,还要立刻在主内存中对这块数据进行标记,内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
技术图片
?? 在硬件层面为代码的先后顺序提供了防止指令重排序的内存屏障后,软件层面Java底层利用这一功能制作了JMM内存模型,使得程序员来自己控制代码运行的先后顺序。

三、软件层面JMM

1. JMM内存模型

??JMM全称是Java Memory Model. 什么是 JMM 呢?
通过前面的分析发现,导致可见性问题的根本原因是缓存以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
JMM 属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了 CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
??JMM抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。
??Java 内存模型底层实现可以简单的认为:通过内存屏障(memory barrier)禁止重排序,届时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的CPU指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。
??需要注意的是,JMM并没有限制CPU的高速缓存以及指令重排序,因为这些本质都是为了提升性能,只是在某些场景不适合使用而已。当我们需要禁用CPU高速缓存或者指令重排序时,其实是依赖于Java中提供给我们的一些关键字来实现的。

2. JMM是如何解决可见性问题的

??简单来说,JMM提供了一些禁用缓存以及禁止重排序的方法,来解决可见性和有序性问题。这些方法大家都很熟悉:volatile 、 synchronized 、 final。

3. JMM是如何解决有序性问题的

??为了提高程序的执行性能,编译器和处理器都会对指令做重排序,其中处理器的重排序在前面已经分析过了。所谓的重排序其实就是指执行的指令顺序。
??编译器的重排序指的是程序编写的指令在编译之后,指令可能会产生重排序来优化程序的执行性能。
从源代码到最终执行的指令,可能会经过三种重排序。2 和 3 属于处理器重排序。这些重排序可能会导致可见性问题。
技术图片
编译器的重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序。
??当然并不是所有的程序都会出现重排序问题编译器的重排序和CPU的重排序的原则一样,会遵守数据依赖性原则,
编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。即如果单线程内代码的执行顺序被打乱,最终结果会不一致,那么就不会重排序:比如下面的代码,

a=1;b=a;
a=1;a=2;
a=b;b=3;

这三种情况在单线程里面如果改变代码的执行顺序,都会导致结果不一致,所以重排序不会对这类的指令做优化。
??再比如下面的代码:

int a = 3;   // 1
int b = 3;   // 2 
int rst = a * b;  // 3

1和3、2和3存在数据依赖,所以在最终执行指令时,1,2必须在3指令前执行;1和2没有互相依赖,所有可以重排序1和2的顺序。

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

四. HappenBefore

??HappenBefore表示的是前一个操作的结果对于后一个操作是可见的。所以我们认为在JMM中,如果一个操作执行的结果对另一个操作可见,那么这两个操作必须存在HappenBefore关系。这两个操作可以使不同的线程,也可以是不同的线程。

JMM中有哪些方法建立了happen-before规则

1. 程序顺序规则

??一个线程中的每个操作,happens-before与该线程中的任意后续操作。单个线程中的代码顺序不论如何改变,对于结果来说是不变的。

Class VolatileTest {
  
  int a = 1;
  volatile boolean flag = false;
  
  public void writer() {
    a = 1;   // 1 
    flag = true;  // 2
  }
  
  public void reader() {
    if(flag) {	// 3
      int i = a;	// 4
    }
  }

} 

根据程序顺序规则: 1 happen-before 2; 3 happen-before 4;

2. volatile变量规则

??对于volatile修饰的变量的写操作,一定 happen-before 后续对于volatile变量的读操作。

Class VolatileTest {
  
  int a = 1;
  volatile boolean flag = false;
  
  public void writer() {
    a = 1;   // 1 
	flag = true;  // 2
  }
  
  public void reader() {
    if(flag) {	// 3
	  int i = a;	// 4
	}
  }

} 

根据volatile变量规则,2 happen-before 3。这里也解释了最开始我们给出的例子,加了volatile子线程可以中断。

3. 传递性规则

??如果 1 happen-before 2,3 happen-before 4,那么根据传递性 1 happen-before 4;

Class VolatileTest {
  
  int a = 1;
  volatile boolean flag = false;
  
  public void writer() {
    a = 1;   // 1 
	flag = true;  // 2
  }
  
  public void reader() {
    if(flag) {	// 3
	  int i = a;	// 4
	}
  }

} 

4. start规则

??如果线程A调用ThreadB.start(),那么线程A中ThreadB.start()之前的操作 happen-before 线程B中的任意操作。

    static int x = 0;

    public static void main(String[] args) {

        Thread t1 = new Thread(()-> {
            // 主线程内调用t1.start()之前的所有操作,在线程t1内都可见
            System.out.println(Thread.currentThread().getName() +  " ----- " + x);

        }, "t1");


        x = 10;

        t1.start();

    }

根据start规则,线程t1内x的值一定为10。

5. join规则

??如果线程A调用ThreadB.join()并成功返回,那么线程B中的任意操作 happen-before 线程A中ThreadB.join()之后的操作

	static int x = 0;

    public static void main(String[] args) {

        Thread t1 = new Thread(()-> {

            x = 20;

        }, "t1");

        x = 10;

        t1.start();

        try {
            t1.join();
	   // t1线程内的所有操作,对主线程调用t1.join()之后的操作都可见
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(Thread.currentThread().getName() + " --------- " + x);

    }

根据start规则和join规则,最终主线程内x值一定为20。

6. 监视器锁的规则

??对一个锁的解锁 happen-before 后续对这个锁的加锁。及第一次解锁前内容一定对下一次加锁中的内容可见。

public class ThreadSynchronized {

    public static void main(String[] args) throws InterruptedException {

        MyLock lock = new MyLock();

        Thread t1 = new Thread(()-> {
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + " ---- " + lock.x);
                lock.x = 5;
            }

        }, "t1");

        Thread t2 = new Thread(()-> {
            synchronized (lock) {

                System.out.println(Thread.currentThread().getName() + " ---- " + lock.x);
                lock.x = 8;
            }
        }, "t2");

        lock.x = 1;
        t2.start();
        t1.start();

    }

}

class MyLock {

    int x;

}

根据监视器锁的规则,如果t1先获得锁,那么x由1改为5;之后t2获得锁,x由5改为8;如果t2先获得锁,那么x由1改为8,之后t1获得锁,x由8改为5;

总结:简单来说完全是一部血泪史,解决了一个问题,又立刻引入了另一个问题。
技术图片

以上是关于并发编程线程可见性的底层原理的主要内容,如果未能解决你的问题,请参考以下文章

voliate怎么保证可见性

第二章 并发机制的底层实现原理

2.Java并发机制的底层实现原理

并发编程艺术-锁类型以及底层原理

java并发编程之一

Java并发编程艺术系列-二Java并发机制底层原理