本文参考 黑马Java并发编程

Volatile

一个案例

在看volatile之前,我们先来看两段有点 “诡异” 的代码👇

//①号
class UnstoppableTest {
    static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(run){
                // ....
            }
        });
        t.start();
        Thread.sleep(1000);
        run = false; // 线程t不会如预想的停下来
    }
}
//②号
//I_Result r 是一个对象,有一个r1属性保存结果
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}
// 线程2 执行此方法
public void actor2(I_Result r) {
    num = 2;
    ready = true;
}

//r.r1的结果可能是 0 !

要知道这两段代码为什么会出现这样的结果,以及如何解决,我们需要从Java的内存模型讲起👇

Java内存模型

JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。

来看下结构图:
image-20211217112712883

JMM围绕建立的并发三大特性:

  • 原子性:保证操作要么全部执行要么全部不执行
  • 可见性:多个线程访问同个变量时,一个线程修改了变量值,其它线程能立刻看到修改的值
  • 有序性:保证指令不会受 cpu 指令并行优化的影响;JVM在不影响正确性的前提下,可以调整语句的执行顺序,称为指令重排

这里原子性我们先不讲,volatile不能保证原子性

分析问题,解决问题

简单了解了JMM长啥样后,我们来看下前面那些代码的问题出在哪了?

首先来看看①号问题:

根据结构图,我们可以看到每个线程都一份共享变量的缓冲副本,线程t在第一次读取变量run时是在主内存中拷贝一份到自己的缓冲区中,由于之后都没有再修改过这个run变量,所以其它线程对run的改变对线程 t 是不可见的!

那么该如何保证变量run的可见性呢?方法有以下几种:
① 使用Synchronized加锁

② 打印run变量(本质上与①一样)

③ 在run变量上添加 **volatile **关键字

class UnstoppableTest {
    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Object obj = new Object();
        Thread t = new Thread(() -> {
            while (true) {
                synchronized (obj) {
                    if (!run) break;
                }
//                System.out.println(run);
            }
        });
        t.start();
        Thread.sleep(1000);
        synchronized (obj) {
            run = false;
        }
    }
}

解决完①号问题后,我们来看②号问题:

②号问题主要是多线程环境下指令重排引起的,我们来模拟一下0出现的场景:

方法actor2的语句被调换顺序 👇

	// 线程2 执行此方法
    public void actor2(I_Result r) {
        ready = true;
        num = 2;
    }

    // 线程1 执行此方法
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
  • 线程2执行到 ready = true 时间片到了,切换到线程1运行
  • 线程1执行判断进入if语句:r.r1 = 0 + 0; 结束运行

分析完后来解决,解决的方法是前一个例子一样,Synchronized和volatile都能保证有序性

原理

那,它们是如何保证可见性有序性的呢?

首先是Synchronized

  • 保证可见性的方法是JVM规定线程加锁时,需要清空线程工作内存区,从主内存读取最新变量值,解锁时,把工作内存区的所有变量值写回主内存,这期间该线程获取着锁,所以不用担心线程安全问题;
  • 保证有序性的方法在于加锁后临界区内代码执行是单线程的,即使重排,其它线程在阻塞中,无法进行操作,所以不会出现问题;这不是严格意义上的有序,语句仍可能会被重排;

虽然synchronized能解决可见性,但每次使用都需要申请Monitor对象并关联,有没有一种更轻量级的办法呢?欸,volatile在这里是一种更不错的选择;

那 volatile 又是如何保证可见性有序性的呢?

答案是内存屏障

  • 屏障(sfence)保证在该屏障之的,对共享变量的改动,都同步到主存当中
  • 屏障(lfence)保证在该屏障之,对共享变量的读取,加载的是主存中最新数据
  • 屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之
  • 屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之
  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

volatile的应用——DCL单例模式

我们先来看看下面的DCL单例模式实现代码有没有问题

public final class Singleton{
    private Singleton(){}
    private static Singleton INSTANCE = null;
    public static Singleton getInstance(){
        if(INSTANCE	== null){
            synchronized(Singleton.class){
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

看着好像没什么问题,但需要注意的是变量INSTANCE没有加volatile修饰,需要注意其可见性有序性

虽然synchronized保护了内层的 IF 语句,但就像我们之前说的 synchronized内语句仍可能会被重排,只是单线程执行不影响结果,别忘了外层循环可不是单线程执行!

通过字节码分析我们可以看到,会发生重排的关键代码在这一行

INSTANCE = new Singleton();
/* 字节码
	new 			#3
	dup
	invokespecial 	#4  --执行构造方法
	putstatic 		#2  --赋值给INSTANCE
*/

这里的第三行和第四行可能会被重排,就可能会发生一下情况👇

	线程1							线程2 
thread-1:	 进入IF①
thread-1:    进入synchronized
thread-1:    进入IF②
thread-1:    发生指令重排
thread-1:    INSTANCE=未执行构造方法的Singleton对象
thread-2:    进入IF①
thread-2:	 返回INSTANCE (错了)
thread-1:    该Singleton对象执行构造器方法

可以看到线程2获取到的Singleton对象是没有执行构造方法的初始对象

所以,DCL中,我们必须使用 volatile修饰 INSTANCE变量!

CAS与volatile

既然看完了volatile,就顺便来看看CAS,CAS是CompareAndSwap的缩写,它是一种乐观锁;

我们来了解下用到CAS的一个典型方法:AtomicInteger.compareAndSet()

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

可以看到这是底层实现的,它完成了CPU层面的一次比较并交换的原子操作(锁住总线)

CAS的思想是,读取当前值,进行一次比较并交换,这是一次原子操作,如果比较后发现值没有变,就交换/set值,如果发生变化就不变,在循环中一直尝试;

代码展示:

class mytest1 implements Account {
    private AtomicInteger balance;

    public mytest1(Integer balance) {
        this.balance = new AtomicInteger(balance);
    }

    @Override
    public Integer getBalance() {
        return balance.get();
    }

    @Override
    public void withdraw(Integer amount) {
        while (true) {
            int prev = balance.get();
            int next = prev - amount;
            if (balance.compareAndSet(prev, next)) {
                break;
            }
        }
        // 可以简化为下面的方法
        // balance.addAndGet(-1 * amount);
    }
}

CAS离不开volatile,因为它需要保证每次读到的值都是内存中的最新值👇

image-20211217141600039

Q.E.D.


记录 • 分享 • 日常