동시 프로그래밍 - 가시성의 본질과 vloatile의 원리 탐구

시계

단일 스레드 환경에서 변수에 먼저 값을 쓴 다음 간섭 없이 변수의 값을 읽으면 이때 읽은 변수의 값은 value 앞에 쓰여진 값이어야 합니다. 이것은 지극히 정상적인 일이었을 것입니다. 그러나 다중 스레드 환경에서 읽기와 쓰기가 다른 스레드에서 발생하면 읽기 스레드가 다른 스레드가 쓴 최신 값을 적시에 읽을 수 없는 경우가 발생할 수 있습니다. 이것을 가시성이라고 합니다

보이지 않는 원인

비가시성에 대한 두 가지 이유가 있습니다. 하나는 캐시 일관성이고 다른 하나는 명령 재정렬입니다.

캐시 일관성

아래 그림과 같이 운영 체제의 캐시 아키텍처를 볼 수 있습니다. 운영 체제 캐시 아키텍처.png원래 시스템 설계자는 데이터 액세스 속도와 성능을 향상시키기 위해 3단계 캐시를 설계했습니다(캐시가 CPU에 가깝기 때문에 액세스 속도가 빠름) , 그리고 3단계 캐시의 주파수와 대역폭은 더 높고 모두 CPU 칩에 있는 반면, 메인 메모리는 버스와 같은 연결을 통해 프로세서와 통신해야 하며 액세스 지연이 상대적으로 높습니다)

이 3단계 캐시는 성능 향상을 위해 스레드마다 자체 작업 메모리, 즉 캐시를 가지며, 스레드가 공유 변수의 값을 수정할 때 먼저 자체 작업 메모리에 값을 저장할 수 있습니다. 메인 메모리에 즉시 다시 기록되지 않습니다. 다른 스레드가 공유 변수를 읽을 때 메인 메모리에서 최신 값을 가져오는 대신 자체 작업 메모리에서 값을 읽을 수 있습니다. 이 경우 한 스레드가 공유 변수의 값을 수정하면 다른 스레드가 변경 사항을 즉시 인식하지 못하여 보이지 않는 문제가 발생할 수 있습니다.

캐시 일관성으로 인한 비가시성을 해결하기 위해 시스템 수준에서는 버스 잠금 및 캐시 잠금 버스
잠금을 제안합니다 . 버스를 통해 공유 메모리의 데이터에 액세스할 수 없습니다. 버스 잠금은 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;后执行也就是指令重排序 如下图所示: 무제파일(UHD).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. 휘발성 키워드 추가

JIT는 인터프리터에서 핫 코드(자주 실행되는 코드 세그먼트)를 기계 코드로 컴파일하여 프로그램의 실행 효율성을 향상시킵니다.위의 세 가지 솔루션은 JIT 최적화를 방지할 수 있습니다.

추천

출처juejin.im/post/7229517949970890812