18.并发编程原子性、可见性、有序性原理

原子性、可见性、有序性原理

原子性,可见性,有序性,是并发编程中所面临的三大问题,Java通过CAS操作已经解决了并发编程中的原子性问题,下面来看下Java如何解决另外两个问题,可见性和有序性

1.CPU物理缓存结构

由于CPU的运算速度比物理内存的存取速度快很多,为了提高处理速度,现在CPU不直接和主存进行通信,而是在CPU和主存之间设计了多层高速的Cache(高速缓存),越靠近CPU的缓存越快,容量越小

为了更好的理解,我们可以打开自己的电脑的任务管理器,可以看到其CPU型号,缓存大小,线程相关信息,如下
在这里插入图片描述

多核CPU的三层缓存,它的存储结构究竟是如何设计的呢?下面我们通过图先来了解一些相关概念

在这里插入图片描述

1.1.寄存器

寄存器是计算机内部的一种高速存储器,直接与 CPU 核心相连。它们通常以硬件寄存器的形式存在于 CPU 中,并用于暂时存储指令、数据和中间结果。寄存器的主要特点包括:

  1. 极快的访问速度: 寄存器位于 CPU 核心内部,与执行单元直接相连,因此具有非常高的访问速度。寄存器的访问时间通常在1-2个 CPU 周期左右,远远快于其他存储器。
  2. 容量有限: 由于寄存器是集成在 CPU 核心内部的,因此其容量相对有限。典型的 CPU 中包含的寄存器数量在几十个到几百个不等,只能存储少量的数据和中间结果。
  3. 用途多样: 寄存器可以存储指令的操作数、中间计算结果以及控制信息。它们在 CPU 的指令执行过程中起到了临时存储和传递数据的作用,支持了 CPU 的运算和控制功能。
  4. 作为指令执行的基础: CPU 在执行指令时需要使用寄存器存储指令的操作数和中间结果。寄存器的快速访问速度和直接与 CPU 核心相连的特性使得它们成为了指令执行的基础。

1.2.缓存

缓存是计算机系统中用于提高数据访问速度的一种高速存储器。它位于 CPU 和主内存之间,作为主内存和 CPU 之间的中间存储器,存储了最近被频繁访问的数据和指令。缓存的主要特点包括:

  1. 加速数据访问: 缓存通过存储最近被访问的数据和指令,减少了 CPU 对主内存的访问次数,从而提高了数据的访问速度和系统的性能。
  2. 分级结构: 缓存通常被划分为多级(例如 L1、L2 和 L3 缓存),每一级缓存的容量和访问速度不同。这种分级结构可以根据访问频率和访问延迟来优化数据存储和访问效率。
  3. 容量逐级增大: 缓存的容量随着级别的增加而逐级增大,L1 缓存容量最小,L3 缓存容量最大。这种设计可以根据数据访问模式和访问延迟来调整数据存储的层次结构。
  4. 速度逐级降低: 缓存的访问速度随着级别的增加而逐级降低,L1 缓存速度最快,L3 缓存速度最慢。这种设计可以根据访问频率和访问延迟来优化数据的存取速度。
  5. 数据共享: 在多核处理器中,L3 缓存通常被多个 CPU 核心共享,用于提高多个核心之间的数据共享和通信效率。

1.3.物理内存

物理内存是计算机系统中的主要存储介质,用于存储程序、数据和操作系统所需的信息。它是计算机系统中的一种硬件设备,通常被称为 RAM(随机存取存储器)。物理内存的主要特点包括:

  1. 存储容量: 物理内存的存储容量取决于计算机硬件的配置,通常以 GB(Gigabyte)为单位进行表示。现代计算机通常具有几 GB 到数十 GB 的物理内存容量。
  2. 数据访问速度: 物理内存的数据访问速度比较快,但远远慢于 CPU 内部的寄存器和缓存。物理内存的访问时间通常在数十到数百个 CPU 周期之间。
  3. 持久性: 物理内存是一种持久性存储介质,存储在其中的数据在断电后不会丢失,因此适合存储程序和数据。
  4. 地址空间: 物理内存通过地址线和数据线进行访问,每个内存单元都有一个唯一的地址。物理内存的地址空间取决于计算机的位数,32 位系统可以寻址的物理内存空间为 2^32 字节,即 4 GB;而 64 位系统可以寻址的物理内存空间则更大。
  5. 操作系统管理: 操作系统负责管理物理内存的分配和释放,以及内存的访问权限和保护。操作系统通过虚拟内存管理技术将物理内存抽象为虚拟内存,为每个进程提供了独立的内存空间,从而实现了内存的隔离和保护。

