youyichannel

志于道,据于德,依于仁,游于艺!

0%

JUC知识点_03

Volatile 关键字

Volatile 保证变量可见性

Java程序中,被volatile关键字修饰的变量的特点:

  • 可见性
  • 有序性
  • 不保证原子性

在Java中,volitail关键字修饰的变量将会指示JVM,这个变量共享且不稳定,每次使用它都需要从主存中读取。

volatile关键字并不是Java特有的,在C语言中也存在,该关键字最原始的意义就是禁止CPU缓存。

📢注意:volatile关键字能够保证数据的可见性,但是不保证数据的原子性。(synchronized二者都能保证)

volatile的内存语义

  1. 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新会主内存中
  2. 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

=> volatile内存语义:

  • 写内存语义:直接刷新到主存中
  • 读内存语义:直接从主内存中读取

禁止指令重排序

Java中,volatile关键字除了可以保证变量的可见性,还有一个重要的作用就是保证指令的有序性,也就是防止JVM的指令重排序。使用volatile修饰变量时,在对这个变量进行读写时,会通过插入特定的内存屏障来禁止指令重排序。

【指令重排序经典例子:单例模式】双检锁实现对象单例(线程安全)

public class Singleton {
private volatile static Singleton INSTANCE;

private Singleton() {}

public static Singleton getInstance() {
// 首先判断对象是否实例化过,没有实例化才进入同步代码块
if(INSTANCE == null) {
// 类对象加锁
synchronized (Singleton.class) {
if(INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

上述代码中,使用volatile给变量INSTANCE是有必要的,因为实例化的代码INSTANCE = new Singleton();在字节码层面是分为三步进行的:

  1. 为INSTANCE分配内存空间
  2. 初始化INSTANCE
  3. 将INSTANCE执行分配的内存地址

上述步骤的顺序是不可以进行重排序的。指令重排序在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获取到还没有初始化的实例对象的问题。

不使用volatile的单例模式就是使用饿汉式,声明的时候就进行初始化。

内存屏障

概念:内存屏障(内存栅栏、内存栅障、屏蔽指令),是CPU或者编译器在对内存随机访问的操作中的一个同步点,使得同步点之前的所有的读写操作都执行后才可以开始执行同步点之后的操作,避免指令重排序。

内存屏障就是一种JVM指令,JMM的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障,volatile实现了JMM中的可见性和有序性。

在Java中,Unsafe类提供了三个内存屏障相关的本地方法,屏蔽了操作系统底层的差异:

/**
* Ensures lack of reordering of loads before the fence
* with loads or stores after the fence.
* @since 1.8
*/
public native void loadFence();

/**
* Ensures lack of reordering of stores before the fence
* with loads or stores after the fence.
* @since 1.8
*/
public native void storeFence();

/**
* Ensures lack of reordering of loads or stores before the fence
* with loads or stores after the fence.
* @since 1.8
*/
public native void fullFence();

volatile不保证原子性

代码证明:

public class Demo {

public volatile static int num = 0;

public void increase() {
num++;
}

public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
Demo demo = new Demo();
for (int i = 0; i < 5; i++) {
threadPool.execute(() -> {
for (int j = 0; j < 500; j++) {
demo.increase();
}
});
}
// 暂停,保证上述程序运行结束
Thread.sleep(1500);
System.out.println("num = " + num);
threadPool.shutdown();
}
}

正常情况下,上述代码的结果应该是2500,但实际上每次的输出结果都会小于2500。

原因是num++并不是一个原子操作,它包括三个步骤:

  1. 读取num的值
  2. num + 1操作
  3. 将num的值写会内存

volatile是无法保证这三个操作具有原子性的。

解决方案:使用synchronizedLock或者原子类AtomicInteger都可以实现

volatile应用场景

1)单一赋值的场景

volatile int a = 10;
volatile boolean flag = false;

📢注意: a++或者++a这类操作不属于单一赋值操作,是复合运算操作

2)状态标志,用于判断业务是否结束

public class UseVolatileDemo{
private volatile static boolean flag = true;

public static void main(String[] args){
new Thread(() -> {
while(flag) {
//do something......
}
},"t1").start();

//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(2L); } catch (InterruptedException e) { e.printStackTrace(); }

new Thread(() -> {
flag = false;
},"t2").start();
}
}

3)开销较低的读写锁策略

public class UseVolatileDemo{
// 使用: 当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
// 理由: 利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
public class Counter{
private volatile int value;
public int getValue(){
return value; //利用volatile保证读取操作的可见性
}
public synchronized int increment(){
return value++; //利用synchronized保证复合操作的原子性
}
}
}

参考文章

  • https://blog.csdn.net/TZ845195485/article/details/117601980
  • https://javaguide.cn/java/concurrent/java-concurrent-questions-02.html