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

一、一段代码引发的思考

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

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 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
  X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)
Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(storebufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

   在硬件层面为代码的先后顺序提供了防止指令重排序的内存屏障后,软件层面Java底层利用这一功能制作了JMM内存模型,使得程序员来自己控制代码运行的先后顺序。

三、软件层面JMM

1. JMM内存模型

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

2. JMM是如何解决可见性和有序性的

  其实通过前面的内容分析我们发现,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?
其实前面在分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、synchronized、final等关键字,使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。

  从源代码到最终执行的指令,可能会经过三种重排序。2 和 3 属于处理器重排序。这些重排序可能会导致可见性问题。

编译器的重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序。
  当然并不是所有的程序都会出现重排序问题编译器的重排序和CPU的重排序的原则一样,会遵守数据依赖性原则,下面会提到的Happens-Before模型。

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

四. Happens-Before模型

  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;

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

五、DCL半对象问题

  最后,有了指令重排序的问题,可以再来看下单例模式中的DCL(Double Check Lock)双重检查所问题。

public class DCLDemo {

    private static DCLDemo instance = null;

    private DCLDemo() {
    }

    public static DCLDemo getInstance() {
        if (null == instance) {
            synchronized (DCLDemo.class) {
                if (null == instance) {
                    instance = new DCLDemo();
                }
            }
        }
        return instance;
    }

}

  以上代码在多线程并发的情况下,是否会存在问题?
如果仅仅从多线程并发的角度来说,本身加了类锁,不存在线程安全问题。但是结合这篇blog提到的处理器底层指令重排序问题,就能看出一些问题了。下面我们分享一下。
  首先, instance = new DCLDemo();本身是由多条指令构成的:

  // 创建对象并赋值
  instance = new DCLDemo();

  // 上面这行代码翻译为伪指令可以视为:
  memory = allocate();  	// 1.分配对象的内存空间
  constructInstance(memory);	// 2.初始化对象
  instance = memory;		// 3.设置instance指向刚分配的内存  

  这三条指令中,首先需要分配内存,1 HappenBefore 2,1 HappenBefore 3,但是2和3指令互不依赖,先后顺序可能会重排。即最终执行顺序为 1-2-3,也可能是1-3-2。
  当在单线程中执行这个单例模式不会有问题,当是多线程环境下,就可能会有半对象问题。即:

  1. 线程A先访问,判断instance为空 -> 加锁 -> 为空 -> 创建对象[执行指令1 -> 执行指令3 ]
  2. 此时另一线程B访问,因为线程A已经执行了指令3,instance已经指向了一块内存,所以判断 instance不为空,直接返回了instance。
  3. 之后线程A继续 -> 执行指令2 初始化对象。
      这样的话线程B获取到的instance只是指向了一块内存,但并没有真正初始化对象,所以线程B返回的对象其实是没有初始化完成、不完整的对象。这也就是DCL(Double Check Lock)双重检查锁的半对象问题。
    解决方法也就是禁止instance写操作的指令重排序,加上volatile关键字。
private static volatile DCLDemo instance = null;

  合理的双重检查锁单例模式:

public class DCLDemo {

    private static volatile DCLDemo instance = null;

    private DCLDemo() {
    }

    public static DCLDemo getInstance() {
        if (null == instance) {
            synchronized (DCLDemo.class) {
                if (null == instance) {
                    instance = new DCLDemo();
                }
            }
        }
        return instance;
    }

}
posted @ 2019-07-08 14:16  BigShen  阅读(287)  评论(0编辑  收藏  举报