在之前一篇文章里,讲到了利用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
,各自有一个局部变量r1
,r2
,假定初始状态时a = 0, b = 0
:
1 |
|
直觉上来看,r1 = 1
,r2 = 2
似乎不可能。对线程A或者B来说,执行动作1与3都执行在前,如果1在前,则r2 = a
不应该看到动作4执行的结果;如果3在前,同样r1 = b
不应该看到2执行的结果。但是,分开来看,只要不影响当前线程的执行结果,编译器是可以对两个线程的指令进行重排的,因此,实际上代码可能按照如下顺序执行:
1 |
|
不难看出,这样就有可能出现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
orThread.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
以及final
在happens-before规则下的语义。
volatile
关键词volatile
在不用同步的情况下,通过内存屏障的方式来禁止变量读写操作的重排序,从而确保一个线程的写操作对其他线程是可见的(volatile变量不会缓存到对其他处理器比可见的register或者cache,因此其他线程的读操作得到的总是其他任何线程最新写的结果)。在JSR133之前,只是不允许volatile变量之间的重排序,但不会禁止volatile变量与普通变量之间的重排序,而新的JSR133模型则严格限制了volatile变量读写与普通变量读写之间的重排序:写一个volatile变量在内存效果上等同于释放monitor锁; 读volatile变量则等同于获取monitor锁。
volatile只是保证了变量读写的可见性,但并没有确保互斥性。因此,使用volatile变量需要慎重。以下两点可以作为volatile使用的参考:
- 将一个类的引用声明为volatile并不能保证该类中非volatile的成员的可见性;
- 在单个写线程、多个读线程一个变量时使用volatile;
示例:
1 |
|
| 有关更多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 |
|
参考文献
- [JMM refers] http://www.cs.umd.edu/~pugh/java/memoryModel/
- [Reordering] https://mortoray.com/2010/11/18/cpu-reordering-what-is-actually-being-reordered/
- [Fixing JMM] https://www.ibm.com/developerworks/library/j-jtp02244/index.html
- [Doug Lee] http://gee.cs.oswego.edu/dl/cpj/jmm.html?cm_mc_uid=64655373050114725223524&cm_mc_sid_50200000=1494921172
- https://www.ibm.com/developerworks/library/j-jtp03304/
- https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
- http://download.oracle.com/otndocs/jcp/memory_model-1.0-pfd-spec-oth-JSpec/
- C++多线程内存模型
- Shared Memory Consistency Models: A Tutorial
- Memory Model: 从多处理器到高级语言