JMM是怎么解决原子性、可见性、有序性问题的?

  1. 云栖社区>
  2. 博客>
  3. 正文

JMM是怎么解决原子性、可见性、有序性问题的?

浦涛 2018-10-30 13:43:29 浏览5193
展开阅读全文

Java内存模型封装了底层的实现后提供给开发人员一系列和并发处理相关的关键字,,比如volatile、Synchronized、final等,在开发多线程代码的时候,我们可以直接使用 这些关键词来控制并发,从而不需要关心底层的编译器优化、缓存一致性的问题了,所以JMM除了定义了一套规范外,还给开发人员提供了一套在底层封装后的开放的指令。
一.原子性
在java中提供了两个高级的字节码指令monitorenter和monitorexit,使用对应的关键字Synchronized来保证代码块内的操作是原子的
二.可见性
在Java中可以使用volatile关键字来保证多线程操作时变量的可见性。volatile的功能是被其修饰的变量在被修改后可以立即同步到主内存,而被其修饰的变量在每次使用之前都会从主内存刷新。除此之外,synchronized和final两个关键字也可以实现可见性 。volatile也可以看作是轻量级的锁,在其内部使用了Lock指令来解决可见性问题。
volatile关键字修饰的共享变量,在进行写操作的时候会多出一个lock前缀的汇编指令,这个指令会触发总线锁或者缓存锁,通过缓存一致性协议来解决可见性问题。对于声明了volatile的变量进行写操作时,JVM就会向处理器发送一条Lock前缀的指令,把这个变量所在的缓存行的数据写回到系统内存,再根据MESI的缓存一致性协议,来保证多核CPU下的各个高速缓存中的数据的 一致性。

volatile用法总结:
对一些可变的对象,属性做修饰,修饰以后能保证可见性和防止内存重排序。
保证可见性:会使得#Lock指令(汇编指令)基于CPU的缓存锁和MESI的协议实现缓存一致性。
防止内存重排:内存屏障。
volatile对复合操作无法保证原子性。对单操作可以保证原子性。
对一个原子递增的操作,会分为三个步骤:1.读取volatile变量的值到local;2.增加变量的值;3.把local的值写回让 其他线程可见
volatile使用场景:
1.停止线程,主线程改变了变量的值要对子线程可见,因为这个变量是子线程停止线程的标志。

三.有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。只是实现方式有所区别: volatile关键字会禁止指令重排,synchronized关键字保证同一时刻只允许一个线程的操作。
volatile防止指令重排:
指令重排的目的是为了最大化的提高CPU利用率以及性能,CPU的乱序执行优化在单核时代并不影响正确性,但是 在多核时代,多线程能够在不同的核心上实现真正的并行,一旦线程之间共享数据,再加上CPU的乱序执行优化(CPU执行汇编指令),就可能会出现一些不可预料的问题。
指令重排序必须要遵循的原则是,不影响代码执行的最终结果。编译器和处理器不会改变存在数据依赖关系(仅仅是针对单个处理器中执行的指令和操作)的两个操作的执行顺序,不管怎么重排序,单线程程序的执行结果不会改变,编译器、处理器都必 须遵守这个原则。
指令重排序,从代码开始编写到最后执行出结果的过程,特点是能提升性能和合理利用CPU资源,不会改变代码原有的语义,包括编译器的优化重排序:会导致可见性问题(保证语义的正确性);CPU的指令重排序:CPU包括寄存器(存储本地变量,函数的参数等)和高速缓存l1l2l3(提高CPU和内存之间交互的性能),缓存离CPU越远性能越低,效率排名,寄存器>L1>L2>L3;内存系统的重排序:
L1,l2缓存不共享,用缓存一致性协议MESI解决,当一个CPU在进行写操作的时候,其他的CPU在等待,为了减少等待时间,引入了storebuffer.loadbuffer来缓冲等待的时间,提高性能。相对于MESI来说,这就是异步机制,而MESI可以看成是同步阻塞的。 L2,L3在多核心CPU上缓存是共享的。
重排序会导致可见性问题,但是重排序带来的问题的严重性可能远远大于可见性,因为并不是 所有指令都是简单的读或写,比如DCL(synchnozied双重检查锁)的部分初始化问题。

