java的多线程的可见性和有序性

124 阅读7分钟

可见性和有序性

可见性和有序性一直是java多线程的一个比较有趣的话题,我们今天来聊一聊

基本概念

首先,让我们来看一下基本的概念:

  • 可见性一个线程对共享变量值的修改,能够及时地被其他线程看到。顾名思义,我让一个值+1,然后其他线程也可以看到
  • 共享变量如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。这主要涉及到JVM的内存模型,在JVM的内存模型中,有专门的地方用来存储当前线程自己拥有的变量
  • 有序性:的是在代码顺序结构中,我们可以直观的指定代码的执行顺序, 即从上到下按序执行。但编译器和CPU处理器会根据自己的决策,对代码的执行顺序进行重新排序。优化指令的执行顺序,提升程序的性能和执行速度,使语句执行顺序发生改变,出现重排序,但最终结果看起来没什么变化(单核)。
  • 有序性问题有序性问题 指的是在多线程环境下(多核),由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。这就是编译器的编译优化给并发编程带来的程序有序性问题

共享变量可见性实现的原理

接着我们来看一看共享变量是如何实现可见性的,我们来看一个例子:

我们现在有两个线程要对一个共享变量进行修改,其中线程1对共享变量的修改要想被线程2及时看到,必须要经过如下两个步骤:

  1. 把工作内存1中更新过的共享变量刷新到主内存
  2. 将主内存中最新的共享变量的值更新到工作内存2

注意:其中,线程对共享变量的操作,遵循以下两条规则

  1. 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写
  2. 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成

java共享变量实现可见性.png

所以,要想实现可见性,必须保证:

  • 线程修改后的共享变量值能够及时从工作内存刷新到主内存中
  • 其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中

实现方案

然后我们来看看java是如何实现可见性的,我们先聊synchronized和volatile

Synchronized

如何实现

基于Moniter实现(不太了解的小伙伴可以先看看《从有点基础开始学JUC:管程(Moniter)

  • 原子性(同步)
  • 可见性

JMM关于synchronized的两条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时,需要从主内存中重新读取最新的值(注意:加锁与解锁需要是同一把锁)

注意:线程解锁前对共享变量的修改在下次加锁时对其他线程可见

执行过程

线程执行互斥代码的过程:

  1. 获得互斥锁
  2. 清空工作内存
  3. 从主内存拷贝变量的最新副本到工作的内存
  4. 执行代码
  5. 将更改后的共享变量的值刷新到主内存
  6. 释放互斥锁

volatile

特点

  • 能够保证volatile变量的可见性
  • 不能保证volatile变量复合操作的原子性

如何实现

深入来说:通过加入内存屏障禁止重排序优化来实现的

  • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令
  • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令

线程写volatile变量的过程:

  1. 改变线程工作内存中volatile变量副本的值
  2. 将改变后的副本的值从工作内存刷新到主内存

线程读volatile变量的过程:

  1. 从主内存中读取volatile变量的最新值到线程的工作内存中
  2. 从工作内存中读取volatile变量的副本

区别

  • volatile不需要加锁,比synchronized更轻量级,不会阻塞线程;
  • 从内存可见性角度,volatile读相当于加锁,volatile写相当于解锁
  • synchronized既能够保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性。

重排序

最后,让我们来聊一聊重排序的概念

基本概念

代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化

  1. 编译器优化的重排序(编译器优化)
  2. 指令级并行重排序(处理器优化)
  3. 内存系统的重排序(处理器优化)

注意:synchronized 是不能保证指令重排的

问题

以双重校验锁(dcl)的单例为例子,为什么要给变量加volatile

首先我们来看一下没有加volatileDCL(双重检查锁)

public class Singleton {  
    private static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
        if (singleton == null) {  
            synchronized (Singleton.class) {  
                if (singleton == null) {  
                    singleton = new Singleton();  
                }  
            }  
        }  
        return singleton;  
    }  
}  

关键在于创建对象的步骤

singleton = new Singleton() 

这一步做的事可以简化为3步:

  • ①JVM为对象分配一块内存M。
  • ②在内存M上为对象进行初始化。
  • ③将内存M的地址复制给singleton变量。

这个步骤有两种执行顺序可以按照 ①②③或者①③②来执行。当我们按照①③②的顺序来执行的时候我们假设有两个线程ThreadA 和ThreadB 同时来请求Singleton.getSingleton()方法:

如果对象初始化的时候按照 ①③② 的步骤我们再来看看:

  1. 第一步: ThreadA进入到第8行,执行 singleton = new Singleton() 执行完.①JVM为对象分配一块内存M。③将内存的地址复制给singleton变量。
  2. 第二步: 此时ThreadB直接进入第5行,发现singleton已经不为空了然后直接就跳转到12行拿到这个singleton返回去执行操作去了。此时ThreadB拿到的singleton对象是个半成品对象,因为还没有为这个对象进行初始化(②还没执行)。
  3. 第三步: 所以ThreadB拿到的对象去执行方法可能会有异常产生。至于为什么会这样列?原因是线程可能看到引用的当前值,但对象的状态值确少失效的,这意味着线程可以看到对象处于无效或错误的状态,ThreadB是拿到了一个引用已经有了但是内存资源还没有分配的对象。

深入理解Java虚拟机第三版》有提到

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内似表现为串行的语义”,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。

所以我们可以有以下结论:

  • synchronized 的有序性是持有相同锁的两个同步块只能串行的进入,即被加锁的内容要按照顺序被多个线程执行,但是其内部的同步代码还是会发生重排序,使块与块之间有序可见。
  • volatile的有序性是通过插入内存屏障来保证指令按照顺序执行。不会存在后面的指令跑到前面的指令之前来执行。是保证编译器优化的时候不会让指令乱序。
  • synchronized 是不能保证指令重排的。

这就是这篇文章的内容了,欢迎大家的讨论,如有错漏,也请指出,谢谢~