Java 并发编程基础 --volatile

发布于:2023-01-05 ⋅ 阅读:(116) ⋅ 点赞:(0)

在多线程并发编程中 synchronized 和 volatile 都扮演着重要角色,volatile 是轻量级的 synchronized,它在多处理器开发中保证了共享变量的"可见性"。如果 volatile 修饰符使用恰当的话,它比 synchronized 的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

初识 volatile

下面这段代码演示了一个使用 volatile 以及没有使用 volatile 关键字,对于变量结果的影响:

无 volatile 修饰符:

public class VolatileDemo {

	public static boolean stop = false;

	public static void main(String[] args) throws InterruptedException {

		Thread thread = new Thread(() -> {

			int i = 0;

			while (!stop) {

				i++;

			}

		}, "VolatileDemoThread") ;

		thread.start();

		System.out.println("start Thread");

		TimeUnit.SECONDS.sleep(1);

		stop = true;

	}

}

运行结果:

使用 volatile 修饰符:

public class VolatileDemo {

	public volatile static boolean stop = false;

	public static void main(String[] args) throws InterruptedException {

		Thread thread = new Thread(() -> {

			int i = 0;

			while (!stop) {

				i++;

			}

		}, "VolatileDemoThread") ;

		thread.start();

		System.out.println("start Thread");

		TimeUnit.SECONDS.sleep(1);

		stop = true;

	}

}

 运行结果:

由上面可以看到,在没有使用 volatile 修饰符的情况下,即使我们将 stop 改成 true,线程也并没有终止,而是一直在运行。当我们使用了 volatile 修饰符的时候,线程很快就终止了。由此可以看出,volatile 可以使得在多处理器环境下保证了共享变量的可见性。

到底什么是可见性呢?我们可以思考一个问题,在单线程环境下,如果向一个变量写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情,但是在多线程环境下,读和写操作发生在不同线程中的时候,可能会出现读线程不能及时读取到其它线程写入的最新的值。这就是所谓的可见性问题,可见性的意思是当一个线程修改一个共享变量时,另外的线程能够读取到这个修改的值。为了实现跨线程写入的内存可见性,必须使用到一些机制来实现,而 volatile 就是这样一种机制。

volatile 关键字如何保证可见性

我们通过工具获取 JIT 编译器生成的汇编指令来查看对 volatile 进行写操作时,CPU 做了什么:

Java 代码如下:

instance = new Singleton();

 转换成汇编指令:

0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);

在输出结果中我们可以发现,成员变量带有 volatile 时,会多一条 lock 指令。lock 是一种控制指令,在多处理器环境下,lock 会引发下面两件事:

  1. 将当前处理器缓存行中的数据写回到系统内存

  2. 这个写回内存的操作会使在其它 CPU 里缓存了该内存地址的数据无效

为了更好的理解可见性的本质,我们需要从硬件层面进行梳理。

一台计算机中最核心的组件是 CPU、内存以及 I/O 设备。在整个计算机的发展历程中,除了 CPU、内存以及 I/O 设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度上的差异。CPU 的计算速度是非常快的,内存次之,最后是 I/O 设备。而在绝大部分程序中,一定会存在内存访问,有可能有些还会存在 I/O 设备的访问。

为了提升性能,CPU 从单核升级到了多核甚至用到了超线程技术来最大化提高 CPU 的处理性能,但是仅仅提升 CPU 性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做了很多优化:

  1. CPU 增加了高速缓存

  2. 操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率

  3. 编译器的指令优化,更合理的利用好 CPU 告诉缓存

然而,每一种优化都会带来相应的问题,这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们必须要了解这些优化的过程。

CPU 高速缓存

线程是 CPU 调度的最小单元,线程设计的目的仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器就能完成,处理器还需要于内存进行交互,比如读取运算数据、存储运算结果,这些 I/O 操作很难消除的。而由于计算机存储设备与处理器的运算速度差距很大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中, 让运算能快速的进行,当运算结束后在从缓存同步到内存中。

通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更好的复杂度,因为它引入了一个新的问题:缓存一致性

什么叫缓存一致性呢?

首先,有了高速缓存的存在以后,每个 CPU 的处理过程是先将计算需要用到的数据缓存到高速缓存中,在 CPU 进行计算时,直接从高速缓存中读取数据并且在计算完成后写到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。

由于在多 CPU 环境中,每个线程可能运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存数据,同一份数据可能会缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。

为了解决缓存不一致的问题,在 CPU 层面做了很多事情,主要提供两种解决方案:

  1. 总线锁

  2. 缓存锁

总线锁:简单来说就是在多 CPU 环境下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问共享内存中的数据,总线锁把 CPU 和内存之间的通信给锁住了,这使得锁定期间其他处理器不能操作其他内存地址的数据,所以总线锁开销比较大,这种机制显然不是最合适的。

最好的方式就是控制锁的保护力度,我们只需要保证对于被多个 CPU 缓存的同一份数据是一致的就行,所以引入了缓存锁,它的核心机制是基于缓存一致性协议来实现的。