现在需要解决两个问题,一个是编译器的优化乱序,另一个是CPU的执行乱序,我们可以分别使用优化 屏障和内存屏障这两个机制来解决。
内存屏障
先从CPU层面来了解一下什么是内存屏障?
CPU的乱序执行,本质还是由于在多CPU的机器上,每个CPU都存在cache,当某个数据第一次被一个 CPU获取时,由于在该CPU缓存中不存在这个数据,就会从内存中去获取,从而被加载到CPU高速缓存中后就能从缓存中快速访 问。当某个CPU进行写操作时,必须确保其他的CPU已经将这个数据从他们各自的缓存中移除,这样才能让其他CPU 在各自的缓存中安全的修改数据。显然,存在多个cache时,我们必须通过缓存一致性协议来避免数据不一致的问题,而这 个通讯的过程就可能导致乱序访问,也就是运行时的内存乱序访问。
现在的CPU架构一般都提供了内存屏障功能,不同CPU架构的内存屏障是不一样 的,有的CPU支持内存强一致性,就不需要内存屏障了。
内存屏障分为:写屏障(store barrier)、读屏障(load barrier)和全屏障(Full Barrier),主要的作用有两点:防止指令之间的重排序和保证数据的可见性 。
写屏障(store barrier):相当于storestore barrier, 强制所有在storestore内存屏障之前的所有执行,都要在该 内存屏障之前执行,并发送缓存失效的信号。所有在storestore barrier指令之后的store指令,都必须在 storestore barrier屏障之前的指令执行完后再被执行。也就是禁止了写屏障前后的指令进行重排序,使得所有 store barrier之前发生的内存更新都是可见的(修改值可见及操作结果可见)
读屏障(load barrier):相当于loadload barrier,强制所有在load barrier读屏障之后的load指令,都在load barrier屏障之后执行。也就是禁止对load barrier读屏障前后的load指令进行重排序, 配合store barrier,使得所 有store barrier之前发生的内存更新,对load barrier之后的load操作是可见的
全屏障(Full Barrier):相当于storeload,是一个全能型的屏障,因为它同时具备前面两种屏障的效果。强制了 所有在storeload barrier之前的store/load指令,都在该屏障之前被执行,所有在该屏障之后的的store/load指 令,都在该屏障之后被执行。禁止对storeload屏障前后的指令进行重排序。
综上所述:内存屏障只是解决了顺序一致性问题,不解决缓存一致性问题,缓存一致性是由cpu的缓存锁以及MESI协议来 完成的。而缓存一致性协议只关心缓存一致性,不关心顺序一致性。

优化屏障
编译器层面如何解决指令重排序问题?
在编译器层面,通过volatile关键字,取消编译器层面的缓存和重排序。保证编译程序时在优化屏障之前的指令不 会在优化屏障之后执行。这就保证了编译时期的优化不会影响到实际代码运行时的逻辑顺序。

如果硬件架构本身已经保证了内存可见性,那么volatile就是一个空标记,不会插入相关语义的内存屏障。如果硬 件架构本身不进行处理器重排序,有更强的重排序语义,那么volatile就是一个空标记,不会插入相关语义的内存 屏障。

在JMM中把内存屏障指令分为4类,是用来解决编译器的重排序和CPU的指令重排序问题的,通过在不同的语义下使用不同的内存屏障来禁止特定类型的处理器重排序,从 而来保证内存的可见性。

LoadLoad Barriers, load1 ; LoadLoad; load2 , 确保load1数据的装载优先于load2及所有后续装载指令的装载
StoreStore Barriers,store1; storestore;store2 , 确保store1数据对其他处理器可见优先于store2及所有后续存储 指令的存储
LoadStore Barries, load1;loadstore;store2, 确保load1数据装载优先于store2以及后续的存储指令刷新到内存
StoreLoad Barries, store1; storeload;load2, 确保store1数据对其他处理器变得可见, 优先于load2及所有后续 装载指令的装载;这条内存屏障指令是一个全能型的屏障,它同时具 有其他3条屏障的效果
如果加了这两个操作loadload,,loadstore,操作之前的指令必须要在操作之后的指令之前执行完,即后续的指令必须要晚于这个屏障之后去执行。

原文:https://blog.csdn.net/lx_Frolf/article/details/82686201
版权声明:本文为博主原创文章,转载请附上博文链接!

网友评论

登录后评论
0/500
评论
浦涛
+ 关注