Java并发 --volatile关键字

27 阅读6分钟

面试中经常会问到“你了解volatile吗?”,今天牢梦就来和大家聊聊我对volatile的了解

【哲风壁纸】卡通小男孩-小男孩_3840x2160.jpg

volatile概述

在Java并发中,volatile可以保证变量的可见性,如果我们将变量声明为volatile,就指示JVM,这个变量是共享且不稳定的,每次使用都需要从主存中读取

注意到volatile并不能保证原子性,所以对于多次写的情况下不适合用volatile,容易造成数据丢失问题,单独使用volatile一般可以用来操作一些开关,多次读一次写的对象,如:

public class Button {
	public volatile boolean isopen = true;

	public void run() {
		while (isopen == true) {
			// 逻辑操作
		}
	}

	public void stop() {
		isopen = false;
	}
}

上面例子在开关打开的时候可以做一些操作,同时由于volatile保证了可见性,isopen变成false后其他线程可以马上看到并停止running


volatile能保证原子性吗

volatile不能保证原子性!!!!(这是血泪教训,之前小红书一次面试时候说错了这个,才意识到这里学的不扎实猛猛回来补习)

例子:

public class VolatileAtomcityDemo {
	public volatile static int count;

	public void add() {
		count++;
	}

	public static void main(String[] args) throws Exception {
		ExcutorService threadpool = Excutors.newfixedThreadPool(5);
		VolatileAtomcityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
		for (int i = 0; i < 5; i++) {
			threadpool.excute(() -> {
				volatileAtomicityDemo.add();
			});
		}

		Thread.sleep(1500);
		System.out.Println(count);
		threadPool.shutdown();
	}
} 

在以上例子中,预期输出的count值应该是2500,但是实际上每次输出都小于2500,这是因为volatile不保证原子性,对于Java线程来说,每一个线程都有自己独立的本地栈和运行主机,线程中对于将声明为volatile的变量的操作都需要从主内存中读取,如果线程A某一时刻读取到count = 1,同一时刻线程B也读取了count = 1, 之后A线程执行add方法,将count + 1并返回内存,count变成了2,但是线程B早已读取到之前的count = 1,之后线程B也修改count ++, 将count = 2写回内存,此时内存中的count值变成了2,但实际预期结果是3,导致了数据丢失问题。

想要让上面的例子正常运行,可以配合悲观锁,将给add方法加锁:

public synchronized add() {
	count++;
}

这样可以得到正确结果


volatile实现一个单例

volatile实现单例模式

单例模式只创建一个实例,很适合用volatile修饰

public class SingleTon {
	public volatile static SingleTon Instance;

	private SingleTon() {}

	public SingleTon getinstance() {
		if (Instance == null) {
			synchronized(SingleTon.class) {
				if (Instance == null) {
					Instance = new SingleTon();
				}
			}
		}
		return Instance;
	}
}

原理

volatile 实现原理和它的作用有关,我们首先先来看它的内存可见性。

2.1 内存可见性实现原理

volatile 内存可见性主要通过 lock 前缀指令实现的,它会锁定当前内存区域的缓存(缓存行),并且立即将当前缓存行数据写入主内存(耗时非常短),回写主内存的时候会通过 MESI 协议使其他线程缓存了该变量的地址失效,从而导致其他线程需要重新去主内存中重新读取数据到其工作线程中。

什么 MESI 协议?

MESI 协议,全称为 Modified, Exclusive, Shared, Invalid,是一种高速缓存一致性协议。它是为了解决多处理器(CPU)在并发环境下,多个 CPU 缓存不一致问题而提出的。
MESI 协议定义了高速缓存中数据的四种状态:

  1. Modified(M):表示缓存行已经被修改,但还没有被写回主存储器。在这种状态下,只有一个 CPU 能独占这个修改状态。
  2. Exclusive(E):表示缓存行与主存储器相同,并且是主存储器的唯一拷贝。这种状态下,只有一个 CPU 能独占这个状态。
  3. Shared(S):表示此高速缓存行可能存储在计算机的其他高速缓存中,并且与主存储器匹配。在这种状态下,各个 CPU 可以并发的对这个数据进行读取,但都不能进行写操作。
  4. Invalid(I):表示此缓存行无效或已过期,不能使用。

MESI 协议的主要用途是确保在多个 CPU 共享内存时,各个 CPU 的缓存数据能够保持一致性。当某个 CPU 对共享数据进行修改时,它会将这个数据的状态从 S(共享)或 E(独占)状态转变为 M(修改)状态,并等待适当的时机将这个修改写回主存储器。同时,它会向其他 CPU 广播一个“无效消息”,使得其他 CPU 将自己缓存中对应的数据状态转变为I(无效)状态,从而在下次访问这个数据时能够从主存储器或其他 CPU 的缓存中重新获取正确的数据。

这种协议可以确保在多处理器环境中,各个 CPU 的缓存数据能够正确、一致地反映主存储器中的数据状态,从而避免由于缓存不一致导致的数据错误或程序异常。

2.2 有序性实现原理

volatile 的有序性是通过插入内存屏障(Memory Barrier),在内存屏障前后禁止重排序优化,以此实现有序性的。

什么是内存屏障?

内存屏障(Memory Barrier 或 Memory Fence)是一种硬件级别的同步操作,它强制处理器按照特定顺序执行内存访问操作,确保内存操作的顺序性,阻止编译器和 CPU 对内存操作进行不必要的重排序。内存屏障可以确保跨越屏障的读写操作不会交叉进行,以此维持程序的内存一致性模型。

在 Java 内存模型(JMM)中,volatile 关键字用于修饰变量时,能够保证该变量的可见性和有序性。关于有序性,volatile 通过内存屏障的插入来实现:

  • 写内存屏障(Store Barrier / Write Barrier): 当线程写入 volatile 变量时,JMM 会在写操作前插入 StoreStore 屏障,确保在这次写操作之前的所有普通写操作都已完成。接着在写操作后插入 StoreLoad 屏障,强制所有后来的读写操作都在此次写操作完成之后执行,这就确保了其他线程能立即看到 volatile 变量的最新值。
  • 读内存屏障(Load Barrier / Read Barrier): 当线程读取 volatile 变量时,JMM 会在读操作前插入 LoadLoad 屏障,确保在此次读操作之前的所有读操作都已完成。而在读操作后插入 LoadStore 屏障,防止在此次读操作之后的写操作被重排序到读操作之前,这样就确保了对 volatile 变量的读取总是能看到之前对同一变量或其他相关变量的写入结果。

通过这种方式,volatile 关键字有效地实现了内存操作的顺序性,从而保证了多线程环境下对 volatile 变量的操作遵循 happens-before 原则,确保了并发编程的正确性。

关注我,每天一个小知识