我的代码出现幻觉?说好的a = 1; x = b,怎么成了x = b; a = 1?

有序性:代码执行的幻觉

前面讲到通过缓存一致性协议,来保障共享变量的可见性。那么是否还有其他情况,导致对共享变量操作不符合预期结果。可以看下面的代码:

java 复制代码
private int a, b;
private int x, y;

public void test() {
    Thread t1 = new Thread(() -> {
        x = b;
        a = 1;
    });

    Thread t2 = new Thread(() -> {
        y = a;
        b = 2;
    });
        
    // ...start启动线程,join等待线程
    assert x == 2;
    assert y == 1;
}

假设将线程t1的代码块从a = 1; x = b;改成x = b; a = 1; 。将线程t2的代码块从b = 2; y = a;改成y = a; b = 2;。

对于线程t1和t2自己来说,代码的重排序,不会影响当前线程执行。但是在多线程并发执行下,会出现如下情况:

1)假设处理器A先将变量b = 0赋值给x,再将变量a赋值1。处理器B先将变量a = 0赋值给y,再将变量b赋值2。那么这时结果是:x等于0,y等于0。

可见代码的重排序也会影响到程序最终结果。

重排序是一种被编译器和处理器采用的优化策略,以便更有效地利用处理器资源,减少指令的执行延迟,以及提高并行指令的数量。

在编译阶段,编译器会进行静态重排序。例如,编译器可能会将计算密集型的指令移动到I/O操作之前,以便在等待I/O完成时,处理器可以执行其他的计算任务。

在运行阶段,现代处理器会进行动态重排序,也被称为指令重排序。例如,当一个指令需要等待数据从内存加载时,处理器可能会先执行其他没有数据依赖的指令,从而避免处理器空闲。

重排序需要遵守两点。

1)数据依赖性:如果两个操作之间存在数据依赖,那么编译器和处理器不能调整它们的顺序。

java 复制代码
// 写后读
a = 1;
b = a;
// 写后写
a = 1;
a = 2;
// 读后写
a = b;
b = 1;

上面3种情况,编译器和处理器不能调整它们的顺序,否则将会造成程序语义的改变。

2)as-if-serial语义:即给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。

java 复制代码
a = 1;
b = 2;
c = a * b;

如上对变量a的赋值和对变量b的赋值,不存在数据依赖关系。因此对变量a和b重排序不会影响变量c的结果。

但数据依赖性和as-if-serial语义只保证单个处理器中执行的指令序列和单个线程中执行的操作,并不考虑多核处理器和多线程之间的数据依赖情况。因此在多线程程序中,对存在数据依赖的操作重排序,可能会改变程序的执行结果。因此要避免程序的错误的执行,便是需要禁止这种编译和处理器优化导致的重排序。

这种解决重排序问题的机制,叫做内存屏障。内存屏障也被称为内存栅栏或内存栅障,是一种用于处理多处理器编程中的同步问题的计算机指令。它的主要作用是防止某些内存操作的重排序。以日常接触的 X86_64 架构来说,内存操作指令如读读(LoadLoad)、读写(LoadStore)以及写写(StoreStore)内存屏障是空操作(no-op),只有写读(StoreLoad)内存屏障会被替换成具体指令。

在Java语言中,内存屏障通过volatile关键字实现,禁止被它修饰的变量发生指令重排序操作:

1)不允许 volatile 字段写操作之前的内存访问被重排序至其之后。

2)不允许 volatile 字段读操作之后的内存访问被重排序至其之前。

java 复制代码
//  变量a,b通过volatile修饰
private volatile int a, b; 
private int x, y;

public void test() {
    Thread t1 = new Thread(() -> {
        a = 1;
        // 编译器插入storeload内存屏障指令
        // 1)禁止代码和指令重排序
        // 2)强制刷新变量a的最新值到内存
        x = b;
        // 1)强制从内存中读取变量b的最新值
    });

    Thread t2 = new Thread(() -> {
        b = 2;
        // 编译器插入storeload内存屏障指令
        // 1)禁止代码和指令重排序
        // 2)强制刷新变量b的最新值到内存
        y = a;
        // 1)强制从内存中读取变量a的最新值
    });
        
    // ...start启动线程,join等待线程
    assert x == 2;
    assert y == 1;
}

可以看到通过volatile修饰的变量通过LOCK指令和内存屏障,实现共享变量的可见性和避免代码和指令的重排序,最终保障了程序在多线程情况下的正常执行。

未完待续

很高兴与你相遇!如果你喜欢本文内容,记得关注哦!!!

相关推荐
Jack_abu2 天前
详解java中的线程间协作工具:CountDownLatch,Semaphore,CyclicBarrier(二)
java·并发编程·juc
眠りたいです2 天前
现代C++:C++11并发支持库
开发语言·c++·多线程·c++11·c++并发支持库
2401_841495644 天前
【自然语言处理】中文文本字频统计与交互式可视化工具
人工智能·python·自然语言处理·多线程·分块读取·文本分析·字频统计
为什么要做囚徒4 天前
并发系列(一):深入理解信号量(含 Redis 分布式信号量)
redis·分布式·多线程·并发编程·信号量
努力发光的程序员5 天前
互联网大厂Java求职面试实录
java·jvm·线程池·多线程·hashmap·juc·arraylist
chen_note5 天前
Python面向对象、并发编程、网络编程
开发语言·python·网络编程·面向对象·并发编程
Da Da 泓6 天前
多线程(四)【线程安全问题】
java·开发语言·jvm·学习·安全·多线程·线程安全问题
Brookty6 天前
Java并发编程核心的基础知识
java·开发语言·java-ee·多线程·线程安全
羑悻的小杀马特8 天前
C++多线程同步工具箱:call_once精准触发、lock_guard/unique_lock智能管理,打造无死锁程序!
c++·多线程·死锁·lock_guard·unique_lock·call_once
武藤一雄9 天前
C# 万字拆解线程间通讯?
后端·微软·c#·.net·.netcore·多线程