大家好,我是王有志。
最近沉迷P5R,所以写作的进度很不理想,但不得不说高卷杏YYDS。话不多说,开始今天的主题,JMM和Happens-Before。
关于它们的问题并不多,基本上只有两个:
Tips:本文以JMM理论为主。
JMM即Java Memory Model,Java内存模型。JSR-133 FAQ中对内存模型的解释是:
At the processor level, a memory model defines necessary and sufficient conditions for knowing that writes to memory by other processors are visible to the current processor, and writes by the current processor are visible to other processors.
处理器级别上,内存模型定义了处理器核心间对彼此写内存操作可见性的充要条件。以及:
Moreover, writes to memory can be moved earlier in a program; in this case, other threads might see a write before it actually "occurs" in the program. All of this flexibility is by design -- by giving the compiler, runtime, or hardware the flexibility to execute operations in the optimal order, within the bounds of the memory model, we can achieve higher performance.
在内存模型允许的范围内,允许编译器、运行时或硬件以最佳顺序执行指令,以提高性能。最佳顺序是通过指令重排序得到的指令执行顺序。
我们对处理器级别的内存模型做个总结:
接着看对JMM的描述:
The Java Memory Model describes what behaviors are legal in multithreaded code, and how threads may interact through memory.It describes the relationship between variables in a program and the low-level details of storing and retrieving them to and from memory or registers in a real computer system.It does this in a way that can be implemented correctly using a wide variety of hardware and a wide variety of compiler optimizations.
提取这段话的关键信息:
我们结合内存模型来看,JMM到底是什么?
那么为什么要有内存模型呢?
关于线程你必须知道的8个问题(上)中给出了并发编程的3要素,以及无法正确实现带来的问题,接下来我们探究下底层原因。
Tips:补充一点Linux中线程调度相关内容。
Linux线程调度是基于时间片的抢占式调度,简单理解为,线程尚未执行结束,但时间片耗尽,线程挂起,Linux在等待队列中选取优先级最高的线程分配时间片,因此优先级高的线程总会被执行。
我们以常见的自增操作count++
为例。
直觉上我们认为自增操作是一气呵成,没有任何停顿。但实际上会产生3条指令:
count
读入缓存;count
写入内存。那么问题来了,如果两个线程t1,t2同时对count
执行自增操作,且t1执行完指令1后发生了线程切换,此时会发生什么?
我们期望的结果是2,但实际上得到1。这便是线程切换带来的原子性问题。那么禁止线程切换不就解决了原子性问题吗?
虽然是这样,但禁止线程切换的代价太大了。我们知道,CPU运算速度“贼快”,而I/O操作“贼慢”。试想一下,如果你正在用steam下载P5R,但是电脑卡住了,只能等到下载后才能愉快的写BUG,你气不气?
因此,操作系统中线程执行I/O操作时会放弃CPU时间片,让给其它线程,提高CPU的利用率。
P5R天下第一!!!
你可能会想上面例子中,线程t1,t2操作的不是同一个count
吗?
看起来是同一个count
,但其实是内存中count
在不同缓存中的副本。因为,不仅是I/O和CPU有着巨大的速度差异,内存与CPU的差异也不小,为了弥补差异而在内存和CPU间添加了CPU缓存。
CPU核心操作内存数据时,先拷贝数据到缓存中,然后各自操作缓存中的数据副本。
我们先忽略MESI带来的影响,可以得到线程对缓存中变量的修改对其它线程来说并不是立即可见的。
Tips:拓展中补充MESI协议基础内容。
除了以上提升运行速度的方式外,还有其它“幺蛾子”--指令重排序。我们把关于线程你必须知道的8个问题(上)中的例子改一下。
public static class Singleton {
private Singleton instance;
public Singleton getInstance() {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
}
Java中new Singleton()
需要经历3步:
Singleton
对象;instance
指向这块内存。分析下这3步间的依赖性,分配内存必须最先执行,否则2和3无法进行,至于2和3无论谁先执行,都不会影响单线程下语义的正确性,它们之间不存在依赖性。
但是到了多线程场景下,情况就变得复杂了:
此时线程t2拿到的instance
是尚未经过初始化的实例对象,重排序导致的有序性问题就产生了。
Tips:拓展中补充指令重排序。
正式描述JMM前,JSR-133中提到了另外两种内存模型:
顺序一致性内存模型禁止了编译器和处理器优化,提供了极强的内存可见性保证。它要求:
顺序一致性模型的约束力太强了,显然不适合作为支持并发的编程语言的内存模型。
Happens-Before描述两个操作结果间的关系,操作A happens-before 操作B(记作$A \xrightarrow{hb} B$),即便经过重排序,也应该有操作A的结果对操作B是可见的。
Tips:Happens-Before是因果关系,$A \xrightarrow{hb} B$是“因”,A的结果对B可见是“果”,执行过程不关我的事。
Happens-Before的规则,我们引用《Java并发编程的艺术》中的翻译:
程序顺序规则:线程中的每个操作happens-before该线程中的任意后续操作。
监视器锁规则:锁的解锁happens-before随后这个锁的加锁。
volatile变量规则:volatile变量的写happens-before后续任意对这个volatile变量的读。
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
以上内容出现在JSR-133第5章Happens-Before and Synchronizes-With Edges中,原文较为难读。
这些看似是废话,但是别忘了,我们面对的是多线程环境和编译器,硬件的重排序。
再次强调,以监视器锁规则为例,虽然只说了解锁发生在加锁前,但实际是解锁后的结果(成功/失败)发生在加锁前。
Tips:Happens-Before可以翻译为发生在…之前,Synchronizes-With可以翻译为与…同步。
另外JSR-133还还提及了非volatile变量的规则:
The values that can be seen by a non-volatile read are determined by a rule known as happens-before consistency.
即非volatile变量的读操作的可见性又happens-before一致性决定。
Happens-Before一致性:存在对变量V的写入操作W和读取操作R,如果满足$W \xrightarrow{hb} R$,则操作W的结果对操作R可见(JSR 133上的定义诠释了科学家的严谨)。
JMM虽然不是照单全收Happens-Before的规则(进行了增强),不过还是可以认为:$Happens-Before规则 \approx JMM规则$。
那么为什么选择Happens-Before呢?实际就是易编程,约束性和运行效率三者权衡后的结果。
图中只选了今天或多或少提到过的内存模型,其中X86/ARM指的是硬件架构体系。
虽然Happens-Before是JMM的核心,但是除此之外,JMM还屏蔽了硬件间的差异;并为Java开发人员提供了3个并发原语,synchronized
,volatile
和final
。
关于内存模型和JMM的理论内容已经结束了,这里为文章中出现的概念做个补充,大部分都是硬件层面的内容,不感兴趣的话可以直接跳过了。
缓存一致性协议(Cache Coherence Protocol),一致性用的并不是常见的Consistency。
Coherence和Consistency经常出现在并发编程,编译优化和分布式系统设计中,如果仅仅从中文翻译上理解你很容易误解,实际上两者的区别还是很大的,我们看维基百科中对一致性模型的解释:
Consistency is different from coherence, which occurs in systems that are cached or cache-less, and is consistency of data with respect to all processors. Coherence deals with maintaining a global order in which writes to a single location or single variable are seen by all processors. Consistency deals with the ordering of operations to multiple locations with respect to all processors.
很明显的,如果是Coherence,针对的是单个变量,而Consistency针对的是多个绵连。
MESI协议是基于失效的最常用的缓存一致性协议。MESI代表了缓存的4种状态:
Tips:除了MESI协议外还有MSI协议,MOSI协议,MOESI协议等,首字母都是描述状态的,O代表的是Owned。
MESI是硬件层面做出的保证,它保证一个变量在多个核心上的读写顺序。
不同的CPU架构对MESI有不同的实现,如:X86引入了store buffer,ARM中又引入load buffer和invalid queue,读/写缓冲区和无效化队列提高了速度但是带来了另一个问题。
重排序可以分为3类:
前两种重排序很好理解,但是内存系统重排序要怎么理解呢?
引入store buffer,load buffer和invalid queue,将原本同步交互的过程修改为了异步交互,虽然减少了同步阻塞,但也带来了“乱序”的可能性。
当然重排序也不是“百无禁忌”,它有两个底线:
两个操作依赖同一个数据,且其中包含写操作,此时两个操作之间就存在数据依赖。如果两个操作存在数据依赖性,那么在编译器或处理器重排序时,就不能修改这两个操作的顺序。
as-if-serial语义并不是说像单线程场景一样执行,而是无论如何重排序,单线程场景下的语义不能被改变(或者说执行结果不变)。
关于内存模型和JMM的阅读资料
虽然《Time, Clocks, and the Ordering of Events in a Distributed System》是讨论分布式领域问题的,但在并发编程领域也有着巨大的影响。
最后说个有意思的事情,大佬们的博客都异常“朴素”。
Doug Lea的博客首页:
Lamport的博客首页:
最近沉迷P5R,一直在偷懒~~
JMM的内容删删减减的写得很纠结,因为涉及到并发原理时,从来不是编程语言自己在战斗,从CPU到编程语言每个环节都有参与,所以很难把控每部分内容的详略。
不过好在也是把JMM的本质和由来说明白了,希望这篇对你有所帮助,欢迎各位大佬留言指正。
好了,今天就到这里了,Bye~~
手机扫一扫
移动阅读更方便
你可能感兴趣的文章