【Java学习笔记-并发编程】关键字 volatile 详解

前言

本文介绍 Java 并发编程的关键字 volatile 以及其相关原理,和一些相关的计算机基础知识。

一、项目背景

最近项目中,写到了很多多线程的东西。其中有个多任务轻型阻断器的实现,牵出了 volatile 关键词的应用,顺便学习一下原理和相关的基础知识。

因为多任务是多线程执行的,所以多任务的阻断,自然就想到了 线程间的通信。 所以需要线程内的一些变量具有 可见性

说到线程可见性,对于 Java 而言,有两种实现方法:volatile 与 synchronize 。

volatile仅仅用来保证该变量对所有线程的可见性,但不保证原子性

但由于 synchronize 这个锁太重了,只允许一个拿到锁的线程执行任务,所以我们选择 volatile 来实现这个中断器。

二、volatile 实现原理

首先,从计算机的底层内存模型来复习一下,为什么线程之间都是不可见的。

1. 线程之间为何不可见?

计算机内存模型

现在的 CPU 运行速度是远远快于内存的读写速度,所以为了保证充分的让 cpu 计算起来,我们使用不同读写速度的介质,结合实际的应用情况,来组成计算机。其中,CPU 拥有最顶层的 4 层缓存结构(内置 3 层),高速缓存(Cache)来作为内存与处理器之间的缓冲,而主内存和硬盘是外挂的存储介质。

在这里插入图片描述
CPU 的计算都是在自身拥有的高速缓存中进行的,减少与主内存的读写次数,以确保运行效率。具体内存模型如下图所示:

在这里插入图片描述
如上图模型所示,计算机将需要的数据从主内存拷贝至高速缓存,之后将运算后的结果回写到主内存。这个执行计算的过程中,各个非公有变量的变化,各线程间都是不可见的,这就会导致线程不安全。

JMM内存模型

Java 的内存模型实际上也存在着同样的问题:

  • 所有的实例变量和类变量都存储于主内存。
    (其不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。)
  • 线程对变量的所有的读写操作都在工作内存中完成,而不能直接读写主内存中的变量。

(图)
所以,在Java的内存模型中,实际上线程可见性还是没有得到解决。那么,线程可见性的问题该如何解决呢?

2. 如何解决可见性?

根据计算机与 JMM 内存模型,想要一个变量在线程间具有可见性,看来只有两种方式:

  • 给变量上锁,拿到锁的线程才能计算变量,其他线程阻塞。(悲观锁:synchronized )
  • 写变量都写到主内存,主内存变量更新后,通知其他线程变量过期,在其他线程之后的计算中,使用更新后的变量。(volatile)

因为只有拿到锁的线程才能执行计算,所以使用 synchronized 悲观锁毫无疑问实现了线程间变量得 可见性原子性 。但是其代价高昂,并且并发性差。

而使用 volatile 虽然不能保证其原子性(线程不安全),但是可以保证线程间的可见性,所以,作为多任务阻断器,volatile 关键字的应用是符合项目需求的。

为了更好地了解 volatile 的作用范围以及其应用的风险,可以先来看一下 volatile 的内存语义。

volatile 的内存语义

内存语义可以理解为 volatile 在执行计算时,内存中的要实现的功能与规则:

  • 一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • 一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,并从主内存中读取共享变量。

要实现如上的内存语义,那么就要对 JMM 的指令重排作出限制。

3. 内存屏障控制下的指令重排

指令重排

首先简单介绍一下什么是指令重排。

为了优化性能,编译器会根据一些特定的规则,在 as-if-serial (不管怎么重排序,单线程下的执行结果不能被改变。)规则的前提下,进行重新排序。

禁止指令重排的意义在于:

要第一时间写 volatile 变量时,将更新的变量更新到主存;在线程读 volatile 变量时,要第一时间读到主存的变量。 这样能保证功能的正确执行。

指令重排导致问题的例子经典的是单例模式的双重检查,这里先写个伪代码,举个简单的例子:

volatile boolean testMark = false;
int a = 2
//线程1执行task1
task1() {
    
    
	while(!flag) {
    
    
	
	}
	dosomething(a);
}
//线程2执行task2
task2() {
    
    
	a = 3;
	flag = true;
}

对于上面的例子而言,显然,编码者的意图是要在将 a = 3 这个值输入到 dosomething 这个函数中。但如果说,在 task2 中,两个执行步骤进行了重排(对于单线程而言,a = 3 与 flag = true 前后执行顺序即使颠倒,也没有改变此线程最后的计算结果,所以有可能重排)。

那么指令重排之后,明显执行后功能可能是不正确的,所以 volatile 关键字修饰的变量前后,是要禁止指令重排的。

阻止指令重排的方法就是使用内存屏障。 在这里,就不介绍了,稍微看一下定义就好:

java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

happens - before