2.并发编程的三大问题

由于需要尽可能的释放CPU的能力,CPU上不断增加内核和缓存,内核也是越增加越多,缓存也层数也逐渐增加,就导致了并发编程中可见性 和有序性的问题。

2.1.原子性

所谓原子操作,就是一个不可中断的一个或者一系列操作,是不会被线程调度机制打段的一个操作。

我们来看一段代码

/**
 * 输出自增
 */
public class NumberAddTest {
    private static int sum = 0;
    public static void main(String[] args) {
        sum++;
    }
}

build项目 然后找到class文件,然后我们通过javap命令解析出 NumberAddTest的汇编代码

javap -c build/classes/java/main/com/hrfan/java_se_base/base/thread/volatile_new/NumberAddTest.class

编译出的汇编代码为

Compiled from "NumberAddTest.java"
public class com.hrfan.java_se_base.base.thread.volatile_new.NumberAddTest {
  public com.hrfan.java_se_base.base.thread.volatile_new.NumberAddTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #7                  // Field sum:I
       3: iconst_1
       4: iadd
       5: putstatic     #7                  // Field sum:I
       8: return

  static {};
    Code:
       0: iconst_0
       1: putstatic     #7                  // Field sum:I
       4: return
}
  1. 构造函数 com.hrfan.java_se_base.base.thread.volatile_new.NumberAddTest():
    • 这是类 com.hrfan.java_se_base.base.thread.volatile_new.NumberAddTest 的构造函数。
    • 代码行 0 加载了当前对象(this)。
    • 代码行 1 调用了父类 java.lang.Object 的构造函数。
    • 代码行 4 返回。
  2. 主函数 public static void main(java.lang.String[]):
    • 这是一个静态的 main 方法,是程序的入口点。
    • 代码行 0 获取静态字段 sum 的值。
    • 代码行 3 对 sum 的值加 1。
    • 代码行 5 将新的 sum 的值存储回静态字段 sum。
    • 代码行 8 返回。
  3. 静态初始化块 static {}:
    • 这是一个静态的初始化块,在类加载时执行。
    • 代码行 0 将值 0 存储到静态字段 sum。
    • 代码行 4 返回。

通过上面我可以看出,其实++ 实际上是3个操作

  1. 获取静态字段 sum 的值。
  2. 对 sum 的值加 1。
  3. 将新的 sum 的值存储回静态字段 sum(给sum重新赋值)。

这3个操作之间是可以发生线程切换的,或者说是可以被其他线程打断的,所以说++操作不是原子操作。

2.2.可见性

一个线程对共享变量的修改,另一个线程能够立刻看见,我们称之为该共享变量具备内存可见性

内存可见性的概念需要先了解 Java Memory Model (JMM)。JMM规定了多线程环境下内存操作的规则和行为,保证了多线程程序的正确性。

内存可见性是指一个线程对共享变量的修改能够被其他线程及时感知到。在多线程环境下,每个线程都有自己的工作内存,线程对共享变量的操作首先发生在工作内存中,然后通过主内存进行线程间的通信。因此,当一个线程修改了共享变量的值后,其他线程不一定能立即看到这个修改,这就是内存可见性的问题。

考虑两个线程 A 和 B 同时修改一个共享变量的情况,如果线程 A 修改了共享变量的值,但这个修改还没有被刷新到主内存,那么线程 B 可能无法立即看到这个修改。这种情况下,线程 B 可能会继续使用自己工作内存中的旧值,导致数据不一致的问题。

为了解决内存可见性的问题,可以通过以下方式确保共享变量的修改能够被其他线程及时感知到:

  1. 使用 volatile 关键字:将共享变量声明为 volatile 可以保证变量的修改对所有线程可见。当一个线程修改了 volatile 变量的值后,该值会立即被刷新到主内存,并且其他线程在访问该变量时会从主内存中获取最新的值。
  2. 使用锁:通过使用锁来实现线程间的同步,可以保证多个线程对共享变量的修改操作是互斥的,从而避免了多个线程同时修改共享变量导致的内存可见性问题。
  3. 使用原子类:Java 提供了一系列原子类(如 AtomicInteger、AtomicLong 等),这些类提供了一种线程安全的方式来进行原子操作,保证了操作的原子性和内存可见性。

注意:为什么Java局部变量,方法参数不存在内存可见性问题?

