操作系统角度下的 Volatile 变量(上)

154 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第23天,点击查看活动详情

Java这种托管型语言如何自动进行内存管理和回收整理?

Java中的volatile有什么用?如何正确地使用它?Java并发库大量使用volatile变量,在各种不同的体系结构下产生了很多典型的问题。

volatile语义由Java内存模型定义,先从Java内存模型聊起。

1 Java内存模型

在不同架构,缓存一致性问题不同,如x86采用TSO模型,写后写(StoreStore)和读后读(LoadLoad)完全无需程序员操心,但Arm弱内存模型要求我们在合适位置添加StoreStore barrier和LoadLoad barrier。

1.1 案例

public class MemModel {
    static int x = 0;
    static int y = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                x = 1;
                y = 1;
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (y == 0);
                if (x != 1) {
                    System.out.println("Error!");
                }
            }
        });
        t2.start();
        t1.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在x86运行没问题,但在Arm机器就有概率打印Error:

  • 第一个线程t1对变量x、y的写操作顺序不能保证顺序
  • 第二个线程t2读取x、y时候也不保证顺序

为解决该问题,Java语言规范规定,即JSR 133文档规定Java内存模型,描述java编程语言在支持多线程编程中,对共享内存访问的顺序。显然,案例中的线程间变量共享的情况,就可借此解决。

JSR133文档中,这内存模型有个专门名字 - Happens-before,规定一些同步动作的先后顺序。这规范也不是一蹴而就,它也是经过几次讨论才定稿。所以,早期JVM仍存在一些弱内存相关问题,很难称其为bug,因为标准里的规定就有问题,虚拟机实现只是遵从标准。

2 Happens-before模型

Java内存模型(Java Memory Model, JMM)通过各种操作来定义的,包括对变量的读写操作、加锁和释放锁及线程的启动和等待操作。JMM为程序中的动作定义一种先后关系 - Happens-Before关系。

要想保证操作B可看到操作A的结果,A、B须满足Happens-Before,这结论与A、B是否在同一线程中执行无关。

HB模型讨论的都是同步动作,包括加锁、解锁、读写volatile变量、线程启动和线程完成等。下面操作都指同步动作:

第三条规则:写操作如果在程序里出现在读操作之前,那就不能乱序。这就是写后读屏障的规则

HB模型强调同步动作的先后关系,而对非同步动作,无任何限制。案例一的读写操作都是非同步动作,所以它在不同体系结构运行,得到不同结果。但这并不违反JMM规定。

JMM是一种理论内存模型,并非真实存在。它以具体CPU内存模型为基础的。看JSR 133的两个例子,你就理解了。

2.1 控制流依赖

包括两个线程且变量x、y初值都是0。第一个线程的代码:

// Thread1
r1 = x;
if (r1 != 0)
  y = 1;

第二个线程的代码:

// Thread 2
r2 = y;
if (r2 != 0)
  x = 1;

由于存在控制流依赖,这两段代码的第4行都不能提前到第2行前执行。即至今,所有主流CPU,上面两段代码都会按代码顺序执行。最终运行结果一定是r1=r2=0。

但HB是种理论模型,若线程1中,y=1先于r1=x执行,同时线程2中,x=1先于r2=y执行,最后结果,存在r1=r2=1的可能。理论上确实可能存在一种CPU,当它进行分支预测投机执行时,投机结果被其他CPU观察到。当然,实际绝对不可能出现这样CPU,这意味着厂家花费更多精力为软件开发者带来巨大麻烦,且由于核间同步通讯要求,CPU性能还会下降。

2.2 数据流依赖

假设x=y=初值0,而r1=r2=初值42。

线程1的代码:

r1 = x;
y = r1;

线程2的代码:

r2 = y;
x = r2;

因为每个线程内部的第2行和第1行之间都存在数据依赖,所以这里无法产生乱序执行,所以无论怎样顺序对这两个线程调度,都不可能r1=r2=42。

但是r1=r2=42在HB模型却是合理的,因为它没有对数据流依赖规定。

即普通的变量读写在JMM是允许乱序的,若真有人造出这愚蠢CPU,运行出这种结果却符合HB的规定的。

但这两个问题在现实并不存在。这两个案例,是因为JSR 133文档大量在介绍本不应该存在的两个问题,导致文档晦涩难懂。

理解JMM时,一定要结合具体CPU体系结构。JMM加上每一种体系结构都有的控制流依赖和数据流依赖,才是一种比较实用的内存模型。纯粹的JMM本身的实用性并不强。

JMM是一种标准规定,它并不管实现者如何实现它。具体到Java语言虚拟机的实现,当Java并发库的核心开发者Doug Lea将JMM简化之后,就变得更容易理解一些。