Q: 揮発性についてのあなたの理解について話してください。
回答: volatile は、Java 仮想マシンによって提供される軽量の同期メカニズムです。これには 3 つの特徴があります:
1) 可視性の保証
2) アトミック性は保証されません 性別
3)命令の再配置を禁止する
Java の基礎を学び終えたところですが、揮発性とは何ですか? と尋ねられたら?何か機能があるとしたら、とても混乱していると思います...
回答を読んでもまったく理解できないかもしれませんが、同期メカニズムとは何ですか?可視性とは何ですか?原子性とは何ですか?命令の並べ替えとは何ですか?
可視性とは何かを理解するには、まず JMM を理解する必要があります。
JMM (Java Memory Model、Java メモリ モデル) 自体は抽象的な概念であり、実際には存在しません。一連の規則または仕様を記述したもので、この一連の仕様によってプログラム内のさまざまな変数へのアクセス方法が決定されます。同期に関する JMM の規則:
1) スレッドがロック解除される前に、共有変数の値がメイン メモリに更新される必要があります;
2) スレッドがロックされる前に、メイン メモリの最新の値が更新されなければなりません独自の作業メモリに読み込まれます;
3) ロックとロック解除は同じロックです;
JVM 実行プログラムのエンティティはスレッドであるため、各スレッドが作成されると、JMM はワーキングメモリ(スタックスペースと呼ばれることもあります)のこと、ワーキングメモリは各スレッドのプライベートデータ領域です。
Java メモリ モデルでは、すべての変数がメイン メモリに格納されることが規定されており、メイン メモリはすべてのスレッドからアクセスできる共有メモリ領域です。
ただし、変数に対するスレッドの操作 (読み取り、割り当てなど) は作業メモリ内で実行する必要があります。まず、変数をメイン メモリから作業メモリにコピーし、演算を実行して、メイン メモリに書き戻す必要があります。
上記の JMM の紹介を読んでも、その利点についてまだ混乱しているかもしれません。例としてチケット販売システムを使用してみましょう:
1) 以下に示すように、現時点ではバックエンドチケット販売システムのみ チケットが 1 枚残っており、メイン メモリに読み込まれています: ticketNum=1。
2) この時点で、ネットワーク上にはチケットを取得しているユーザーが複数存在するため、同時にチケット購入サービスを実行しているスレッドが複数存在し、現在のチケット数を読み取ったスレッドが 3 つあるとします。 ticket: ticketNum. =1 の場合、チケットを購入します。
3) スレッド 1 が最初に CPU リソースを占有し、最初にチケットを購入し、独自の作業メモリ内で ticketNum の値を 0 (ticketNum=0) に変更してから、それをメイン メモリに書き戻すと仮定します。
現時点では、スレッド 1 のユーザーはすでにチケットを購入しているため、スレッド 2 とスレッド 3 は現時点でチケットの購入を続行できないはずです。そのため、システムはスレッド 2 とスレッド 3 に通知する必要があります。 ticketNum は 0 になります (ticketNum=0)。このような通知操作があれば、可視性があると理解できます。
JMM の上記の紹介と例を通じて、JMM を簡単に要約することができます。
JMM メモリ モデルの可視性とは、複数のスレッドがメイン メモリ内のリソースにアクセスするときに、スレッドが独自の作業メモリ内のリソースを変更してメイン メモリに書き戻すと、JMM メモリがモデルは、最新のリソースの可視性を確保するために、他のスレッドに最新のリソースを再取得するように通知する必要があります。
セクション 1.1 で可視性の定義を基本的に理解したので、コードを使用してその定義を検証できるようになりました。実際に、volatile を使用すると確実に可視性を確保できることが証明されています。
まず、volatile が使用されていない場合に非可視性があるかどうかを検証します。
package com.koping.test;import java.util.concurrent.TimeUnit;class MyData{ int number = 0; public void add10() { this.number += 10; }}public class VolatileVisibilityDemo { public static void main(String[] args) { MyData myData = new MyData(); // 启动一个线程修改myData的number,将number的值加10 new Thread( () -> { System.out.println("线程" + Thread.currentThread().getName()+"\t 正在执行"); try{ TimeUnit.SECONDS.sleep(3); } catch (Exception e) { e.printStackTrace(); } myData.add10(); System.out.println("线程" + Thread.currentThread().getName()+"\t 更新后,number的值为" + myData.number); } ).start(); // 看一下主线程能否保持可见性 while (myData.number == 0) { // 当上面的线程将number加10后,如果有可见性的话,那么就会跳出循环; // 如果没有可见性的话,就会一直在循环里执行 } System.out.println("具有可见性!"); }}
実行結果は以下のとおりです。スレッド 0 が数値の値を 10 に変更しましたが、この時点では数値が表示されておらず、システムがシステムに影響を与えているため、メインスレッドはまだループ内にあることがわかります。積極的に通知しません。
上記のコードの 7 行目で、以下に示すように、変数番号に volatile を追加して再度テストします。メインスレッドの時間 JMM がメインスレッドに数値の値を更新するよう積極的に通知し、数値が 0 でなくなったため、ループが正常に終了しました。
上記の可視性を理解した後、アトミック性とは何かを理解しましょう。
原子性とは、分割したり中断したりすることができず、完全性を維持する特性を指します。つまり、スレッドが操作を実行しているときは、いかなる要因によっても中断されることはできません。同時に成功するか、同時に失敗するかのどちらかです。
まだ少し抽象的ですが、例を挙げてみましょう。
以下に示すように、アトミック性をテストするためのクラス TestPragma が作成されます。コンパイルされたコードは、add メソッドでの n の増加が 3 つの命令によって完了することを示しています。
因此可能存在线程1正在执行第1个指令,紧接着线程2也正在执行第1个指令,这样当线程1和线程2都执行完3个指令之后,很容易理解,此时n的值只加了1,而实际是有2个线程加了2次,因此这种情况就是不保证原子性。
在2.1中已经进行了举例,可能存在2个线程执行n++的操作,但是最终n的值却只加了1的情况,接下来对这种情况再用代码进行演示下。
首先给MyData类添加一个add方法
package com.koping.test;class MyData { volatile int number = 0; public void add() { number++; }}
然后创建测试原子性的类:TestPragmaDemo。验证number的值是否为20000,需要测试通过20个线程分别对其加1000次后的结果。
package com.koping.test;public class TestPragmaDemo { public static void main(String[] args) { MyData myData = new MyData(); // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000 for (int i=0; i<20; i++) { new Thread(() -> { for (int j=0; j<1000; j++) { myData.add(); } }).start(); } // 程序运行时,模型会有主线程和守护线程。如果超过2个,那就说明上面的20个线程还有没执行完的,就需要等待 while (Thread.activeCount()>2){ Thread.yield(); } System.out.println("number值加了20000次,此时number的实际值是:" + myData.number); }}
运行结果如下图,最终number的值仅为18410。
可以看到即使加了volatile,依然不保证有原子性。
上面介绍并证明了volatile不保证原子性,那如果希望保证原子性,怎么办呢?以下提供了2种方法
方法1是在add方法上添加synchronized,这样每次只有1个线程能执行add方法。
结果如下图,最终确实可以使number的值为20000,保证了原子性。
但在实际业务逻辑方法中,很少只有一个类似于number++的单行代码,通常会包含其他n行代码逻辑。现在为了保证number的值是20000,就把整个方法都加锁了(其实另外那n行代码,完全可以由多线程同时执行的)。所以就优点杀鸡用牛刀,高射炮打蚊子,小题大做了。
package com.koping.test;class MyData { volatile int number = 0; public synchronized void add() { // 在n++上面可能还有n行代码进行逻辑处理 number++; }}
给MyData新曾一个原子整型类型的变量num,初始值为0。
package com.koping.test;import java.util.concurrent.atomic.AtomicInteger;class MyData { volatile int number = 0; volatile AtomicInteger num = new AtomicInteger(); public void add() { // 在n++上面可能还有n行代码进行逻辑处理 number++; num.getAndIncrement(); }}
让num也同步加20000次。可以将原句重写为:使用原子整型num可以确保原子性,如下图所示:在执行number++时不会发生竞态条件。
package com.koping.test;public class TestPragmaDemo { public static void main(String[] args) { MyData myData = new MyData(); // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000 for (int i=0; i<20; i++) { new Thread(() -> { for (int j=0; j<1000; j++) { myData.add(); } }).start(); } // 程序运行时,模型会有主线程和守护线程。如果超过2个,那就说明上面的20个线程还有没执行完的,就需要等待 while (Thread.activeCount()>2){ Thread.yield(); } System.out.println("number值加了20000次,此时number的实际值是:" + myData.number); System.out.println("num值加了20000次,此时number的实际值是:" + myData.num); }}
在第2节中理解了什么是原子性,现在要理解下什么是指令重排?
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排:
源代码–>编译器优化重排–>指令并行重排–>内存系统重排–>最终执行指令
处理器在进行重排时,必须要考虑指令之间的数据依赖性。
单线程环境中,可以确保最终执行结果和代码顺序执行的结果一致。
但是多线程环境中,线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测。
看了上面的文字性表达,然后看一个很简单的例子。
比如下面的mySort方法,在系统指令重排后,可能存在以下3种语句的执行情况:
1)1234
2)2134
3)1324
以上这3种重排结果,对最后程序的结果都不会有影响,也考虑了指令之间的数据依赖性。
public void mySort() { int x = 1; // 语句1 int y = 2; // 语句2 x = x + 3; // 语句3 y = x * x; // 语句4}
看完指令重排的简单介绍后,然后来看下单例模式的代码。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()"); } public static SingletonDemo getInstance() { if (instance == null) { instance = new SingletonDemo(); } return instance; } public static void main(String[] args) { // 单线程测试 System.out.println("单线程的情况测试开始"); System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance()); System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance()); System.out.println("单线程的情况测试结束\n"); }}
首先是在单线程情况下进行测试,结果如下图。可以看到,构造方法只执行了一次,是没有问题的。
接下来在多线程情况下进行测试,代码如下。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()"); } public static SingletonDemo getInstance() { if (instance == null) { instance = new SingletonDemo(); } // DCL(Double Check Lock双端检索机制)// if (instance == null) {// synchronized (SingletonDemo.class) {// if (instance == null) {// instance = new SingletonDemo();// }// }// } return instance; } public static void main(String[] args) { // 单线程测试// System.out.println("单线程的情况测试开始");// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println("单线程的情况测试结束\n"); // 多线程测试 System.out.println("多线程的情况测试开始"); for (int i=1; i<=10; i++) { new Thread(() -> { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } }}
在多线程情况下的运行结果如下图。可以看到,多线程情况下,出现了构造方法执行了2次的情况。
在3.3中的多线程单里模式下,构造方法执行了两次,因此需要进行改进,这里使用双端检锁机制:Double Check Lock, DCL。即加锁之前和之后都进行检查。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()"); } public static SingletonDemo getInstance() {// if (instance == null) {// instance = new SingletonDemo();// } // DCL(Double Check Lock双端检锁机制) if (instance == null) { // a行 synchronized (SingletonDemo.class) { if (instance == null) { // b行 instance = new SingletonDemo(); // c行 } } } return instance; } public static void main(String[] args) { // 单线程测试// System.out.println("单线程的情况测试开始");// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println("单线程的情况测试结束\n"); // 多线程测试 System.out.println("多线程的情况测试开始"); for (int i=1; i<=10; i++) { new Thread(() -> { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } }}
在多次运行后,可以看到,在多线程情况下,此时构造方法也只执行1次了。
DCL バージョン 3.4 のシングルトン モードはまだ 100% 正確ではないことに注意してください。 ! !
シングルトン モードの 3.4DCL バージョンが 100% 正確ではない理由がよくわかりませんか?
3.1 で命令の再配置について簡単に理解した後、なぜ突然マルチスレッド シングルトン モードについて話さなければならないのかよく分かりませんか?
シングルトン モードの 3.4DCL バージョンでは命令の再配置により問題が発生する可能性があるため、この問題が発生する可能性は 1,000 万分の 1 ですが、コードは依然として 100% 正確ではありません。 100% の精度を確保したい場合は、volatile キーワードを追加する必要があります。volatile を追加すると、命令の再配置が禁止される可能性があります。
次に、シングルトン モードの 3.4DCL バージョンが 100% 正確ではない理由を分析しましょう。 View インスタンス = new SingletonDemo(); コンパイルされた命令は、次の 3 つのステップに分割できます: 1) オブジェクトのメモリ領域を割り当てます:memory = assign();
2) オブジェクトを初期化します。 :instance(memory);
3) 割り当てられたメモリアドレスを指すようにインスタンスを設定します:instance =memory;
たとえば、スレッド 1 はステップ 13 を実行しましたが、ステップ 2 は実行していません。この時点では、instance!=null ですが、オブジェクトはまだ初期化されていません;
この時点でスレッド 2 が CPU を占有した場合、その後findsinstance!=null、その後直接使用に戻ると、インスタンスが空であることがわかり、例外が発生します。
1) 特定の操作の実行順序を保証します;
2) 特定の変数のメモリ可視性を保証します;
はメモリ バリアを挿入することにより、メモリ バリアの前後の命令を禁止します。再配置を行うには が必要です。
メモリ バリアのもう 1 つの機能は、さまざまな CPU のキャッシュ データを強制的にフラッシュして、CPU 上のどのスレッドもこれらのデータの最新バージョンを読み取れるようにすることです。以上がJava の基本の揮発性アプリケーション例の分析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。