JUC之volatile

59 阅读13分钟

1、volatile的两大特点

1.1 特点

使用内存屏障来实现下面的两个特点

1.1.1 可见性

其他线程对变量的写操作对其他线程可见。

1.1.2 有序性

有时候会禁止重排。

1.2 volatile的内存语义

  • 当写一个volatile变量时,JMM会把该线程的对应的本地内存中的共享变量值立即刷新到主内存中。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存的共享变量设置为无效,去主内存中获取最新的值

2、内存屏障(重点内容)

2.1 内存屏障的定义

内存屏障是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,避免代码重排序。 内存屏障其实是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性。

内存屏障之前的所有写操作都要回写到主内存中, 内存屏障之后的所有读操作都能获得内存屏障之前的写操作的最新结果。

写屏障:告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存中,也就是看到Store屏障指令,就必须把该指令之前所有写入指令执行完毕才能往下执行。 读屏障:处理器在读屏障之后的读操作,都在读屏障之后执行,也就是在Load屏障指令之后就能够保证后面的读取数据指令一定能够读取到最新的数据。

2.2 内存配置的分类

2.2.1 2种粗分

① 读屏障LoadFence

在读指令之前插入读屏障,重新从主内存中读到最新的数据放到工作内存或CPU高速缓存中

② 写屏障StoreFence

在写指令之后插入写屏障,将写缓冲区的数据刷回到主内存中。

③ 全屏障FullFence

即读屏障和写屏障的混合屏障,同时实现了读屏障和写屏障的功能。

在Unsafe.class中有如上三个屏障的定义

public native void loadFence();  
  
public native void storeFence();  
  
public native void fullFence();

这三个方法都用了native,说明底层是使用C++写的。 因此可以去openjdk中去查找对应的Unsafe.java文件,Unsafe.java对应着Unsafe.cpp,而在Unsafe.cpp中有如下的定义

UNSAFE_ENTRY(voidUnsafe_LoadFence(JNIEnv *env, jobject unsafe))
	UnsafeWrapper("Unsafe LoadFence");
	OrderAccess::acquire();
UNSAFE_END

UNSAFE_ENTRY(void, Unsafe_StoreFence(JNIEnv *env, jobject unsafe))
	Unsafewrapper("Unsafe StoreFence");
	OrderAccess::release();
UNSAFE_END

UNSAFE_ENTRY(void,Unsafe_FullFence(JNIEnv *env, jobject unsafe))
	UnsafeWrapper("Unsafe FullFence");
	OrderAccess::fence();
UNSAFE_END

可以看到,其底层都调用了OrderAccess,因此我们去找对应的OrderAccess.hpp,在这个文件中有对应的定义

// 读后读
static void loadload();
// 写后写
static void storestore();
// 读后写
static void loadstore();
// 写后读
static void storeload();

而这,也是将读屏障和写屏障的细分情况。

2.2.2 4种细分

上述提到,4种细分为loadload()、storestore()、loadstore()、storeload()四种。

屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2Load1执行完之后,执行LoadLoad屏障后才能执行Load2
StoreStoreStore1;StoreStore;Store2Store1执行完之后,执行StoreStore屏障后才能执行Store2
LoadStoreLoad1;LoadStore;Store2Load1执行完之后,执行LoadStore屏障后才能执行Store2
StoreLoadStore1;StoreLoad;Load2Store1执行完之后,执行StoreLoad屏障后才能执行Load2

2.3 有序性的说明

有序性,禁止指令重排。

  • 重排序有可能影响程序的执行和实现,因此,我们有时候不希望JVM进行重排序
  • 对于编译器的重排序,JMM会根据重排序的规则,禁止特定类型的编译器重排序
  • 对于处理器的重排序,Java编译器在生成指令序列的适当位置,==插入内存屏障指令==,来禁止特定类型的处理器排序。

2.4 happens-before之volatile变量规则

第一个操作第二个操作:普通读写第二个操作:volatile读第二个操作:volatile写
普通读写可以重排可以重排不可以重排
volatile读不可以重排不可以重排不可以重排
volatile写可以重排不可以重排不可以重排
对上述表格的说明:
  • ① 当第一个操作为==volatile读==时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
  • ② 当第一个操作为==volatile写==时,第二个操作是volatile时,不能重排。
  • ③ 当第二个操作为==volatile写==时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后。

2.5 内存屏障插入策略的4种规则

2.5.1 读屏障

  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障 禁止处理器把前面的volatile读与下面的普通读写重排序。 具体的执行顺序如下

img11.png

2.5.2 写屏障

  • 在每个volatile写操作的前面插入一个StoreStore屏障,保证volatile写之前,前面的所有普通写都刷新到主内存中
  • 在每个volatile写操作的后面插入一个StoreLoad屏障,避免volatile写与之后可能有的volatile读/写操作重排序

img12.png

3、volatile特性

3.1 保持可见性

3.1.1 说明

保证不同线程对某个变量完成操作后结果及时可见,即该共享变量一旦改变所有线程立即可见。

3.1.2 代码演示

