1、基础与概念
(1)、共享性、互斥性、原子性、可见性、有序性。
(2)、JMM内存模型——描述线程本地内存和主内存之间的抽象关系。线程A和线程B之间通讯,需要通过主内存。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
注意,线程本地内存只是一个抽象概念,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
2、重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。
重排序分类
(1)、编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)、指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
(3)、内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。如下图:
重排序的原则:as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。数据依赖关系如下图所示:
as-if-serial语义只能保证单线程下,重排序引起的问题。在多线程情况下,不存在数据依赖关系的重排序也会破坏程序的意图。
单线程情况下,控制依赖关系的重排序,不影响最终结果。多线程情况下,则可能会破坏程序的意图。
JMM禁止重排序的措施:
(1)、对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
(2)、对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为MemoryFence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
<div class="image-package">
<div class="image-container" style="max-width: 700px; max-height: 228px; ">
<div class="image-container-fill" style="padding-bottom: 18.54%;"></div>
<div class="image-view" data-width="1230" data-height="228">
</div>
<div class="image-caption">
</div>
</div>
从上图可以看出:常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO和X86拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。
内存屏障如上图4种类型,StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
3、happen-before
JDK5之后,采用JSR-133版本的JMM内存模型,使用hap-pens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。happens-before规则如下:
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
传递性:如果A happens-before B,且B happens-beforeC,那么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()操作成功返回。
对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
4、顺序一致性
顺序一致性内存模型是一个理论参考模型。顺序一致性内存模型有两大特性:
1)一个线程中的所有操作必须按照程序的顺序来执行。
2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
5、volatile
5-1、volatile实现原理
1)将当前处理器缓存行的数据写回到系统内存。(Lock前缀指令.“缓存锁定”,阻止两个或以上处理器同时修改被缓存的内存区域)
2)这个写回内存的操作会,其他处理器“嗅探”到,使在其他CPU里缓存了该内存地址的数据无效。
5-2、volatile特性
可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
5-3、volatile写-读建立的happens-before关系
volatile的写-读与锁的释放-获取有相同的内存效果。
这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
5-4、volatile写-读的内存语义
volatile写的内存语义如下:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义如下:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
注:关于volatile变量重排序,严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
5-5、volatile和锁的区别
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。
volatile的不能完全取代Synchronized的位置,只有在一些特殊的场景下,才能适用volatile。总的来说,必须同时满足下面两个条件才能保证在并发环境的线程安全:
(1)对变量的写操作不依赖于当前值。如i++不符合
(2)该变量没有包含在具有其他变量的不变式中。
6、锁
6-1、锁的获取和释放 建立的happens-before关系
6-2、锁的释放和获取的内存语义
MM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
7、java concurrent包的通用化的实现模式
分析concurrent包的源代码实现,会发现一个通用化的实现模式。
首先,声明共享变量为volatile。
然后,使用CAS的原子条件更新来实现线程之间的同步。
同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
7、final
8、双重检查和延迟优化
上面代码表面上看起来,似乎两全其美:在多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。在对象创建好之后,执行getInstance()将不需要获取锁,直接返回已创建好的对象。
双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
问题的根源:
前面的双重检查锁定示例代码的第7行(instance = new Singleton();)创建一个对象。这一行代码可以分解为如下的三行伪代码:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
上面三行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器上,这种重排序是真实发生的,详情见参考文献1的“Out-of-order writes”部分)。2和3之间重排序之后的执行时序如下:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址
//注意,此时对象还没有被初始化!
ctorInstance(memory); //2:初始化对象
<div class="image-package">
<div class="image-container" style="max-width: 700px; max-height: 311px; ">
<div class="image-container-fill" style="padding-bottom: 26.33%;"></div>
<div class="image-view" data-width="1181" data-height="311">
</div>
<div class="image-caption"></div>
</div>
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
1)不允许2和3重排序。 2)允许2和3重排序,但不允许其他线程“看到”这个重排序。
当声明对象的引用为volatile后,“问题的根源”的三行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。(注意,这个解决方案需要JDK5或更高版本,因为从JDK5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义。)
另外一种方式:通过内部类的加载来实现
作者:JavaEdge
链接:https://www.jianshu.com/p/26385b1b9a8c
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。