你必须知道的 volatile 关键字

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

前言

在高并发实际场景下,volatile 关键字应用较多,同时,这也是面试的必问的一个知识点了。那么,valatile 关键字有什么作用呢?

内存可见性

这需要从 Java 的内存模型(JMM)说起,JMM 规定所有的变量都需要存放在主内存中,同时,每个线程又有着自己的工作内存 (主要做高速缓存)。

这样,线程工作时需要操作变量时,需要将主内存中的数据拷贝到工作内存中。这样线程对数据的任何操作都是基于线程本身的工作内存(目的是为了提升效率),而不能去直接操作主内存以及其他线程的工作内存的数据,当线程对数据做了更新以后,还需要将更新后的数据刷新到主内存中。

以上图为例,在并发情况下,可能会出现线程 B 读取到的数据是线程 A 更新之前的数据,从而导致数据不一致。

这个时候,就需要 volatile 出场了:

当一个变量被 volatile 修饰的时候,任何线程对其做的写操作都会被立即刷新到主内存中,并且强制让那些缓存了该变量的线程内的该变量数据清空,需要从主内存中重新读取最新数据

注意:volatile 修饰的变量,并不是让线程直接操作主内存获取数据,还是需要将变量拷贝到工作内存中。

volidate 应用 demo

我们模拟一个简单的应用场景,两个线程需要同时访问主内存中的某个标志位变量 flag, 我们用 volidate 来修饰:

public class VolatileRunnable implements Runnable{

    private static volatile boolean flag = true ;

    @Override
    public void run() {
        while (flag) {
            System.out.println(Thread.currentThread().getName()+ "正在运行。。。");
        }
        System.out.println(Thread.currentThread().getName()+ "执行完毕。。。");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileRunable vr = new VolatileRunable();
        new Thread(vr,"thread A").start();

        System.out.println("main 线程正在运行") ;

        TimeUnit.MILLISECONDS.sleep(100) ;

        vr.stopThread();}

    private void stopThread(){
        flag = false ;
    }
}

主线程在对 flag 做了修改以后,flag 会立马被刷新到主内存中,从而及时停止线程 A, 如果说 flag 没有被 volidate 修饰,就可能会出现延迟。

volidate 一定能保证线程的安全性吗?

上面说到的 volidate 能够保证被修改的变量及时被刷新到主内存中,很多人会认为这样就能保证线程的安全性。

答案是否定的。volidate 并不能保证线程的安全性!

public class VolatileInc implements Runnable{

	private static volatile int count = 0 ; // 使用 volatile 修饰基本数据内存不能保证原子性

	//private static AtomicInteger count = new AtomicInteger() ;

	@Override
	public void run() {
		for (int i=0;i<10000 ;i++){
			count ++ ;
			//count.incrementAndGet();}
	}

	public static void main(String[] args) throws InterruptedException {
		VolatileInc volatileInc = new VolatileInc() ;
		Thread t1 = new Thread(volatileInc,"t1") ;
		Thread t2 = new Thread(volatileInc,"t2") ;
		t1.start();
		//t1.join();

		t2.start();
		//t2.join();
		for (int i=0;i<10000 ;i++){
			count ++ ;
			//count.incrementAndGet();}


		System.out.println("最终 Count="+count);
	}
}

上面这段代码中,三个线程对 int i 进行累加,最终的结果都会小于 30000,而不是刚好 30000!

这是什么情况?不是说 volidate 保证了内存的可见性吗?

volidate 固然保证了内存的可见性,使得每个线程都能拿到最新的值,但是 count ++ 这个操作并不是原子的,看似简单的自增加 1 的操作,实际上包含了三个操作:

  • 获取值;

  • 自增;

  • 赋值;

但这三个操作没有原子性,并不能同时完成。

那要如何解决对没有原子性的基本数据的并发安全性呢?

  • 1.用单线程串行执行(不推荐,无法充分发挥多核 CPU 的优势);

  • 2.通过 synchronize 对数据上锁保证原子性;

  • 3.通过 Atomic 包中的 AtomicInteger 来替换 int, 它的底层利用了 CAS 算法来保证原子性

validate 之指令重排

validate 除了能够保证内存的可见性,它的另一重作用是防止 JVM 进行指令重排优化。

用代码说事:

int a=1 ; // 1
int b=2 ; // 2
int c= a+b ; // 3

上面是一段基础代码,理想情况下它的执行顺序是:1 ==> 2 ==> 3。但是在 JVM 对其进行指令重排优化后,它的执行顺序可能变为: 2 ==> 1 ==> 3.

JVM 的指令重排优化是在保证最终结果不变的情况下进行的。

上面的代码还看不出指令重排对实际业务带来的影响,看下面的代码:

private static Map<String,String> value ;
private static volatile boolean flag = fasle ;

// 以下方法发生在线程 A 中 初始化 Map
public void initMap() {
	// 耗时操作
	value = getMapValue() ; // 1
	flag = true ; // 2
}


// 发生在线程 B 中 等到 Map 初始化成功进行其他操作
public void doSomeThing() {
	while(!flag) {
		sleep();}
	// dosomething
	doSomeThing(value);
}

如果上面的 flag 变量没被 volidate 修饰的话,JVM 指令重排优化后,导致 value 还没有被初始化,就有可能被线程 B 使用了。

这里加上 volidate 之后可以保证业务的正确性。

volidate 防指令重排应用

比较经典的应用场景就是双重懒加载的单例模式了:

public class Singleton {

	private static volatile Singleton singleton;

	private Singleton(){}

	public static Singleton getInstance() {
		if (singleton == null) {
			synchronized (Singleton.class) {
				if (singleton == null) {
					// 防止指令重排
					singleton = new Singleton();}
			}
		}
		return singleton;
	}
}

上面对 Singleton 对象添加 volatile 关键字,就是为了防止指令重排。

如果说我们不使用的话,singleton = new Singleton();,这段代码其实是分为三步:

  • 第一步:分配内存空间

  • 第二步:初始化对象

  • 第三步:将 singleton 对象指向分配的内存地址

加上 volidate 保证上面三步得以顺序执行,否则可能出现 第二步第三步 之前执行的情况发生,就可能导致某个线程拿到的单例对象是还没有被初始化,导致程序报错。

总结

volatile 在 Java 并发应用场景有很多,比如像 Atomic 包中的 value、以及 AbstractQueuedLongSynchronizer 中的 state 都是被定义为 volatile 来用于保证内存可见性。

将这块理解透彻对我们编写并发程序时将获益匪浅。