並行プログラミング - 可視性の背後にある本質と vlotile の原則を探る

視認性

シングルスレッド環境で、最初に変数に値を書き込み、次に書き込み干渉なしで変数の値を読み取ると、この時点で読み取られる変数の値は、値の前に書き込まれた値になります。これはごく普通のことだったでしょう。ただし、マルチスレッド環境では、読み取りと書き込みが異なるスレッドで発生すると、読み取りスレッドが他のスレッドによって書き込まれた最新の値をタイムリーに読み取ることができない場合があります。これを視認性といいます

見えない原因

非表示には 2 つの理由があります。1 つはキャッシュの一貫性で、もう 1 つは命令の並べ替えです。

キャッシュの一貫性

下の図に示すように、オペレーティング システムのキャッシュ アーキテクチャを見ることができます.オペレーティング システムのキャッシュ アーキテクチャ.png元のシステム設計者は、データ アクセスの速度とパフォーマンスを向上させるために第 3 レベルのキャッシュを設計しました (キャッシュが CPU に近いため、アクセス速度は高速です)。 、および第3レベルキャッシュの周波数と帯域幅が高く、すべてCPUチップ上にある一方で、メインメモリはバスなどの接続を介してプロセッサと通信する必要があり、アクセス遅延は比較的高いです)

この第 3 レベルのキャッシュでは、パフォーマンスを向上させるために、各スレッドが独自の作業メモリ、つまりキャッシュを持っています. スレッドが共有変数の値を変更する場合、最初にその値を独自の作業メモリに格納することがあります.すぐにメイン メモリに書き戻されるわけではありません。他のスレッドが共有変数を読み取るとき、メイン メモリから最新の値を取得する代わりに、独自の作業メモリから値を読み取る場合があります。この場合、1 つのスレッドが共有変数の値を変更すると、他のスレッドは変更をすぐに認識できず、非表示の問題が発生する可能性があります。

キャッシュの一貫性によって引き起こされる不可視性を解決するために、システム レベルはバス ロックとキャッシュ ロック バス
ロックを提案します。プロセッサの 1 つが共有メモリで操作したい場合、プロセッサはバス上で LOCK# 信号を送信します。バスを介して共有メモリ内のデータにアクセスすることはできません.バスロックはCPUとメモリ間の通信をロックします.ロック中は他のプロセッサが他のメモリアドレスのデータを操作することはできません.バスロックのオーバーヘッドは比較的大きいこのメカニズムは明らかに不適切です。

缓存锁:就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执 行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作 的原子性。缓存锁的实现方式有多种,其中比较常见的是MESI(Modified, Exclusive, Shared, Invalid)协议。MESI协议定义了缓存行的四种状态:修改(Modified)、独占(Exclusive)、共享(Shared)和无效(Invalid)。处理器在执行读写操作时,会根据缓存行的状态来确定是否需要使用缓存锁,以保证数据的一致性

下面是MESI协议的四种状态和其含义:

  • Modified(修改):缓存行被修改且未写回内存。在该状态下,缓存行是处理器私有的,其他处理器无法缓存该数据。如果该缓存行被写回内存,状态会转换为Shared或Invalid。

  • Exclusive(独占):缓存行只存在于当前处理器的缓存中,未被其他处理器缓存。该数据是一致的,其他处理器可以通过缓存一致性协议来读取数据。

  • Shared(共享):缓存行被多个处理器缓存,且数据是一致的。多个处理器可以同时缓存该数据,读取操作不会修改缓存行的内容。如果某个处理器要修改数据,则会将缓存行状态转换为Modified,并阻止其他处理器的读访问。

  • Invalid(无效):缓存行无效,需要从内存中读取最新数据。这种状态发生在其他处理器修改了共享数据,并将其标记为Invalid,通知其他处理器需要重新从内存中获取最新数据。

