我的代码背叛了我?为什么 a=1, b=2,最后x和y都等于0?

随着多核架构的普及,并发编程已成为开发者不可或缺的核心技能。在学习过程中,开发者常会遇到这样的困惑:正确编写的单线程代码,为何在并发环境下可能瞬间失效?看似有序的语句执行后,为何结果却混乱不堪?这些问题,都指向了编程领域的一个关键课题------内存模型。

本文以Java语言为例,剖析共享数据在并发环境中的传播机制、指令执行的有序性保障,以及原子操作的实现原理,从而揭示多线程程序从代码到处理器执行的底层逻辑。同时,通过剖析工程实践中常见的并发异常,并追溯其根本原因,帮助读者构建对并发编程本质的系统理解。

并发之谜:为何我的代码背叛了我?

在并发编程中,共享变量是指能够被多个线程同时访问的变量,如全局变量、静态变量或对象的实例成员变量。这些变量通常存储在堆内存中,而非线程私有的栈内存中,因为堆内存对所有线程可见。

共享变量为线程间通信提供了便利,允许线程通过读写这些变量来交换信息和协调任务。然而,这种共享机制也带来了复杂性。当多线程同时读写共享变量且缺乏保护措施时,可能引发数据不一致、程序异常甚至系统崩溃等后果。

java 复制代码
private int a, b;
private int x, y;
 
public void test() {
    Thread t1 = new Thread(() -> {
       a = 1;
       x = b;
    });
 
    Thread t2 = new Thread(() -> {
       b = 2;
       y = a;
    });

    // ...start启动线程,join等待线程
    assert x == 2;
    assert y == 1;
}

首先,考虑如上代码片段:定义了两个共享变量 x 和 y,并在两个线程中分别对它们进行赋值。当同时启动这两个线程并等待它们执行完毕后,x 是否等于 2 且 y 等于 1 呢?答案是不确定的,因为共享变量 x 和 y 可能存在多种执行结果。这种现象在并发编程中并不罕见,常常会导致程序逻辑与预期不符,进而引发困惑。

然而,通过深入分析这些问题的根源,可以发现它们并非无迹可寻。主要原因可以归结为两点:首先,处理器与内存之间对共享变量的处理速度存在差异,这会导致可见性问题。其次,编译器和处理器可能会对代码指令进行重排序优化,从而导致有序性问题。

可见性:你看到的是真相吗?

如上图所示,由于处理器和内存之间的速度差异显著,为了提高处理效率,处理器并不直接与内存进行通信,而是先将系统内存中的数据加载到处理器内部的缓存(如L1、L2或其他级别缓存)中,然后再进行操作。这一机制基于局部性原理,即处理器在读取内存数据时,通常以块为单位进行读取,每一块数据称为缓存行(Cache Line)。当处理器完成对数据的操作后,并不会立即将结果写回内存,而是先写入缓存中,并将该缓存行标记为脏(Dirty)状态。只有当该缓存行被替换时,数据才会被写回内存。这一过程被称为写回策略(Write Back)。

此外,处理器还引入了写缓冲区(Store Buffer)来进一步提升效率。写缓冲区用于临时保存处理器向内存写入的数据,使得处理器在写入数据时无需等待慢速的内存操作完成,从而可以继续执行后续指令,确保指令流水线的持续运行。然而,这种优化机制也带来了潜在的问题:由于写缓冲区中的数据并不会立即写回内存,且写缓冲区仅对当前处理器可见,其他处理器无法即时感知共享变量的变更。这可能导致处理器的读写顺序与内存实际操作的读写顺序不一致,从而引发可见性和有序性问题,进一步增加了并发编程的复杂性。

现在再回来看上面代码,那么可以得到四种结果:

1)假设处理器A对变量a赋值,但没及时回写内存。处理器B对变量b赋值,且及时回写内存。处理器A从内存中读到变量b最新值。那么这时结果是:x等于2,y等于0;

