23:volatileキーワード:可変の可視性と並べ替えの禁止

この記事は、「新人クリエーションセレモニー」イベントに参加し、一緒にゴールドクリエーションの道を歩み始めました。

Volatileは、スレッドによって取得された変数値が常に最新であることを保証するために、わずかに弱い同期メカニズムを提供します。

volatileによって変更された変数には、変数の可視性と並べ替えが禁止されているという2つの特性があります。

1つの変数が表示されます

1.1可変可視性とは

変数の可視性とは、変数がすべてのスレッドに表示され、揮発性変数がレジスタにキャッシュされたり、他のプロセッサに表示されたりしないことを意味します。したがって、スレッドが変数の値を変更すると、新しい値が他のスレッドに表示されます。すぐに利用できます。

1.2可変の可視性に問題があるのはなぜですか

コンピュータの実行中、命令はCPUによって実行され、データはメモリに保存されます。CPUの実行速度に比べて、メモリ内のデータの読み取りと書き込みの速度ははるかに遅く、データ操作がメモリの読み取りに依存している場合、CPUの実行速度は遅くなります。この状況に対処するために、CPU内にキャッシュ領域があります。プログラムの実行プロセス中に、使用されるデータがメモリからCPUのキャッシュ領域にコピーされます。データを取得します。 、実行が完了した後、キャッシュ領域のデータをメモリにフラッシュします。

シングルスレッド実行では問題はありませんが、複数のスレッドが変数を共有すると問題が発生する可能性がありますここに画像の説明を挿入

図に示すように、Thread1とThread2は変数iを共有し、両方ともi=i+1命令を実行すると想定されています。期待される結果は、実行後のiの値が2になることです。しかし、そのようなシナリオがあります。CPU1とCPU2の両方がメインメモリから独自のキャッシュにi = 0の値を読み取り、スレッドi=i+11の実行後にCPU1がi = 1を取得し、次にiをメインメモリにフラッシュします。これはCPU2が実行されるときです。スレッド2、i = 0はCPU2のキャッシュに格納されているため、CPU2はキャッシュ内のデータを直接実行し、結果はi = 0 + 1、1のままです。その後、CPU2はiの値をメインメモリにフラッシュします。この時点で、Thread1とThread2の両方が実行されていることがわかりますが、メインメモリのデータは期待される結果2ではなく1です。

1.3揮発性物質の可変可視性

而volatile的变量可见性则会保证volatile修饰的变量读写跳过CPU高速缓存这一步,即CPU1执行完毕后会将i的值直接更新到主存中,而CPU2取i的值时则直接从主存中取。如此就解决了上面两个线程串行执行,结果依然不是期望值的问题。

但是这并不意味着volatile修饰后的i就能保证i=i+1的执行同步了。多线程同时对i进行计算依然会存在同步问题,这是因为i=i+1这一计算过程分为三步:

  1. 拿到i的值
  2. i和1执行加算
  3. 将计算出来的值赋值到i中

volatile修饰后变量i只是保证第一步拿到i的值总是最新的,以及第三步i更新后总是能及时更新主存中以供其他线程获取最新值。但是却不能保证两个线程串行执行。因此要想保证i=i+1计算的同步依然需要更重的synchronized来实现。

2 禁止重排序

2.1 什么是重排序

java在运行过程中有两个地方可能用到重排序:一个是编译器编译的时候,一个是处理器运行的时候。

重排序是指编译器或者处理器为了提高处理,会对程序进行优化,最终导致实际的执行顺序与程序的定义顺序不一致。这不意味着重排序是任意无限制的,那样的话程序就乱套了。重排序所实现的优化必须保证不会影响单线程下程序的执行结果,即as-if-serial语义。

如以下程序:

int a = 1;  // step1
int b = 2;	// step2
int c = a + b;	// step3
复制代码

为了遵守as-if-serial语义,编译器或处理器不会对存在数据依赖的操作进行重排序如step3依赖于step1和step2,因为这种排序会会影响执行结果,但是对于不存在数据依赖的操作,编译器或处理器可能对其进行重排序,如step2和step1之前不存在数据依赖关系,因此以上程序的运行顺序可能是step1-step2-step3,也可能是step2-step1-step3。