MESI协议的实现原理如下:

  • 当处理器读取一个缓存行时,会首先检查缓存行的状态:

    • 如果状态是Modified或Exclusive,表示缓存中的数据是一致的,可以直接读取。
    • 如果状态是Shared,表示其他处理器也在缓存该数据,可以直接读取。
    • 如果状态是Invalid,表示缓存行无效,需要从内存中获取最新数据。
  • 当处理器要写入一个缓存行时,会根据以下情况进行处理:

    • 如果状态是Modified,表示缓存中的数据已被修改,可以直接写入。
    • 如果状态是Exclusive,表示缓存中的数据是一致的,可以直接写入,并将状态转换为Modified。
    • 如果状态是Shared,表示其他处理器也在缓存该数据,需要进行协调。
      • 处理器会发出一个写的信号,通知其他处理器将该数据的缓存行状态转换为Invalid,从而使其他处理器重新从内存中读取最新数据。
      • 处理器将缓存行状态转换为Modified,表示该数据被修改,并且只有自己能够缓存该数据。
  • 在状态转换时,缓存一致性协议会使用总线或其他互联机制来进行通信,以确保各个处理器的缓存状态保持一致。

指令重排序

针对上面的缓存一致性协议我们提出一个这样的例子比如两个cpu异步访问两个参数如下伪代码:

executeToCPU0(){ 
   x = 1; 
   flag = true;
}
executeToCPU1(){
   while(flag){
     assert(x==1)
   }
}
复制代码

可以发现有可能会抛出异常,安装我们正常的思维while循环进来的时候 x应该是等于1的所以应该没问题。为什么会有可能抛出异常呢?就是因为处理器对内存写入操作的效率的提高引出了存储缓冲器(Store Buffers):
存储缓冲器的工作原理如下:

  1. 当处理器执行写操作时,写入的数据和目标内存地址会被缓存到存储缓冲器中,而不是立即写入内存。
  2. 处理器可以继续执行后续的指令,而不需要等待写入操作完成。
  3. 在后续的阶段,处理器会根据一定的策略将存储缓冲器中的写操作提交到内存中。这个提交的过程通常发生在特定的点,如内存屏障指令、条件分支或是其他内部机制触发的时候。
  4. 内存子系统负责将存储缓冲器中的写操作同步到实际的内存位置。

因为Store Buffers的存在也就有可能出现flag = true;先执行x = 1;后执行也就是指令重排序 如下图所示: 無題ファイル (Ultra HD).jpeg 这个Store Buffers通俗点讲就是一个mq就是我们把写的操作丢到mq里面不耽误下面代码的执行

针对于指令重排序的问题系统又提出了内存屏障来禁止指令重排序。

