并发编程-锁的那些事儿【二:Volatile - 三大特性[原子,有序,可见]】

在上篇并发编程-锁的那些事儿【一:并发的本质-Java内存模型】中了解到Java内存模型的构造,那么接着学习在上述的规则中,如何实现并发安全的; 从这里因为并发的三大特性[原子,有序,可见],可以说这是三个特性,组成了并发编程。 在最后在用Volatile来分析下;

并发三大特性[原子性,有序性,可见性]

  • 原子性: 把一个或者多个操作在 CPU 执行的过程中不被中断的特性;Java内存模型中,直接保证了原子性变量操作【read,load,use,assign,store,wirte】,在应用中,可以大致认定基本类型操作读写具备原子性的,除了【long,double】,如果应用场景需要一个更大范围的原子操作,那么就有lock,unlock来保证。 但这并不是唯一的,比如还提供了,隐式的字节符码指令monitorenter和monitorexit,对应关键字就是synchronized。

  • 可见性: 当一个线程修改了共享变量值后,其他线程能够立即感知这个修改。 那么在上篇提到过,对变量的使用,都是在工作内存中进行,在同步到主内存。 那么怎么能够让其他线程可以里面感知呢? 那对指定的共享变量,直接在主内存进行操作,其他线程在在读取的时,也直接从主内存获取即可。 so一般普通的变量是不具备可见性的, 只有被volatile,synchronize,final修饰的是具备的。 volatitle后续讲。

    synchronize的可见性,是由于在unlock操作结束前,会将变量同步到主内存中,是由store, write这俩个操作提供的。

    final的可见性,是因为在构造器一旦初始化后,没有把this的引用传递出去;

  • 有序性: 在java线程中有一句话: 在本线程内观察,所有的操作都是有序的。 在其他线程内观察其他线程,所有的操作都是无序的。 怎么理解这句话呢?在虚机编译时,为了优化性能,所以编译成功中,将上下没有关联的代码可以不按照顺序进行初始化。这种做法被称为指令重排,例如代码块顺序: int a,int b ,int c; 那么在编译的过程可能是: int c,int a,int b。 这是其一, 其二存在 主内存与工作内存同步延迟的现象。

    那么有序就是指,要么编译的顺序按照代码块的来走,要么把这端代码块编程一个原子操作; 其实也就是volatitle[后续讲]与synchronize的方式。
    synchronize 是有lock操作规定某一时刻,只能有一个线程进行占有。 那么久符合上述说的前半句话,在本线程内观察,所有的操作都是有序的。

    总结下:在了解了三个特性后,发现synchronize是具备了三个特性所有的能力。 导致一度广泛使用。 但万能的同时,也就带来了严重的性能问题,这个在后面synchronize博文中详细说明;

volatitle

千呼万唤始出来,volatitle是Java中轻量级的同步机制。 从上述特性中,可以得知,被volatitle修饰的变量,只具备 有序性,可见性。 那么就逐一来分析下:

可见性:
先来理解下 这句话“volatitle变量在各个线程中是一致的,所以基于volatitle变量的运算,在并发下也是安全的” , 这句话前半段是正确的,但后半段是有问题的。 Java中的运算并非是原子操作,导致volatitle变量运算在并发下也不安全。 举个例子看看:

public class VolatitleTest {
  private long count = 0;
  
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count ++;
    }
  }
  
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行 add() 操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}

每执行一次 add10K() 方法,都会循环 10000 次 count+=1 操作。在 calc() 方法中我们创建了两个线程,每个线程调用一次 add10K() 方法,我们来想一想执行 calc() 方法得到的结果应该是多少呢?

正确答案应该: 20000,因为在单线程里调用两次 add10K() 方法,count 的值就是 20000,但在并发环境中实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢?

因为自运算 count ++并非是安全的,以及count也非valotitle变量;
线程 1 和线程 2 同时开始执行,第一次都会将 count=0 读到各自的 工作内存里,执行完 count+=1 之后,各自 工作内存的值都是 1,同时写入内存后,会发现主内存中是 1,而不是我们期望的 2。之后由于各自的 工作内存里都有了 count 的值,两个线程都是基于 工作内存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

好,那么此时我给count给加上volatitle–>private volatitle long count = 0; 咱直接从主内存进行读写, 在次运行依旧会发现,结果依然不是20000,这是作怪的源头就是自运算 count ++了,为啥呢? 现还是线程 1 和线程 2 同时开始执行,因为使用了volatile,直接从主内存读取。 count ++ = count = count + 1; 这时线程1 和 2 读取到count都是0没问题,但可怕的是,如果线程1 在进行count + 1,线程2已经运算结束,并且把count写入到主内存了,那么此时线程1的中count不就过期了么? 等线程1运算后, 这时主内存的count已经是被线程2运算过得结果了, 结果又被线程1重新同步了,但咱们想要的结果是 2个线程对count的运算操作。 so就能得出一个结论:

运算结果并不依赖变量当前值,或者能保证单线程修改变量的值;
变量不需要与其他的状态变量共同参与不变约束

有序性
volatitle能有有效的禁止指令重排。 前篇也提到过,为了编译的优化,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的问题。例如:

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

这是一个单例模式,使用synchronized,对初始化这步进行lock。 但在并发环境下执行性的话,就可能会出现空指针异常。 让我们来分析:

正常情况: 假如现在有线程1 和 2同时调用getInstance()方法, 按照正常逻辑走,2个线程首次进入时,发现instance均为null,synchronized保证只有一个线程进入 初始化逻辑,这里就说是线程1独占。 那么线程1对instance初始化后,释放lock。 线程2获取lock,发现instance不为null,直接跳出释放lock。

异常情况: 假如线程1先于线程2获取lock,进入初始化逻辑过程中。 因为指令重排,会导致线程1的初始化顺序有些变化,比如:
理想中的情况下:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

指令重排后:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

那么我们知道当把内存的地址赋值后,其实这时Singleton已经!=null, 线程1在执行getInstance()方法时,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。 那我们如何解决这问题呢?很简单做双重锁检查,对使用volatile修饰Singleton。

那volatile是如何做到禁止指令重排呢?–被volatile修饰过得变量,在编译时,会有一个内存屏障存在,在指令重排时,不能把后面的变量重排到内存屏障前面; 要想更加深入了解,那么就得学习计算机原理了,有需求的同学大家可以自己去叭叭。

总结

通过上述我们了解到并发的本质处理方式以及在平常运用的部分实现技术。而且只要我们能够对> 这三大特性有深刻理解,那么我们在并发的路上会越发清晰明了,在分析性能方面的文档会更加如鱼得水。

你可能感兴趣的:(并发编程)