2)假设处理器A对变量a赋值,且及时回写内存。处理器B从内存中读到变量a最新值。处理器B对变量b赋值,但没及时回写内存。那么这时结果是:x等于0,y等于1;

3)假设处理器A和B,都没及时回写变量a和b值到内存。那么这时结果是:x等于0,y等于0;

4)假设处理器A和B,都及时回写变量a和b值到内存,且从内存中读到变量a和b的最新值。那么这时结果是:x等于2,y等于1。

从上面可发现:除了第四种情况,其他三种情况都存在对共享变量的操作不可见。所谓可见性,便是当一个线程对某个共享变量的操作,另外一个线程立即可见这个共享变量的变更。

而从上面推论可以发现,要达到可见性,需要处理器及时回写共享变量最新值到内存,也需要其他处理器及时从内存中读取到共享变量最新值。

因此也可以说只要满足上述两个条件。那么就可以保证对共享变量的操作,在并发情况下是线程安全的。在Java语言中,是通过volatile关键字实现。volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用处理器缓存。

对如下代码中的共享变量:

java 复制代码
// instance是volatile变量
volatile Singlenton instance = new Singlenton();

转换成汇编代码,如下:

c 复制代码
0x01a3de1d: movb 5 0 x 0, 0 x 1104800(% esi);
0x01a3de24: lock addl $ 0 x 0,(% esp);

可以看到volatile修饰的共享变量会多出第二行汇编变量,并且多了一个LOCK指令。LOCK前缀的指令在多核处理器会引发两件事:

1)将当前处理器缓存行的数据写回到系统内存;

2)这个写回内存的操作会使在其他处理器里缓存了该内存地址的数据无效。

上述的操作是通过总线嗅探和总线仲裁来实现。而基于总线嗅探和总线仲裁,现代处理器逐渐形成了各种缓存一致性协议,例如 MESI 协议。

总之操作系统便是基于上述实现,从底层来保证共享变量在并发情况下的线程安全。而对实际开发,只需要在恰当时候加上volatile关键字就可以。

除了volatile,也可以使用synchronized关键字来保证可见性。 不同于volatile,synchronized通过两个操作来保证内存可见性:获取锁和释放锁。当一个线程获取锁时,它会清空工作内存中的共享变量,并从主内存中重新加载最新的值。这样,其他线程在获取锁之前无法访问该变量,从而保证了内存可见性。当线程释放锁时,它会将工作内存中的值刷新回主内存,以便其他线程可以看到最新的值。

未完待续

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

相关推荐
一只IT攻城狮5 天前
构建一个简单的Java框架来测量并发执行任务的时间
java·算法·多线程·并发编程
charlie11451419112 天前
我的Qt八股文笔记2:Qt并发编程方案对比与QPointer,智能指针方案
笔记·qt·面试·刷题·并发编程·异步
想躺平的咸鱼干20 天前
Volatile解决指令重排和单例模式
java·开发语言·单例模式·线程·并发编程
佛祖让我来巡山23 天前
【深入理解 volatile】内存可见性与同步机制详解
volatile·内存可见性·指令重排序
LyaJpunov1 个月前
深入理解 C++ volatile 与 atomic:五大用法解析 + 六大高频考点
c++·面试·volatile·atomic
转码的小石1 个月前
Java面试复习指南:并发编程、JVM、Spring框架、数据结构与算法、Java 8新特性
java·jvm·数据结构·spring·面试·并发编程·java 8
牛马baby1 个月前
synchronized 做了哪些优化?
java·高并发·并发编程·synchronized·锁升级·面试资料·程序员涨薪跳槽
在未来等你1 个月前
Java并发编程实战 Day 26:消息队列在并发系统中的应用
微服务·kafka·消息队列·rabbitmq·并发编程·高并发系统·: java
1.01^10001 个月前
[6-01-03].第22节:共享模型之无锁 - CAS和volatile
并发编程