【07】多线程可见性的根源问题和volatile关键字的作用是什么?

【07】多线程可见性的根源问题和volatile关键字的作用是什么?

编码文章call10242025-05-24 11:23:254A+A-


什么是可见性

先看一段代码,下面这段演示了一个使用volatile以及没有使用volatile关键字,对于变量的影响。

public /*volatile*/ static boolean stop = false;

public static void main(String[] args) {
   Thread thread1 = new Thread(() -> {
      int a = 0;
      while (!stop) {
         a++;
      }
      System.out.println("a的值 = " + a);
   }, "thread1");

   Thread thread2 = new Thread(() -> {
      //休息一秒然后修改stop值
      ThreadUtil.sleep(1000L);
      stop = true;
   }, "thread1");

   thread1.start();
   thread2.start();
}

运行发现带有voatile关键字当thread2修改变量后,thread1就会停下。不带有的关键字的不会停下。这就是可见性问题,多线程情况下修改变量对其他线程不可见。

可见性问题是指在多线程环境中,一个线程对共享变量的修改,其他线程不能立即看到。

从硬件层面了解可见性

计算机的核心组件是CPU、内存和I/O设备。它们在处理速度上存在差异,CPU最快,内存次之,I/O设备最慢。大多数程序需要访问内存,有些可能还需要访问I/O设备。

为了提升计算性能,CPU不断升级,从单核到多核甚至采用超线程技术,但仅仅提升CPU性能,如果内存和I/O设备的处理速度没有跟上,整体计算效率会受限于最慢的设备。为了平衡三者的速度差异,最大化利用CPU提升性能,进行了硬件、操作系统和编译器等方面的优化。

这些优化包括:

  1. CPU增加高速缓存,加快数据访问速度。
  2. 操作系统引入进程和线程,通过时间片切换最大化利用CPU。
  3. 编译器进行指令优化,充分利用CPU的高速缓存。

然而,每种优化都会带来相应的问题,其中一些问题导致了线程安全性问题的产生。为了理解可见性问题的本质,我们有必要了解这些优化过程。

CPU高速缓存

线程是CPU调度的最小单元,其设计目的是为了更充分地利用计算机的处理能力。然而,大多数计算任务不能仅依靠处理器进行计算,处理器还需要与内存进行交互,例如读取运算数据和存储运算结果,这些I/O操作很难消除。

由于计算机的存储设备与处理器的运算速度存在很大差距,因此现代计算机系统引入了高速缓存作为内存和处理器之间的缓冲区,以实现读写速度接近处理器运算速度的效果。数据会被复制到缓存中,使得运算可以快速进行,然后在运算结束后将数据从缓存同步回内存中。这样可以减少处理器等待内存的时间,提高计算效率。

cpu高速缓存

引入高速缓存很好的解决了cpu和内存读写的差距,但也提高了计算机的复杂度,也引入了新的问题,缓存一致性。

缓存一致性问题

有了高速缓存之后,cpu处理过程是,先将计算用到的数据缓存到高速缓存中,然后cpu计算,直接从高速缓存中读取然后写入缓存中,在运算结束后,再从高速缓存写入到主内存。在多cpu,多核的情况下,每个cpu都有自己的高速缓存,同一份数据可能被缓存到多个cpu中,导致多个cpu看到的同一个缓存值不一样,就产生了缓存一致性问题。为了解决缓存一致性问题,引入了总线锁缓存锁

总线锁和缓存锁

总线锁就是再多cpu下,其中一个cpu要对共享内存进行操作的时候,在总线上发出一个指令LOCK#,这个指令是的其他的cpu无法通过总线访问主内存,这使得其他cpu在总线锁期间不能操作其他的内存,开销比较大,很显然这种不合适。

优化,就是减小锁的力度,只对需要被保护的缓存加锁,所以就引入了缓存锁,它的核心是缓存一致性协议,锁的是缓存行。

缓存一致性协议

最常见的协议就是EMSI协议。

MESI表示四种状态,分别是:

  • M 修改 (Modified)

描述:该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 监听任务:缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。

  • E 独享、互斥 (Exclusive)

描述:该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 监听任务:缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。

  • S 共享 (Shared)

描述:该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 监听任务:缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。

  • I 无效 (Invalid)

描述:该Cache line无效。 监听任务:无

CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主存中读取数据

CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写使用总线锁和缓存锁机制之后才可以写。

MESI虽然可以实现缓存一致性,但是也会存在一些问题,当一个cpu1要对一个缓存进行写入的时候,首先要发送消息给其他的缓存通知他们失效,并且要等待他们确认回执。这时候cpu1会一直处于阻塞状态,为了避免阻塞引入了Store Buffer, cpu1写入数据到store Buffer,并且发送失效的消息给其他cpu,然后cpu1继续执行其他指令,等到cpu1收到回执消息之后,把store Buffer写到cache line中,最后同步到主内存中。

引入store Buffer依然存在问题:

1.因为是异步操作,所以数据什么时候提交的不确定,需要等到其他cpu确认后才会进行同步。

2.引入store buffer后,处理器会先从store buffer中读取数据,如果没有在去缓存行中读取。

举例:

value = 1;
stop = true;

void cpu1(){
   value = 10;
   stop = false;
}

void cpu2(){
   if(!stop){
      System.out.println(value);
   }
}

两个cpu去运行,假如value是共享,变量stop是独占,cpu1运行到value=10,然后就异步通知其他的cpu失效,cpu1这时继续运行,stop=false,由于是独占直接修改。这时cpu2开始运行,发现stop为false,继续运行,当它开始读取value时候,由于cpu1的消息还没通知到cpu2,有可能还是读取的旧值。

为了解决这个问题,cpu层面提出了内存屏障指令,可以理解为就是将store buffer强制flush到主线上,不异步通知了。

指令重排序的可见性问题

JVM执行程序时可能会对指令进行重排序的主要目的是为了优化程序的性能和执行效率。指令重排序是指在单线程下不改变程序的语义和最终结果的前提下,重新安排指令的执行顺序。

举一个常见的问题,懒加载单例模式下,

public class LazySimpleSingleton {

    private static LazySimpleSingleton lazySimpleSingleton = null;
    int v;

    /**
     * 私有化
     */
    private LazySimpleSingleton() {
        v = 1;
    }

    /**
     * 双重检查
     *
     * @return
     */
    public static LazySimpleSingleton getInstanceDoubleCheck() {
  //将synchronized放到里边,减小锁的范围,提高性能
  //synchronized放到里边就必须用到双重检查锁,防止多个线程争抢创建多个实例.
        if (lazySimpleSingleton == null) {
            synchronized(LazySimpleSingleton.class) {
                if (lazySimpleSingleton == null) {
                    // cpu 执行时候会转换为JVM指令执行
                    // 1: 分配内存给对象
                    // 2: 初始化对象,包括设置对象的默认值
                    // 3: 执行构造函数
                    // 4: 返回对象引用
                    lazySimpleSingleton = new LazySimpleSingleton();
                }
            }
        }
        return lazySimpleSingleton;
    }

}     

new一个对象时候,会执行一下操作 1: 分配内存给对象 2: 初始化对象,包括设置对象的默认值 3: 执行构造函数 4: 返回对象引用

如果在new对象的时候,先返回对象引用,然后在进行初始化和执行构造函数,在单线程情况下没有问题,但是在多线程情况下会存在问题,另外一个线程可能拿到了尚未完全初始化的实例,就会导致问题,就会导致可见性问题。

JMM层面可见性问题

「JMM定义」:Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各 种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。

「JMM内存交互」

JMM内存交互

由于交互操作不是原子的,有可能在一个线程执行,assign,store时候切换上下文,没有立即执行write写回到主内存,导致其他线程读取数据的时候不是最新的数据。

volatile关键字

1.硬件层面上volatile底层用到了内存屏障,也就是store buffer里边通知其他cpu失效指令强刷到主线上。

2.volatile禁止JVM指令重排序。

3.volatile可以让JMM层面上read,load,use和assign,store,write是原子操作,也就是cpu必须连续执行不能切换上下文,在对变量进行写操作后,必须立即将这个更改刷新到主内存中。这样,其他线程在读取这个变量时就能直接从主内存中读取最新的值。

  • 要求在工作内存中,每次使用变量前都必须先从主内存刷新最新的值(固定的 load -> use 顺序),用于保证能看见其他线程对变量所做的修改后的值。
  • 要求在工作内存中,每次修改变量后都必须立刻同步回主内存中(固定的 assign -> store 顺序),用于保证其他线程可以看到当前线程对变量所做的修改。

结合上边说的来看几个问题:

1.volatile变量能否保证原子性?为什么?

volatile能保证JMM交互指令的原子性,不能保证java代码的原子性。

2.volatile变量的写操作和普通变量的写操作有什么区别?

volatile的写操作JMM交互指令会让新数据立即写到主内存,这期间不让cpu切换上下文,也就是原子操作。普通变量有可能被切换上下文,导致没有立即写入主内存。

volatile底层用到了cpu提供的内存屏障,缓存一致性协议MESI中通知其他cpu失效是同步的。而普通对象则是异步的。

3.单例双重检查锁定为什么需要使用volatile关键字?

JVM会指令重排序,保证在单线程的情况下,结果永远一样。但是多线程就不保证了。这就有可能导致问题。平常new一个对象分为四步骤:

1: 分配内存给对象 2: 初始化对象,包括设置对象的默认值 3: 执行构造函数 4: 返回对象引用

指令重排序,多线程的情况下,如果第4步排在第1步后边,导致其他线程拿到了未经过完全初始化的对象,有可能导致程序出现错误。volatile禁止了指令重排序所以不会出问题。

点击这里复制本文地址 以上内容由文彬编程网整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
qrcode

文彬编程网 © All Rights Reserved.  蜀ICP备2024111239号-4