内存屏障分为两种类型:读屏障(Load Barrier)和写屏障(Store Barrier)。

  • 读屏障(Load Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏 障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的

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

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

executeToCPU0(){ 
   x = 1; 
   //storeBarrier()写屏障,写入到内存
   flag = true;
}
executeToCPU1(){
   while(flag){
     //loadBarrier(); //读屏障
     assert(x==1)
   }
}
复制代码

这样也就可以保证指令不会被重排

JMM

JMM(Java Memory Model,Java内存模型)是Java语言规范中定义的一种规范,用于描述Java程序在多线程环境下的内存访问行为。JMM 定义了线程如何与主内存和工作内存进行交互,以及如何保证多线程程序的正确性。

JMM 主要关注以下几个方面:

  1. 主内存(Main Memory):主内存是所有线程共享的内存区域,包含了程序的变量和数据。所有线程都可以读写主内存中的数据。(也就是内存)
  2. 工作内存(Working Memory):每个线程都有自己的工作内存,工作内存是线程私有的内存区域。线程执行时,它的读写操作都是在工作内存中进行的。(对应这cpu高度缓存)
  3. 内存间的交互:线程之间通过主内存进行通信。当一个线程修改了共享变量的值时,它必须将该值写回主内存。其他线程在读取该共享变量时,会从主内存中获取最新的值。
  4. 原子性、可见性和有序性:JMM 定义了一系列规则和特性来保证多线程程序的正确性。其中包括原子性(Atomicity):对基本类型的读写具有原子性;可见性(Visibility):一个线程对共享变量的修改对其他线程可见;有序性(Ordering):程序的执行结果与代码的编写顺序保持一致。

JMM 提供了一套规范,确保多线程程序在不同的平台和实现中表现一致。同时,它也提供了一些同步机制(如锁、volatile关键字、synchronized关键字、原子类等)来帮助程序员编写正确且高效的多线程代码。

需要注意的是,虽然 JMM 提供了一定的保证,但在编写多线程程序时,仍然需要程序员根据具体情况使用适当的同步机制,以确保线程安全性和正确性。

vloatile原理

上文说了那么多其实大致的原理大家应该也清楚了无非就是告诉系统需要添加内存屏障,使用系统的内存屏障来实现防止指令重排序。
这里我们可以简单的写一个demo验证一下:

public class TestVolatile {

    public static volatile int  x = 1;

    public static void main(String[] args){
        x = 2;
        System.out.println(x);
    }
}
复制代码

我们看一下编译后的字节码文件 画像.png 发现使用vloatile修饰的变量会多一个ACC_VOLATILE的标记
我们再看一下字节码命令如下: 画像.png 这个时候我们再去jvm源码里面看putstatic源码如下(具体位置/hotspot/src/share/vm/interpreter路径下的bytecodeInterpreter.cpp) 画像.png 至此vloatile原理也就比较清晰了

Happens-Before模型

Happens-Before模型是Java内存模型(JMM)中定义的一种偏序关系,用于描述并发程序中不同操作之间的可见性和顺序性规则。它是JMM中的一个重要概念,用于指导程序员编写正确且具有可预测行为的多线程代码。

Happens-Before模型的基本原则是,如果一个操作" happens-before"另一个操作,那么第一个操作的结果对于第二个操作是可见的,而且第一个操作一定在第二个操作之前执行。

Happens-Before关系的规则包括:

  1. 程序次序规则(Program Order Rule):同一个线程中的操作,按照程序的顺序执行,前一个操作的结果对后续操作可见。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作 happens-before 后续的lock操作,确保共享变量的可见性。
  3. volatile变量规则(Volatile Variable Rule):对于一个volatile变量的写操作 happens-before 后续的对该变量的读操作,确保volatile变量的可见性。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法的调用 happens-before 新线程中的任意操作。
  5. 线程终止规则(Thread Termination Rule):线程中的任意操作 happens-before 对该线程的终止检测,即Thread.join()的完成。
  6. 传递性规则(Transitive Rule):如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A happens-before 操作C。

这些规则为程序员提供了一些有序性和可见性的保证,以帮助编写正确的多线程代码。通过遵守Happens-Before模型的规则,程序员可以确保多线程程序的执行结果是可预测的,避免出现数据竞争和不确定的行为。

需要注意的是,Happens-Before模型是一种约束性规范,确保程序在不同的平台和实现中表现一致。但它并不代表真实的操作执行顺序,具体的执行顺序由处理器、编译器和运行时环境等因素决定。

案例说明

我们先看一段代码如下图所示:

private static boolean stop;
public static void main(String[] args) throws InterruptedException {
    stop = false;
    Thread thread=new Thread(()->{
        int i=0;
        while(!stop){
            i++;
        }
    });
    thread.start();
    Thread.sleep(1000);
    stop=true;
}
复制代码

我们可以通过正常思路分析一下,我们可以看到的是首先会开辟一个线程运行i++的操作知道stop是true的时候,下面的代码呢是隔了一秒之后会将stop置为true所以这段代码会在一秒后执行完成,实际结果是这段代码不会终止。

注意すべき点として、ここでは不可視性がキャッシュによって引き起こされていると考えている人が多い. 実際には、自動的にキャッシュ整合性プロトコルを持っているわけではありません. 不可視性は一時的なものであり、不可視のままではありません. この結果の根本的な原因は JIT です.上記のコードを一定時間実行した後、コンパイラは上記のコードをホットコードと判断し、以下に示すコードに最適化します (これは JDK のバージョンに関連します)。

if(stop){
   while(true){
   }
}
复制代码

ここにいくつかの解決策があります:

  1. スレッド内に出力を追加
  2. Thread.sleep(0) を追加
  3. volatile キーワードを追加する

JIT は、インタープリター内のホット コード (頻繁に実行されるコード セグメント) をマシン コードにコンパイルして、プログラムの実行効率を向上させます. 上記の 3 つのソリューションは、JIT 最適化を防ぐことができます.

おすすめ

転載: juejin.im/post/7229517949970890812