JasonWang's Blog

Java并发编程之Java Memory Model

字数统计: 2.7k阅读时长: 10 min
2017/05/29

在之前一篇文章里,讲到了利用synchronized关键字来进行同步,从而避免多线程并发执行时可能出现的竞争条件,那么JVM又是如何实现线程之间的通信的?换句话说,线程A写共享数据的结果怎么确保被其他线程可见,使得线程之间共享的数据对每个线程而言都是一致的?Java从5.0开始定义了一个新的Memory Model(Java Specification Request 133, JSR133),在多线程情况下,为Java程序提供了一个最少程度的保证:正确同步的情况下,一个线程写共享变量对于其他线程是可见的。Java Memory Model(JMM)抽象了不同计算机平台底层内存读写细节(Register; Cache; Main Memory),为程序员提供了一个访问内存的统一的模型与视角,从而确保在不同平台上程序能有同样的结果,实现Java“编写一次,可以在任何平台上执行”的目标。

为什么需要JMM

现代计算机的内存是一个层级的结构(memory hierarchy),最上层是寄存器(register),接下来是缓存(cache),最后才是主内存(main memory),因此一个变量的读总是需要经过cache,如果cache数据无效,才会从main meory中获取;写变量同样如此,先将数据写入对应cache,等到某个时间才将cache中的数据与main memory进行同步(见下图)。这样,在硬件层面就存在一个如何确保各个CPU之间数据可见性的问题。不同的硬件平台可能有不同的memory model来处理CPU之间的数据可见性。最简单的memory model就是一个CPU对内存中某个变量的写操作立即对其他CPU可见,这种强内存模型要求数据之间存在严格的顺序一致性,也被称为sequential consistency。虽然sequential consistency为线程之间的数据同步提供了强有力的保证,但是(1)顺序一致性要求所有的内存操作对所有线程可见;(2)禁止了编译器与CPU对于代码的优化,这无疑会降低系统运行的性能。因此,为在性能与数据可见性之间取得平衡,大部分平台都提供了一个更弱的meomory model,在需要进行数据同步时,通过一个内存屏障(Memory Barrier )来同步CPU缓存与主内存之间的数据。

每个CPU有自己独立的Register,Cache,但是共享Main Memory,各个不同的CPU之间通过总线(bus)来访问内存

除了上述硬件平台memory model之间的差异,JMM还需要考虑编译器以及CPU执行指令时对代码执行顺序进行重排(reordering)导致的共享数据不一致问题。

Reordering

在单线程情况下,Java语言本身要求JVM满足as-if-serial语义(只要在单线程情况下程序的执行结果不被改变,任何其他的重排都是允许的)。但对于多线程问题就复杂起来了。考虑如下两个线程,两个线程共享变量a,b,各自有一个局部变量r1r2,假定初始状态时a = 0, b = 0

1
2
3
4
5
6
7
8
9

// Thread A
1: r2 = a;
2: b = 1;

// Thread B
3: r1 = b;
4: a = 2;

直觉上来看,r1 = 1,r2 = 2似乎不可能。对线程A或者B来说,执行动作1与3都执行在前,如果1在前,则r2 = a不应该看到动作4执行的结果;如果3在前,同样r1 = b不应该看到2执行的结果。但是,分开来看,只要不影响当前线程的执行结果,编译器是可以对两个线程的指令进行重排的,因此,实际上代码可能按照如下顺序执行:

1
2
3
4
5
6
7
8
9

// Thread A
b = 1;
r2 = a;

// Thread B
r1 = b;
a = 2;

不难看出,这样就有可能出现r1 = 1,r2 = 2难以预料的结果了。为了解决上述问题(编译器或者CPU优化时导致的指令重排),Java定义了自己的一个memory model,由JVM来负责处理数据同步的底层实现细节(通过在适当的位置插入内存屏障来实现共享内存的可见性;通过CAS操作来实现原子操作),开发者只要通过正确的调用synchronized,final,volatile等语言本身提供的同步语义即可实现共享数据的一致性与可见性。

什么是JMM

总的说来,JMM定义了线程与主内存之间的一种抽象关系,其定义的规则在于解决多线程情况下可能出现的三个问题:

  • Atomicity(原子性): 那些指令必须是不可分割的(原子操作);
  • Visibility(可见性): 在什么情况下,一个线程的写操作结果对另一个线程可见;
  • Ordering(重排): 在什么情况下,对任何线程而言,执行操作是可重排的

JMM确保读写内存区域(除了long/double之外)是原子的;volatile long/double读写是原子的;

一个线程执行的动作可被另一个线程检测到或者被直接影响到,则将该动作称为线程之间的动作(inter-thread action),主要有以下几种:

  • Read(non-volatile): 读一个变量;
  • Write(non-volatile): 写一个变量;
  • 同步动作:
    • 读取一个volatile变量;
    • 写一个volatile变量;
    • Lock/unlock
    • 线程的启动与终止