缓存一致性协议:为了达到数据访问的一致,需要各个处理器在访问缓存数据时遵守一些协议,在读写时根据协议来操作,常见的协议有 MSI、MESI、MOSI 等。最常见的就是 MESI 协议,MESI 表示缓存行的四种状态,分别是:

  1. M (Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存中的数据和主内存中的数据不一致

  2. E (Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改

  3. S (Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致

  4. I (Invalid) 表示缓存已经失效

在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听其它 Cache 的读写操作。

对于 MESI 协议,从 CPU 的角度来说会遵循以下原则:

CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主内存中读取数据

CPU 写请求:缓存处于 M、E 状态才可以被写;对于 S 状态的写,需要将其它 CPU 中缓存行设置成无效后才可以写

使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这个结构,从而达到缓存一致性效果:

由于 CPU 高速缓存的出现使得如果多个 CPU 同时缓存了相同的共享数据时,可能存在可见性问题。也就是 CPU0 修改了自己本地缓存的值对于 CPU1 不可见。不可见导致的后果是 CPU1 后续在对该数据进行写入操作时,使用的是脏数据,使得最终结果不可预测。

可能我们想用代码模拟一下可见性的问题,实际上这种情况很难模拟,因为我们无法让某个线程在某个指定的 CPU 中运行,这是系统底层的算法,JVM 也无法控制;还有最重要的一点,就是我们无法预测 CPU 缓存什么时候会把值同步回主内存,这个时间可能非常短,短到你无法感知;最后就是线程的执行顺序问题,你无法控制哪个线程的某句代码会在另外一个线程的某句代码后面马上执行,所以我们只能基于它的原理去理解这样一个存在的客观事实。

但是我们可能有个疑问,不是说基于缓存一致性协议或者总线锁可以达到缓存一致性的要求吗?为什么还要加 volatile 关键字?

MESI 协议虽然可以实现缓存一致性,但是也会存在一些问题,就是各个 CPU 缓存行的状态是通过消息传递来进行的,如果 CPU0 要对一个在缓存中的共享变量进行写入,首先需要发送一个失效的消息给其它缓存了该数据的 CPU,并且要等到他们的确认回执,CPU0 在这段时间都会处于阻塞状态。为了避免阻塞带来的资源浪费,在 CPU 中引入了 Store Buffers。CPU0 只需要在写入共享数据时,直接把数据写入到 store buffers 中,同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他所有 CPU 发送了 invalidate acknowledge 消息时,再将 store buffers 中的数据存储至 cache line 中,最后再从缓存行同步到主内存。

但是,这种优化存在两个问题:

  1. 数据什么时候提交是不确定的,因为等待其他 CPU 给回复才会进行数据行同步,这里其实是一个异步操作

  2. 引入了 store buffer 后,处理器会优先从 store buffer 中获取值,如果 store buffer 中有数据,则直接从 store buffer 中获取,否则再从缓存行获取

我们看一下例子:

value = 3;

isFinish = false;

void exeToCpu0() {

	value = 10;

	isFinish = true;

}

void exeToCpu1() {

	if (isFinish) {

		assert value == 10;

	}

}

 exeToCpu0 和 exeToCpu1 分别在两个独立的 CPU 中执行,假如 CPU0 中的缓存行中缓存了 isFinish 这个共享变量,并且状态为 E,而 Value 可能是 S 状态。那么在 CPU0 执行的时候,会先把 value=10 的指令写入到 store buffer 中,并且通知其他缓存了该 value 变量的 CPU,在等待其他 CPU 通知结果的时候,CPU0 会执行 isFinish=true 这个指令。而因为当前 CPU0 缓存了 isFinish 并且状态是 E,所以可以直接修改 isFinish=true,这个时候 CPU1 发起了 read 操作去读取 isFinish 的值可能为 true,但 value 不为 10。

这种情况我们可以认为是 CPU 的乱序执行,也可以认为是一种指令重排序,而这种重排序会带来可见性问题。所以在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面看这个 memory barrier 就是 CPU flush store buffer 中的指令。

什么是内存屏障

内存屏障就是将 store buffer 中的指令写入到内存,从而使得其他访问统一共享内存的线程可见。X86 的 memory barrier 指令包括 Ifence(读屏障), Sfence(写屏障),mfence(全屏障)。

写屏障告诉处理器在写屏障之前的所有已经存储在存储缓存(store buffer)中的数据同步到主内存,简单来说就是使得写屏障之前的指令对写屏障之后的读或者写是可见的

读屏障告诉处理器在读屏障之后的操作都在读屏障之后执行,配合写屏障,使得写屏障之前的所有内存更新对于读屏障之后的读操作可见

全屏障确保屏障前内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

有了内存屏障之后,我们可以这么修改上面的例子去避免可见性问题:

value = 3;

isFinish = false;

void exeToCpu0() {

	value = 10;

	storeMemoryBarrier();

	isFinish = true;

}

void exeToCpu1() {

	if (isFinish) {

	  loadMemoryBarrier();

		assert value == 10;

	}

}

 总的来说,内存屏障作用可以通过防止 CPU 对内存的乱序来保证共享数据在多线程环境下的可见性。现在回到我们最开始提到的 volatile 关键字的字节码,这个关键字会生成一个 Lock 的汇编指令,这个指令就相当于实现了一种内存屏障,这样就可以确保被 volatile 关键字修饰的共享变量对所有 CPU 可见。

总结 & 提醒

最后给大家提一嘴,volatile 主要作用是保证可见性以及有序性,但是 volatile 是不能保证原子性的!

也就是说,volatile 主要解决的是一个线程修改变量值之后,其他线程立马可以读到最新的值,是解决这个问题的,也就是可见性!但是如果是多个线程同时修改一个变量的值,那还是可能出现多线程并发的安全问题,导致数据值修改错乱,volatile 是不负责解决这个问题的,也就是不负责解决原子性问题!原子性问题,得依赖 synchronized、ReentrantLock 等加锁机制来解决。


网站公告

今日签到

点亮在社区的每一天
去签到