2.2 为什么要重排序

2.2.1 编译器为什么要重排序

举个例子:张三要做两件事,一件事是水壶接满水并放到炉子上,一件事是把垃圾装到袋子里并丢到楼下垃圾桶里。很显然【接水-烧水-收拾垃圾-丢垃圾】效率会比【接水-收拾垃圾-烧水-丢垃圾】效率更高,因为水壶接完水就在手上,垃圾收拾完也在手上,都是可以直接进行下一步的。但是如果步骤定义的过程中顺序是【接水-收拾垃圾-烧水-丢垃圾】呢,这时为了更高的效率,就会进行重排序,在不影响结果的情况下调整做事顺序。

用代码来体现就是:

int i = 1;  // 接水
int j = 2;	// 收拾垃圾
i = i + 1;	// 烧水
j = j + 1;	// 丢垃圾
复制代码

int i = 1;  // 接水
i = i + 1;	// 烧水
int j = 2;	// 收拾垃圾
j = j + 1;	// 丢垃圾
复制代码

很明显后者的执行效率要比前者高,因为定义的变量在寄存器可以直接操作。但是为什么开发规范或优化策略中并没有这方面的内容呢?就是因为编译重排序的存在,他会在不改变单线程执行结果的前提下选择更优的执行方式。使得程序编写者可以不用花费心思关注这些。

2.2.2 处理器为什么要重排序

处理期间为什么要进行重排序呢?这是因为一个指令的执行会包含到数个步骤,每个步骤可能用到不同的寄存器。而CPU采用的是流水线技术,也就是说CPU具有多个功能单元(如获取、解码、运算和结果),一条指令也分为多个单元。为了充分的利用CPU的资源,前一条指令执行完当前单元后就开始执行下一个需要当前单元参与的指令,而不会等到前一个指令执行完所有单元才开始执行。指令重排序就是为了使具有相似功能单元的指令能够接连执行。

即时编译器的重排序,处理器的乱序执行,以及内存系统的重排序的存在,都是为了减少流水线中断浪费资源。

举例:

add(int x, int y, int z) {
	int r = 0; 	// step1
	r++;		// step2
	int s = 1;	// step3
	int t = 2; 	// step4
}
复制代码

这种情况下CPU并不会等step2完成++任务之后在执行step3,因为step3对step2没有依赖。而是采用流水线形式,流水线技术下装载单元装载r之后交给运算单元执行r++。因为后需s和t的装载不依赖于r,因此依据流水线的规则装载单元不会等运算单元完成r的运算之后才继续装载而是直接装载s和t, 装载s和t的动作可能在运算单元完成r++之后,此时顺序没有改变,但是也可能在运算单元执行完成r++之前,此时就发生了重排序。流水线技术下CPU的执行效率会大大提升。这便是处理器的重排序,指令并不会完全串行执行,step4和step3可能在step2执行完成前就开始执行了。

2.3 重排序在多线程下的问题

上面提到重排序只保证单线程下的程序执行结果。那么重排序在多线程下会有什么问题呢:

2.3.1 编译器下的重排序可能出现的问题

有如下代码:

int i = 0;
boolean flag = false;
public void setMethod() {
	i = 1;			// step1
	flag = true;	// step2
}
public void calcMethod() {
	if(flag) {		// step3
		int j = i;	// step4
	}
}
复制代码

有Thread1执行setMethod,Thread2执行calcMethod,因为重排序只保证单线程执行的结果,而step1和step2不具有依赖关系,那么就可能存在重排序的情况。但是step1(Thread1) - step3(Thread2) - step4(Thread2) - step2(Thread1)计算出的结果j为0,step1(Thread1) - step2(Thread1) - step3(Thread2) - step4(Thread2)计算出来的结果j为1。重排序虽然没有影响Thread1的结果,但是却影响了存在共享数据的Thread2的结果。但是如果我们修饰利用 volatile的禁止重排序使用volatile修饰flag,flag禁止重排序,step1和step2会按照顺序执行,以上问题就迎刃而解了。