这样,一个线程间的执行动作可由一个由线程(t),执行的动作(k),变量或者monitor(v)以及任意的动作标识符(u)这样一个<t,k,v,u>四元组来确定了。在多线程情况下,最简单的内存模型就是所有动作总的顺序(由单个执行动作组成)跟程序的执行顺序(Program Order)保持一致,且所有的单个动作都具有原子性,操作的结果能够立刻对所有其他线程可见(Sequential Consistency)。这种强一致性虽然能够保证动作的可见性与执行顺序的可预测性,但是由于其禁止了编译器以及CPU指令优化,因此会降低系统的性能。

Sequential Consistency:

JMM提供了另一种选择,在不同平台上的执行性能与程序可预测性之间取得了平衡:JMM保证,在正确同步的情况下,JVM必须确保写变量对其他各个线程是可见的。通过对Java中的各种执行动作定义一种偏序关系(partial ordering),JMM确保程序执行结果的一致性。这种偏序关系通常被称为happens-before。JMM中主要有如下happens-before规则:

  • Lock Rule: 在一个monitor上释放锁happens-before每一个在该monitor上的锁获取;
  • Volatile Rule: 写一个volatile变量happens-before每一个volatile变量的读操作;
  • Thread Start Rule: 线程的启动start()happens-before每一个其他该线程的动作;
  • Thread Termination Rule: 任何在一个线程上的动作 happens-before其他线程得知该线程终止之前的动作 (从Thread.join 成功返回或者 Thread.isAlive返回false);
  • All actions in a thread happens-before any other thread successfully returns from a join on that thread(与上条类似);
  • Interruption Rule: 一个线程中断另一个线程happens-before 被中断线程检测到该中断(either by having InterruptedException thrown or invoking Thread.isInterrupted or Thread.interrupted);
  • Initialization Rule: 对象的默认初始化happens-before其他任何执行动作(除默认的写动作之外)(the defaul initialization of any object happens-before any other actions(other than default-writes) of a program);

什么是Partial Ordering Partially_ordered_set

下面就来看下,volatile以及finalhappens-before规则下的语义。

volatile

关键词volatile在不用同步的情况下,通过内存屏障的方式来禁止变量读写操作的重排序,从而确保一个线程的写操作对其他线程是可见的(volatile变量不会缓存到对其他处理器比可见的register或者cache,因此其他线程的读操作得到的总是其他任何线程最新写的结果)。在JSR133之前,只是不允许volatile变量之间的重排序,但不会禁止volatile变量与普通变量之间的重排序,而新的JSR133模型则严格限制了volatile变量读写与普通变量读写之间的重排序:写一个volatile变量在内存效果上等同于释放monitor锁; 读volatile变量则等同于获取monitor锁。

volatile只是保证了变量读写的可见性,但并没有确保互斥性。因此,使用volatile变量需要慎重。以下两点可以作为volatile使用的参考:

  • 将一个类的引用声明为volatile并不能保证该类中非volatile的成员的可见性;
  • 在单个写线程、多个读线程一个变量时使用volatile;

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

class VolatileExample{
int x = 0;
volatile boolean v = false;

public void writer(){
x = 30;
v = true; // JMM确保上述普通变量与volatile变量之间的操作不被重排序
}

public void reader(){
if(v == true){
int y = 2*x; // x = 30对其他任何读线程都是可见的
}
}
}

| 有关更多volatile变量的使用方法,请参考Managing Volatility

final

关键词final确保变量是不可变的(尽管该变量所引用的对象可变),一旦一个变量声明为final,并被初始化一次,之后就不会被更改。因此,final变量无需做同步即可在多线程情况下使用:一个在对象构造完成之后,可见该对象的引用的线程保证可以看到该对象的final变量。

示例:一个线程调用write(),另一个调用read(),根据Happens-before Rule,由于变量f的写动作总是在对象构造完成之后,因此读线程总是可以看到f.x = 3,而由于f.y并非final变量,因此JMM并不保证读线程可以看到f.y = 4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

class FinalFieldExample{
final int x;
int y;

static FinalFiledExample f;

public FinalFieldExample(){
x = 3;
y = 2;
}

static void write(){
f = new FinalFieldExample();
}

static void read(){
if(f != null){
int i = f.x; // guaranteed to see 3
int j = f.y; // could see 0
}
}
}


参考文献

原文作者:Jason Wang

更新日期:2022-12-21, 18:11:38

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. 为什么需要JMM
    1. 1.1. Reordering
  2. 2. 什么是JMM
  3. 3. volatile
  4. 4. final
  5. 5. 参考文献