并发编程——可见性与有序性

Posted 耶瞳

tags:

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

如果有兴趣了解更多相关内容,欢迎来我的个人网站看看:耶瞳空间

JMM即Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。JMM体现在以下几个方面:

  • 原子性:保证指令不会受到线程上下文切换的影响
  • 可见性:保证指令不会受cpu缓存的影响
  • 有序性:保证指令不会受cpu指令并行优化的影响

一:可见性

1.1:问题展示

可以先看下面一段代码:

public class Demo 
  static boolean run = true;

  public static void main(String[] args) 
    new Thread(() -> 
      while(run) 
      System.out.println("停止循环");
    ).start();

    try 
      Thread.sleep(1000);
     catch (InterruptedException e) 
      e.printStackTrace();
    
    System.out.println("停止t");
    run = false;
  

可以看到,即使主线程将run改为了false,子线程还是没有停止循环。

子线程刚开始的时候从主存读取了run的值到工作内存:

因为子线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率:

1秒之后,main线程修改了run的值,并同步至主存,而子线程是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。

1.2:问题解决

我们可以用volatile(易变关键字)解决问题,它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。不过这种方式也降低了程序的运行效率。

除此之外,还可以用synchronized解决该问题:

public class Demo 
  static boolean run = true;

  // 锁对象
  final static Object lock = new Object();

  public static void main(String[] args) 
    new Thread(() -> 
      while(true) 
        synchronized (lock) 
          if (!run) break;
        
      
      System.out.println("停止循环");
    ).start();

    try 
      Thread.sleep(1000);
     catch (InterruptedException e) 
      e.printStackTrace();
    
    System.out.println("停止t");
    synchronized (lock) 
      run = false;
    
  

synchronized实现可见性的原理:

  • 在释放锁之前一定会将数据写回主内存:一旦一个代码块或者方法被synchronized所修饰,那么它执行完毕之后,被锁住的对象所做的任何修改都要在释放之前,从线程内存写回到主内存。也就是说他不会存在线程内存和主内存内容不一致的情况。
  • 在获取锁之后一定从主内存中读取数据:同样的,线程在进入代码块得到锁之后,被锁定的对象的数据也是直接从主内存中读取出来的,由于上一个线程在释放的时候会把修改好的内容回写到主内存,所以线程从主内存中读取到数据一定是最新的。

synchronized与volatile的区别:

  • volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

二:指令重排

JVM会在不影响正确性的前提下,根据情况调整语句的执行顺序。

先看下面的代码,对i和j赋值,先对i赋值再对j赋值其实跟先对j赋值再对i赋值没有区别,所以在真正执行的时候,这两个顺序都有可能。这种特性称之为指令重排。

static int i;
static int j;

// 在某个线程内执行如下赋值操作
i = ...;
j = ...;

指令重排存在的意义,其实说到底都是源于对性能的优化,CPU运行效率相比缓存、内存、硬盘IO之间效率有着指数级的差别,CPU作为系统的宝贵资源,那么如何更好的优化和利用这个资源就能提升整个计算机系统的性能。其实指令重排序就是一种来源于生活的优化思想,比如做菜,一般会把熟得最慢的菜放到最开始(比如煲汤),因为在等待这些菜熟的过程中(IO等待)我们(CPU)还可以做其它事情,这就是一种时间上的优化。在计算机领域也是一样,它也会根据指令的类别做一些优化,目的就是把CPU的资源利用起来,这样就能就能提升整个计计算机的效率。

三种重排序场景

  • 编译器重排序:针对程序代码语而言,编译器可以在不改变单线程程序语义的情况下,可以对代码语句顺序进行调整重新排序。
  • 指令集并行的重排序:这个是针对于CPU指令级别来说的,处理器采用了指令集并行技术来讲多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应的机器指令执行顺序。
  • 内存重排序:因为CPU缓存使用缓冲区的方式(Store Buffere)进行延迟写入,这个过程会造成多个CPU缓存可见性的问题,这种可见性的问题导致结果的对于指令的先后执行显示不一致,从表面结果上来看好像指令的顺序被改变了,内存重排序其实是造成可见性问题的主要原因所在。

指令重排序的原则(as-if-serial语义):编译器和处理指令也并非什么场景都会进行指令重排序的优化,而是会遵循一定的原则,只有在它们认为重排序后不会对程序结果产生影响的时候才会进行重排序的优化,如果重排序会改变程序的结果,那这样的性能优化显然是没有意义的。而遵守as-if-serial语义规则就是重排序的一个原则,as-if-serial 的意思是说,可以允许编译器和处理器进行重排序,但是有一个条件,就是不管怎么重排序都不能改变单线程执行程序的结果。