2.3.2 处理器下的重排序可能存在的问题

处理器的重排序同样存在问题,以上文2.3.1 编译器下的重排序可能出现的问题中的示例代码为例,再加一步运算如下: 有如下代码:

int i = 0;
boolean flag = false;
public void setMethod() {
	i = 1;			// step1
	i++;			// step2
	flag = true;	// step3
}
public void calcMethod() {
	if(flag) {		// step4
		int j = i;	// step5
	}
}
复制代码

有Thread1执行setMethod,Thread2执行calcMethod,因为重排序只保证单线程执行的结果,而step2和step3不具有依赖关系,那么就可能存在重排序的情况。但是step1(Thread1) - step3(Thread1) - step4(Thread2) - step5(Thread2) - step2(Thread1)计算出的结果j为1,step1(Thread1) - step2(Thread1) - step3(Thread1) - step4(Thread2) - step5(Thread2)计算出来的结果j为2。重排序虽然没有影响Thread1的结果,但是却影响了存在共享数据的Thread2的结果。但是如果我们修饰利用 volatile的禁止重排序使用volatile修饰flag,flag禁止重排序,step1,step2,step3会按照顺序执行,就不会出现因为重排序导致的计算错误的问题了。同样的这也并不意味着多线程执行代码setMethod就不存在同步问题了,原因则在1.3 volatile的变量可见性已经解释过了,一次加算是分多步的,而volatile的禁止重排序特性也同样只能保证计算时使用的是最新值。那么volatile的使用场景是什么呢?

3 volatile的使用

volatile是一种比synchronized更轻量级的同步机制。volatile只能保证可见性。因此仅适用于以下场景:一个变量被多个线程共享,各个线程直接给这个变量赋值。 volatile的使用需要满足以下两个条件:

  1. 对变量的写操作不依赖当前值,也就是说对该变量的写操作仅仅是单纯的赋值。
  2. 该变量没有包含在具有其他变量的不变式中,也就是说其他变量不能依赖于volatile。只有状态真正独立于程序内的其他内容时才能使用volatile。

上記の2つの制限の理由は、現在の値に依存するということは、値の取得と割り当ての2つのステップを経ることを意味し、不定詞を含めると必然的に操作が必要になるためですが、volatileはアトミック性を保証できません。

4同期には、命令の並べ替えと変数の可視性を禁止する機能がありますか

Synchronizedは、volatileよりも重い同期メカニズムです。Volatileには、並べ替えられた変数の表示を禁止する機能があります。synchronizedにも同じ機能がありますか?答えは、その質問は意味をなさないということです。前述のように、最適化を実現するための並べ替えの原則は、単一スレッドでの実行結果に影響を与えないことです。同期ロックにより、並列スレッドがシリアルに実行されます。これは、スレッドがすべてシングルスレッド実行環境にあるのと同じです。シングルスレッド環境で並べ替えられるかどうかは重要ではなく、最終結果に影響を与えません。スレッドのセキュリティを確保します。可視性の観点から、ローカルメモリ内の変数を同期的に変更した後、ローカルメモリの変更されたコンテンツは、ロックを解除する前にメインメモリに更新されます。これにより、共有変数の値が最新になり、可視性。したがって、同期を使用する場合は、共有変数にvolatileを使用する必要はありません。

PS:
開発と成長の旅[継続的に更新...]
最初のナビゲーション:22:スレッドコントローラーセマフォ-ナゲッツ(juejin.cn)
次のナビゲーション:24:スレッド間の変数共有-ナゲッツ(juejin.cn)
ようこそ従う...

参照:
なぜ命令を並べ替えるのですか?
同期には命令の並べ替えを禁止する機能がありますか?
Java並行プログラミングの技術[3]-[2]並べ替えと逐次一貫性
Java並行プログラミング:揮発性キーワード分析

おすすめ

転載: juejin.im/post/7079382632777121806