如何解决并发可见性和有序性

847 阅读5分钟

如何解决并发可见性和有序性

前言

并发的三大问题分别是可见性、原子性、有序性,其中可见性和有序性问题应该如何解决呢?

目前了解到可见性是因为CPU缓存引起,而有序性是因为编译器优化了程序导致,想要彻底解决两大问题比较粗暴的办法就是禁用CPU缓存以及禁止编译器优化,那么这样直接会导致程序运行速度直线下降,影响程序运行不可取。

那只能按需禁用CPU缓存和编译器优化,至于何时禁用CPU缓存和编译器优化呢?这个只有程序员知道,所以JVM提出JMM(JAVA内存模型)其规范了按需禁用CPU缓存以及编译器优化的规则(Happens-Before规则)和方法(final、synchronized、volatile)。

方法

final

final修饰的变量一旦在构造函数中赋值,且构造函数没有逸出,其他线程就可以看到变量的值。

什么叫做逸出?

逸出指的是对封装性的破坏。比如对一个对象的操作,通过将这个对象的this赋值给一个外部全局变量,使得这个全局变量可以绕过对象的封装接口直接访问对象中的成员,这就是逸出。

 public class FinalTest{
     final int x;
     // 错误的构造函数
     public FinalTest() { 
       x = 12;
       // 将this赋值给全局变量这就是逸出,全局变量可能在其还没初始化完成时调用成员属性改变值
       global.obj = this;
     }
 }

synchronized

管程中的并发原语,可以修饰代码块和方法,保证同一时刻只有一个线程访问资源。

volatile

volatile并不是java中特有的方法,在其他语言中也是存在的如C,其最初语义就是禁用缓存。

如果使用volatile修饰属性volatile int x = 0;那么就是告诉编译器这个变量的读写不使用CPU缓存,直接读取内存中的数据,属性修改完毕也是直接刷新到内存中。

但是有如下代码,线程A调用write方法,线程B中访问到的x值为多少,线程A改变的x的值在线程B中能否访问到?

这时候就要引用Happens-Before 规则来解释。

 class volatileTest{
     int x = 0;
     volatile boolean v= false;
 ​
     public void write(){
         x = 12;
         v = true;
     }
 ​
     public void read(){
         System.out.println(x);
         System.out.println(v);
     }
 }

规则

Happens-Before规则

什么是Happens-Before规则

名字直译就是先行发生,但是其真正要表达的是前面一个操作的结果对于另外一个操作是可见的

六大规则

程序的顺序性规则

指在一个线程中,按照程序顺序,前面的操作Happens-Before后面的操作,如下代码。

 class volatileTest{
     int x = 0;
     volatile boolean v= false;
 ​
     public void write(){
         x = 12;
         v = true;
     }
 }

x=12;会Happense-Before于v=true;这里可能会有误解产生指令重排,x=12会和v=true调换位置,其实不会变量v是被volatile修饰,因为volatile的写操作会在前后插入内存屏障(StoreStore屏障|volatile写|StoreLoad屏障)。

StoreStore屏障:禁止上面的普通写和下面的volatile写重排序。

StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序。

volatile变量规则

指的是对一个volatile变量的写操作Happens-Before于这个volatile变量的读操作。

这个地方就是会强制刷新cpu缓存,它会通知其它CPU读取的该数据作废,需要重新读取,然后锁住的是消息总线,当消息总线被锁定后,其他CPU是无法进行读取内存中的数据的,等待cpu计算完成后,会将计算结果刷新至内存中,最后释放消息总线。

传递性规则

如A Happens-Before B,B Happens-Before C 那么A Happens-Before C。

到这里就能解释为什么上诉例子中非volatile x变量一个线程修改后另外一个线程能够访问。

 class volatileTest{
     int x = 0;
     volatile boolean v= false;
 ​
     public void write(){
         x = 12;
         v = true;
     }
 ​
     public void read(){
         System.out.println(v);
         System.out.println(x);
     }
 }

原因如下:

  1. 根据顺序性线程A执行write方法那么x=12 Happens-Before v=true,x=12对v=true可见。
  2. 根据volatie规则volatile变量的写操作对于volatile变量的读操作可见。那么v=true对于读取v是可见的。
  3. 根据传递性规则x=12对于volatile变量的读操作可见。

image-20220212151810649

这里就是JDK1.5对volatile关键字的增强,其应用场景还包括JUC(java.util.concurrent)并发工具包就是靠这个volatile搞定的可见性。

管程中锁的规则

指一个锁的解锁Happense-Before这个锁的加锁操作,这里特别注意的就是管程是一种通用同步原语,用于多线程互斥访问共享变量的程序结构,java中指的就是synchronized同步原语实现,并不包括Lock

 public class Test{
     int x = 10;
     public void test(){
         synchronized (this) { 
             //此处自动加锁 
             // x是共享变量,初始值=10 
             if (this.x < 12) { 
                 this.x = 12; 
             } 
         } //此处自动解锁
     }
 }

线程A访问拿到初始值x=10,同时获取锁this后将x改为12,解锁成功,引用管程中锁的规则一个锁的解锁对这个锁的加锁可见,那么线程B访问后拿到的锁x的值应该为12。

线程start规则

指主线程A启动子线程B后,子线程B Happens-Before 线程A启动子线程B之前的操作。

 Thread B = new Thread(()->{
   // 此例中,访问共享变量 a==2
 });
 // 共享变量a修改值
 a = 2;
 // 启动子线程
 B.start();

只要在B.start()之前主线程对于共享变量的修改,B的run方法中都能访问到最新值。

线程join规则

指主线程A等待子线程B执行完毕后,子线程B对于共享变量的修改Happens-Before主线程A。

 Thread B = new Thread(()->{ 
     // 此处对共享变量修改 b = 3;
 });
 // 主线程启动子线程
 B.start();
 B.join();
 // 主线程能访问到线程B修改的共享变量值 b==3