① 没有使用volatile关键字修饰
static boolean flag1 = true;  
private static void volatileSeeWithoutVolatile() {  
    new Thread(() -> {  
        System.out.println("------------- flag1的值为true,程序开始执行");  
        while (flag1) {}        
        System.out.println("------------- flag1的值被其他线程修改为false,程序终止");  
    },"t1").start();  
  
    try {  
        TimeUnit.SECONDS.sleep(2);  
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    }  
    flag1 = false;  
    System.out.println("------------- main线程将flag1修改为false");  
}

此时的运行结果为:

------------- flag2的值为true,程序开始执行
------------- main线程将flag2修改为false
② 使用volatile关键字修饰
static volatile boolean flag2 = true;  
private static void volatileSeeWithVolatile() {  
    new Thread(() -> {  
        System.out.println("------------- flag2的值为true,程序开始执行");  
        while (flag2) {}        
        System.out.println("------------- flag2的值被其他线程修改为false,程序终止");  
    },"t1").start();  
  
    try {  
        TimeUnit.SECONDS.sleep(2);  
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    }  
    flag2 = false;  
    System.out.println("------------- main线程将flag2修改为false");  
}

此时的运行结果为:

------------- flag2的值为true,程序开始执行
------------- main线程将flag2修改为false
------------- flag2的值被其他线程修改为false,程序终止

3.1.3 原理解释

主要是工作内存和主内存的作用。每个线程在运行时会分配一个栈内存(工作内存),而在具体运行的过程中,线程会从主内存中读取变量的值到自己的栈内存中,而线程对变量的读写操作都是对自己栈内存中的变量进行读写的。 所以如果没有使用volatile关键字修饰的变量,那么其他线程对相同变量名的变量进行修改时不会影响其他线程对该变量的读取。 而如果使用了volatile关键字修饰,那么,当其他线程对该变量进行写操作时,会立刻将写操作的结果写入到主内存中,并通知其他线程重新从主内存中读取数据到自己的栈内存中。

而上述代码演示中的第一个案例,flag1没有使用volatile关键字修饰,因此,当main线程将flag1修改为false时,修改的结果并没有写入到主内存中,而每个线程独享自己的栈内存,因此,t1线程读取的还是自己栈内存的那个flag1的值true,所以程序并没有正常的执行。

而第二个案例中,flag2使用了volatile关键字修饰,因此,当main线程将flag2修改为false时,会立刻将修改的值写入主内存中,并通知其他的线程重新从主内存中读取。所以此时t1线程从主内存中读取到由main线程修改的false值,因此,程序正常执行并退出。

3.1.4 volatile变量的读写过程

① 8种原子操作

Java内存模型(JMM)中定义的8种每个线程自己的工作内存与主物理内存之间的原子操作 read(读取)-> load(加载)-> use(使用)-> assign(赋值)-> store(存储)-> write(写入)-> lock(锁定)-> unlock(解锁)

  • read:作用于主内存,将变量的值从主内存中读到自己的栈内存中
  • load:作用于栈内存,将read操作中从主内存传输的变量值放入栈内存变量副本中
  • use:作用于栈内存,将栈内存中变量副本的值传递给执行引擎,当JVM遇到需要该变量的字节码指令时就会执行该操作
  • assign:作用于栈内存,将从执行引擎接收到的值赋值给栈内存变量
  • store:作用于栈内存,将赋值完毕的变量的值写回给主内存
  • write:作用于主内存,将store传输过来的变量值赋值给主内存中的变量
  • lock:作用于主内存,将一个变量标记为一个线程独占的状态
  • unlock:作用于主内存,把一个处于锁定状态的变量恢复到未加锁状态
② 读写过程及案例解析

