stone

一个简单的并发程序(1)volatile
在并发的情况下,很多在单线程下不是问题的问题都会成为致命问题,而且会出现一些奇奇怪怪的结果,如程序运行不正确,死锁...
扫描右侧二维码阅读全文
16
2018/07

一个简单的并发程序(1)volatile

在并发的情况下,很多在单线程下不是问题的问题都会成为致命问题,而且会出现一些奇奇怪怪的结果,如程序运行不正确,死锁,重复创建等等问题,
这需要我们对并发模型有一定的理解,这是一个系列文章吧,分别从 volatile,synchronize,锁和原子性等等方面来看并发模型,希望通过这个
整理,可以对并发模型有一个更深的理解。

先来看一个简单的并发程序:

package com.stone;

public class Main {
    public static void main(String[] args) {
        new Main().test();
    }

    public void test() {
        Thread[] threads = new Thread[100];
        for (int j = 0; j < 100; j++) {
            threads[j] = new Thread(new MyThread());
            threads[j].start();
        }

        for (int j = 0; j < 100; j++) {
            try {
                threads[j].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class MyThread implements Runnable {
    private static Integer i = 0;

    @Override
    public void run() {
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        i = i + 1;
        System.out.println(i);
    }
}

程序很简单,就是启动 100 个线程同时修改 i 的值,每次都加一。如无意外,最终应该输出 0~99,然而问题哪有这么简单。

52474-acsjjifawb.png

95 这个数出现了两次,而且每一次跑的结果应该都不一样。那问题出在哪里呢?

为什么要加入高速缓存

现在计算机系统中,CPU 的计算速度和内存的速度差了几个数量级,为了匹配这种速度上的差异,CPU 和内存之间的都会存在高速缓存,而且不止一级缓存。在计算的时候,cpu 会先去缓存中查找变量,如果没有,则从内存中复制缓存中,然后进行运算,在运算结束之后,会将运算结果同步到内存中。缓存的存在使 cpu 计算的时候无需等待缓慢的内存,但是也带来一些新的问题。

缓存一致性问题

在多核处理器的情况下,每个 cpu 都有属于自已的高速缓存,这就引入了一个新的问题,缓存一致性(Cache Coherence)。

当多个处理器的运算任务都涉及同一块主内存的时候,就可能多核处理器各自缓存的数据不一致,导致运算出错。

32455-e6ngbo9mkx.png

java 的 JMM 模型(java memory model)

实际上,jmm 不是 jvm 上面的一种实现,只是对硬件结构的一种抽象模型,可以用来解释一些多线程并发的问题。

JMM 定义了线程和主内存之间的抽象关系。

下面是 JMM 的示意图:

96989-jnrtzmwqhfk.png

在图中,每个线程都有自己的工作内存。
实际上,每个进程并没有自己的工作内存,都是对主内存直接进行操作。
线程的工作内存实际上并不真实存在,是对各种缓存,寄存器和编译器优化的的一种抽象模型。

线程间变量可见性问题

当执行操作x = x+1这个操作的时候,分为下面三步:

  1. 线程 A 从主内存中读取变量 x 到自己的工作目录
  2. 线程 A 更改变量 x
  3. 线程 A 将变量 x 更新到主内存中

如果一个线程修改了一个变量,在没有添加 volatile 声明的时候,线程对变量的修改不会马上同步到主内存上,那么对这个变量的修改对跑在其他 cpu 上的其他线程就是不可见的。

而解决这种可见性问题的方法有:

  1. 添加 volatile 关键字
  2. 添加同步块 synchronized
  3. final 关键字

指令重排

  1. 编译器的重排序: 编译器在编译的时候,在不改变单线程语义的情况下,可以重新安排语句的执行顺序
  2. 指令级别的重排序:如果语句之间不存在数据依赖的情况,处理器可以改变机器指令的执行顺序
  3. 内存系统的重排序:由于缓存的存在,实际上加载和储存的操作是乱序执行的

as-if-serial语义,无论语句怎么重新排序,单线程的程序执行结果不会改变。所以,指令重排不影响单线程的运行结果,但是影响多线程的正确性。

int a = 1;
int b = 2;
int c = a + b;

上面的语句包含 5 步操作,分别是两次赋值,两次取值,一次赋值,他们之间可能会存在一定的指令重排。
所以指令的执行顺序不一定就等于程序的逻辑代码的顺序。
并且由于一个 cpu 对变量的修改不一定对其他 cpu 可见,这些零零碎碎的原因,最终导致了运行结果的出错。

volatie 和内存屏障(内存栅栏)

volatile 可以保证线程对一个 cpu 的修改可以马上被其他的线程看到,但是要记住一点,volatile 只保证了可见性,但是不保证原子性。
volatile 底层使用内存屏障实现,禁止指令重排。

内存屏障的实现是通过一组处理器指令实现的。

以下内容并没有通过实践证明,来源于网上。

对比添加了 volatile 的代码和没有 volatile 的代码,会发现 volatile 的代码多出一个 lock 前缀的指令,lock 前缀指令相当于一个内存屏障,
提供 3 个功能:

  1. 确保前面的指令不会重排到屏障内,同时,屏障内的指令不会重排到屏障外
  2. 它会强制将对内存的修改立即同步到主内存中
  3. 如果修改了 volatile 修饰的内存,会导致其他 cpu 中对应的缓存行失效

volatile 具体实现如下:

如果对声明了 volatile 变量进行写操作时,JVM 会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写会到系统内存。这一步确保了如果有其他线程对声明了 volatile 变量进行修改,则立即更新主内存中数据。

但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了 volatile 变量都是从主内存中获取最新的。

再分析

再来分析一下上面的那个程序,如果你觉得单单在变量前面加上 volatile 就能保证程序的正确运行,那你就 too young 了。

volatile 只能保证你每次取变量的时候,值是最新的。上面的程序中,i++这个操作实际上包含了取值和赋值两步操作,取值对了,但是不能保证赋值的时候,你的值仍然是最新的。要想解决这个问题,要用到其他的同步手段,如 synchronize 和原子操作。这些就留到下一次讲吧。

Last modification:September 9th, 2018 at 01:03 pm
If you think my article is useful to you, please feel free to appreciate

Leave a Comment