在Java中,所有局部变量(定义在栈中),方法定义参数不会在线程之间共享,所有也就不会存在内存可见性问题。

所有的Object实例,Class实例,数组元素都存储在JVM堆内存中,堆内存在线程之间共享,所以存在可见性问题。

在这里插入图片描述

线程A 可能不知道 这个值已经被线程A 修改了,就导致了可见性问题。

2.3.有序性

所谓程序的有序性,指的是程序执行顺序按照代码的先后顺序进行执行,如果程序执行的顺序和代码的先后顺序不同,那么就会导致错误结果,就发生给你了有序性问题。

下面我通过一个简单的案例来了解一下

@Slf4j
public class OrderTest {
    private static int x = 0;
    private static int y = 0;
    private static int a = 0;
    private static int b = 0;

    @Test
    @DisplayName("测试多线程环境下有序性问题")
    public void test(){
        Thread thread1 = new Thread(() -> {
            a = 1;
            x = b;
        });
        
        Thread thread2 = new Thread(() -> {
            b = 1;
            y = a;
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出 x、y 的值
        log.error("x = " + x + ", y = " + y);
    }
}

在这里插入图片描述

在这里插入图片描述

理想情况下,thread1 先执行,x 应该等于 b 的值,而 thread2 后执行,y 应该等于 a 的值。因此,xy 应该都等于 1。

但是由于 并发执行无序性的存在,可能会导致 thread2 先执行,然后 thread1 执行。这样一来,xy 的值可能都是 0,而不是我们期望的 1。

一次结果并不能说明问题,下面我们通过多次循环来观察结果

    @Test
    @DisplayName("测试多线程环境下有序性问题(循环)")
    public void test2(){

        while (true){
            x = 0 ;
            y = 0 ;
            a = 0 ;
            b = 0 ;
            Thread thread1 = new Thread(() -> {
                a = 1;
                x = b;
            });

            Thread thread2 = new Thread(() -> {
                b = 1;
                y = a;
            });

            thread1.start();
            thread2.start();

            try {
                thread1.join();
                thread2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 输出 x、y 的值
            log.error("x = " + x + ", y = " + y);
        }

    }

循环多次,发现了一个奇怪的数据,x = 0,y = 0,这种情况就说明发生了并发有序性问题

在这里插入图片描述

错误的执行顺序,导致了最终结果为 x = 0,y = 0

在这里插入图片描述

事实上出现了乱序结果,并不代表一定发生了指令重排序,内存可见性也有可能导致这样的输出,但是指令重排是导致出现乱序的原因之一

2.3.1.指令重排

指令重排序

指令重排序是现代计算机体系结构中的一种优化技术,用于提高程序执行的性能。它指的是编译器、处理器或者内存系统在不改变程序语义的前提下,重新安排指令的执行顺序。

为什么进行指令重排序?

指令重排序的目的是尽可能地提高 CPU 的利用率和整体性能。在现代计算机体系结构中,处理器具有多个执行单元和流水线,能够同时执行多条指令,并且存在各种优化技术(如超标量执行、乱序执行等),这些优化技术需要处理器在执行指令时能够灵活地重排序指令,以充分利用处理器资源,提高指令级并行性和流水线利用率。

指令重排序的类型

指令重排序主要分为编译器重排序、处理器重排序和内存系统重排序三种类型。

  • 编译器重排序: 编译器在生成目标代码时会对指令进行重排序,以优化程序的执行效率。例如,编译器可能会对代码进行指令调度和循环展开等优化操作。
  • 处理器重排序: 处理器在执行指令时会对指令进行重排序,以提高指令级并行性和流水线利用率。例如,处理器可能会对乱序执行的指令进行重新排序,以最大程度地减少流水线停顿。
  • 内存系统重排序: 内存系统在读写内存时会对指令进行重排序,以提高内存访问效率。例如,内存系统可能会对内存读写操作进行缓存重排等优化操作。

指令重排序的影响

指令重排序虽然能够提高程序执行的性能,但也可能会引入一些问题,主要包括:

  • 内存可见性问题: 指令重排序可能会导致共享变量的值对其他线程不可见,从而引发内存可见性问题。
  • 程序执行顺序错误: 指令重排序可能会改变程序中操作的执行顺序,导致程序的行为与预期不符。

为了避免指令重排序带来的问题,需要采取相应的措施来保证程序的正确性和一致性,如使用 volatile 关键字、同步机制等。

2.4.总结

总之,要保证并发程序能够正确的执行,必须保证 原子性,可见性,有序性,三者缺一不可!

  • 12
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值