在复杂的多线程环境下,编译器和处理器是根本无法通过语义分析来知道代码指令的依赖关系的,所以这个问题只有写代码的人才知道,这个时候编写代码的人就需要通过一种方式显示的告诉编译器和处理器哪些地方是存在逻辑依赖的,这些地方不能进行重排序。所以在编译器层面和CPU层面都提供了一套内存屏障来禁止重排序的指令,编码人员需要识别存在数据依赖的地方加上一个内存屏障指令,那么此时计算机将不会对其进行指令优化。

不过因为不同的CPU架构和操作系统都有各自对应的内存屏障指令,为了简化开发人员的工作,避免开发人员需要去了解各种不同的底层的系统原理,所以在JAVA里面封装了一套规范,把这些复杂的指令操作与开发人员隔离开来,这套规范就是我们常说的Java内存模型(JMM),JMM定义了几个happens before原则来指导并发程序编写的正确性。程序员可以通过Volatile、synchronized、final几个关键字告诉编译器和处理器哪些地方是不允许进行重排序的。

三:volatile

内存屏障有两个作用:

  • 阻止屏障两侧的指令重排序
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

内存屏障分为两种,读屏障(Load Barrier)和写屏障(Store Barrier):

  • 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据
  • 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见

java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能:

  • LoadLoad屏障:对于这样的语句Load1;LoadLoad;Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1;StoreStore;Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1;LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence),其内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:

  • 对volatile变量的写指令后会加入写屏障
  • 对volatile变量的读指令前会加入读屏障

写屏障(Store Barrier):保证在该屏障之前的,对共享变量的改动,都同步到主存当中

volatile static boolean ready = false;

public void actor2(I_Result r) 
	num = 2;
	ready = true;  // ready是volatile赋值带写屏障
	// 写屏障

读屏障(Load Barrier):保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

volatile static boolean run = false;

public void actor1(I_Result r) 
	// 读屏障
	// ready是volatile读取值带读屏障
	if (ready) 
		r.r1 = num + num;
	 else 
		r.r1 = 1;
	

由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。

volatile 性能:volatile的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

四:happens-before

happens-before规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。

线程解锁m之前对变量的写,对于接下来对m加锁的其它线程对该变量的读可见:

  static int x;
  static Object m = new Object();
  
  public static void main(String[] args) 
    new Thread(() -> 
      synchronized(m) 
        x = 10;
      
    , "t1").start();

    new Thread(() -> 
      synchronized(m) 
        System.out.println(x);
      
    , "t2").start();
  

线程对volatile变量的写,对接下来其它线程对该变量的读可见:

  volatile static int x;
  
  public static void main(String[] args) 
    new Thread(() -> 
      x = 10;
    , "t1").start();

    new Thread(() -> 
      System.out.println(x);
    , "t2").start();
  

线程start前对变量的写,对该线程开始后对该变量的读可见:

  static int x;
  
  public static void main(String[] args) 
    x = 10;
    
    new Thread(() -> 
      System.out.println(x);
    , "t2").start();
  

线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用t1.isAlive()或t1.join()等待它结束):

  static int x;
  
  public static void main(String[] args) throws InterruptedException 
    Thread t = new Thread(() -> 
      x = 10;
    );
    t.start();
    
    t.join();
    System.out.println(x);
  

线程t1打断t2(interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过t2.interrupted或 t2.isInterrupted):

  static int x;

  public static void main(String[] args) 
    Thread t2 = new Thread(() -> 
      while (true) 
        if (Thread.currentThread().isInterrupted()) 
          System.out.println(x);
          break;
        
      
    , "t2");
    t2.start();

    new Thread(() -> 
      try 
        Thread.sleep(1000);
       catch (InterruptedException e) 
        e.printStackTrace();
      
      x = 10;
      t2.interrupt();
    , "t1").start();

    while (!t2.isInterrupted()) 
      Thread.yield();
    
    System.out.println(x);
  

对变量默认值(0,false,null)的写,对其它线程对该变量的读可见:

  volatile static int x;
  static int y;

  public static void main(String[] args) 
    new Thread(() -> 
      x = 20;
      y = 10;
    , "t1").start();
    
    new Thread(() -> 
      // x=20对t2可见,同时y=10也对t2可见
      System.out.println(x);
      System.out.println(y);
    , "t2").start();
  

以上是关于并发编程——可见性与有序性的主要内容,如果未能解决你的问题,请参考以下文章

二:并发编程之JMM&synchronized&volatile详解

JVM基础学习之基本概念可见性与同步

Java 并发编程线程锁机制 ( 线程安全 | 锁机制 | 类锁 | 对象锁 | 轻量级锁 | 重量级锁 )

Java多线程的volatile底层实现原理

volatile,从JVM的层面解释并发

volatile,从JVM的层面解释并发