img13.png 通过上述的案例来解释这个流程。 为了区别,我们将使用如下格式线程名#变量名t1#flag2,用于区分不同线程的相同变量名。

  • main线程和t1线程都从主内存中去读取read,并且将该值加载load进自己的栈内存中。
  • t1线程在while(t1#flag2)中去使用这个变量
  • main线程则在将其进行修改assign main#flag2=false
  • main线程将修改的值存储store到自己的栈内存中
  • 存储之后,main线程则将main#flag2=false的值写入write到主内存中
  • 但是,在write之前,需要对该变量加锁
  • 当主内存中的flag2被加锁之后,t1线程的其他线程名#flag2的值会失效
  • main线程将值写入主内存
  • t1线程重新从主内存中读取这个新的值flag2=false
  • 因此t1线程拿到新值之后t1#flag2=false,因为while循环不成立就弹出了循环

3.2 没有原子性

3.2.1 说明

volatile变量的复合操作不具有原子性,比如number++。

3.2.2 代码演示

① 加锁且不适用volatile关键字的情况下
static class MyNumber {  
    int number;  
  
    public synchronized void addNumber() {  
        number++;  
    }  
}  
  
private static void usingSynchronizedWithoutVolatile() {  
    MyNumber myNumber = new MyNumber();  
    for (int i = 0; i < 10; i++) {  
        new Thread(() -> {  
            for (int j = 0; j < 1000; j++) {  
                myNumber.addNumber();  
            }  
        }).start();  
    }  
  
    try {  
        TimeUnit.SECONDS.sleep(3);  
    } catch (InterruptedException e) {  
        throw new RuntimeException(e);  
    }  
    System.out.println(myNumber.number);  
}

此时运行的结果为:

10000
② 使用volatile但不适用synchronized的情况下
private static void usingVolatileWithoutSynchronized() {  
    MyNumber2 myNumber2 = new MyNumber2();  
    for (int i = 0; i < 10; i++) {  
        new Thread(() -> {  
            for (int j = 0; j < 1000; j++) {  
                myNumber2.addNumber();  
            }  
        }).start();  
    }  
  
    try {  
        TimeUnit.SECONDS.sleep(5);  
    } catch (InterruptedException e) {  
        throw new RuntimeException(e);  
    }  
    System.out.println(myNumber2.number);  
}  
  
static class MyNumber2{  
    volatile int number;  
    public void addNumber() {  
        number++;  
    }  
}

此时的运行结果为:

9901

且多次执行的结果都不一样,但都不会达到10000这个数值。

3.2.3 没有原子性的原因

前面我们提到,JMM一共定义了8种原子操作,分别是read(读取)-> load(加载)-> use(使用)-> assign(赋值)-> store(存储)-> write(写入)-> lock(锁定)-> unlock(解锁)。 而对于number++这种看似是一条指令其实实际上的复合操作的情况,需要经过多个原子操作才能满足,底层上,number++可以翻译成三个原子操作load -> use -> assign,而这三个原子操作,互相之间不能保证都是原子操作。所以,当有线程在执行number++时,读到了load -> use -> assign三者之一,但是这都是在t1线程的栈内存中操作的,具体的仍然是要写回到主内存中。而假设在这期间,有其他线程也来执行,那么就会从主内存中拿到了未被t1线程修改的值,因此就会导致结果错误。

3.2.4 小结

volatile变量不具备原子性,不适合参与到依赖当前值的运算。 适合参与到boolean或int类型值的运算中。

3.3 指令禁重排

4、如何正确使用volatile

  • 单一赋值可以,但是含复合运算赋值的不可以(自增或自减)
    • volatile int a = 10,或者volatile boolean flag = true
  • 状态标志,判断业务是否结束
static volatile boolean flag2 = true;  
private static void volatileSeeWithVolatile() {  
    new Thread(() -> {  
        System.out.println("------------- flag2的值为true,程序开始执行");  
        while (flag2) {  
  
        }        System.out.println("------------- flag2的值被其他线程修改为false,程序终止");  
    },"t1").start();  
  
    try {  
        TimeUnit.SECONDS.sleep(2);  
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    }  
    flag2 = false;  
    System.out.println("------------- main线程将flag2修改为false");  
}
  • 开销较小的读,写锁策略
class Counter {  
    private volatile int value;  
    public int getValue() {  
        return value;  
    }  
    public synchronized int increment() {  
        return value++;  
    }  
}
  • DCL双端锁的发布 普通写法:
private static SingletonDoubleCheck instance;  
  
public static SingletonDoubleCheck getInstance() {  
    if (instance == null) {  
        synchronized (SingletonDoubleCheck.class) {  
            if (instance == null) {  
                instance = new SingletonDoubleCheck();  
            }  
        }  
    }  
    return instance;  
}

这种写法在单线程环境下是没有问题的。 但是,如果在多线程环境下,那么问题会出现在下面的这一行代码上

instance = new SingletonDoubleCheck();  

这一行代码,实际在运行的过程中是分为三步:

memory = allocate(); // 先分配一个内存空间
ctorInstance(memory); // 初始化内存空间的对象
instance = memory; // 再将初始化后的对象赋值给instance

而这,并不是一个原子操作,既然不是原子操作,就会涉及到指令重排序的情况。 而指令重排序时,就会导致,第二行和第三行代码顺序调换

memory = allocate(); // 先分配一个内存空间
instance = memory; // 将未初始化完的对象赋值给instance
ctorInstance(memory); // 再初始化内存空间的对象

而当优先执行了赋值操作之后,因为还未完成初始化,因此此时的值仍然是一个null值,而再次初始化时因为已经获得了值,因此不会对instance的值进行修改。所以第一次返回的是一个null值。而当其他线程也进来之后就也会执行同样的操作,直到某一次没有发生指令重排后才会获取正确的值。==问:(重排序是指重排后结果一致的情况下才会进行重排,这种情况下结果不一样了还会发生重排吗??)== 因此,上述的问题出现在指令会重排序的情况上,为了解决这个问题,我们需要给变量加上volatile关键字,所以正确的写法如下: 正确写法:

private static volatile SingletonDoubleCheck instance;  
  
public static SingletonDoubleCheck getInstance() {  
    if (instance == null) {  
        synchronized (SingletonDoubleCheck.class) {  
            if (instance == null) {  
                instance = new SingletonDoubleCheck();  
            }  
        }  
    }  
    return instance;  
}