happens - before 有两条定义,并体现为 8 条规则。这些在 JMM 里都有实现,我们也不用过于操心,在这里我们看一下 volatile 的规则就可以了。

volatile域规则:对一个 volatile 域的写操作,happens-before 于任意线程后续对这个 volatile 域的读。

就像是上面那个例子,在更改了 flag = true 之后,一定要保证 task1 函数的while 循环中,下一次 flag 的取值是 true。

4. volatile 是否保证原子性?

volatile可以使得 long 和 double 的赋值是原子的。(这个改天展开说。)

但 volatile 不能保证 计算的原子性,实际上最经典的就是 ++ 操作。

当有了 变量++ 操作时,会有以下三个步骤在一个线程中产生:

  • 从主存中取值到工作内存
  • 计算工作内存的值
  • 将计算的 new value 更新到主内存

对应的 CPU 指令如下:

mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier

如果现在有两个线程执行同一个 volatile 变量的 ++ 操作,可能出现的情况:

  • 线程1读取变量到工作内存后,由于没有锁,所以线程2在线程1拷贝后,抢到了CPU,立刻也将主存的变量拷贝至工作内存。
  • 之后,线程1与线程2先后在工作内存进行了自增。
  • 最后,线程1、2分别将自增后的值刷回主存。

注意,之前说的:“回写主存后其他线程变量失效” 是对于 还未进行的计算 而言的。上例中,假定线程1计算后,紧接着是线程2的计算(此时线程1还没有回写),之后才回写的更新值,那么线程2就不会变量失效(因为已经计算结束),所以最后线程2回写时,会覆盖掉线程1回写的值,导致线程不安全,所以 volatile 计算不能保证原子性。

三、Volatile 与 synchronized

这一小节,我们可以学习一下 volatile 和 synchronized 的组合应用:单例模式的双重检查。

1. 单例双重检查

如果程序是单线程的,那单例模式也确实没什么好担心的。但如果是多线程,还是要检查线程不安全的问题。

先写一个简单的单例:

public class Singleton {
    
    

    private static Singleton singleton = new Singleton();

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    
    
        return singleton;
        
    }
}

显然,以上的饥饿式写法。在 JVM 启动的时候会创建实例,不会在创建实例的时候存在线程不安全的问题。(但可能)

但如果式懒汉式写法,显然就会有线程安全的问题:

public class Singleton {
    
    

private static Singleton singleton = null;

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    

        if(singleton == null){
    
    

            singleton = new Singleton();

        }

        return singleton;

    }

}

如果有两个线程现在都要获取实例,那么很容易发现,在创建实例的时候明显不是单例模式,线程明显不安全。

而且,我们显然可以在 get 函数前加锁:

    public static synchronized Singleton getSingleton(){
    
    

        if(singleton == null){
    
    

            singleton = new Singleton();

        }

        return singleton;

    }

但这意味着性能的急剧下降,因为 get 实例的时候,永远只有一个抢到锁的线程能工作。

但如果稍微改进一下,用如下的方式加锁:

public class Singleton {
    
    

    private static Singleton singleton =null;

    private void Singleton(){
    
    }

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

这样虽然提升了性能,但是依旧会导致线程不安全。这里是如何不安全的呢?我们可以假设一个极限的情况 —— 创建对象的指令进行了重排:

创建对象的三个步骤:

  • 分配内存空间。
  • 调用构造器,初始化实例。
  • 返回地址给引用
  • 线程1申请了对象内存空间,并将内存地址回写到了主存,但还没有真的初始化对象。(步骤3提升到了步骤2之前)

对于单线程而言,这样不违法,因为最后单线程的计算结果等同于重排前。

  • 线程2此时要 get 实例,去主内存拿到了对象的地址,并访问,发现是空指针。

所以,指令重排会导致问题,我们需要在变量上加上 volatile 来 禁止指令重排。

public class Singleton {
    
    

    private static volatile Singleton singleton =null;

    private void Singleton(){
    
    }

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

这就是为什么要在变量前加上 volatile 修饰词。

四、volatile 与 static

static 修饰的变量:多实例间,保证变量的唯一性。但是没有可见性和原子性的保证。
volatile 修饰的变量:多实例间,变量没有唯一性。但是能保证线程可见性,不保证原子性。

所以,static volatile 修饰的变量就是多实例间的唯一性,以及线程间的可见性。

五、总结

  • 适用场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如 booleanflag;或者作为触发器,实现轻量级同步。
  • volatile 变量的读写操作都是无锁的,低成本的。它不能替代 synchronized,因为它没有提供原子性和互斥性。
  • volatile 只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
  • volatile 可以在单例双重检查中(特殊场景)实现可见性和禁止指令重排序,从而保证安全性。

线程可见禁止指令重排不一定有原子性

猜你喜欢

转载自blog.csdn.net/weixin_43742184/article/details/113887129