你是否对“线程之间怎么同步数据”这个问题产生过思考?今天带你走进JUC的大门,去了解什么是JMM(Java内存模型),线程之间是如何操作变量值的,volatile
关键字在这其中又起到了什么作用!
Java内存模型(Java Memory Model,简称 JMM) 是Java并发编程的基石,定义了线程如何与主内存及工作内存交互的规则,核心目标是解决多线程环境下的可见性、原子性和有序性问题。
其规定了所有的变量都存储在主内存(Main Memory)中。每条线程都有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程与线程之间的变量数据是不可见的。
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
这8种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对volatile的一些特殊规定,就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。
这种定义相当严谨,但也是极为烦琐,实践起来更是无比麻烦。可能部分读者阅读到这里已经对多线程开发产生恐惧感了,后来Java设计团队大概也意识到了这个问题,将Java内存模型的操作简化为read、write、lock和unlock四种。
volatile
关键字是JMM中实现可见性和禁止指令重排序的关键。 volatile
可以说是Java虚拟机提供的最轻量级的同步机制
当一个变量被定义成volatile之后,它将具备两项特性:
第一项是 保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁 (使用 synchronized、java.util.concurrent中的锁或原子类)来保证原子性:
运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
变量不需要与其他的状态变量共同参与不变约束。
相关信息
volatile 并不不保证原子性
使用volatile变量的第二个语义是 禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
有volatile修饰的变量,赋值后多执行了一个“lock addl$0x0,(%esp)”操作,这个操作的作用相当于一个内存屏障 (Memory Barrier 或 Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置)。
内存屏障
被 volatile 修饰的变量存在一个 Lock 前缀的指令lock 前缀指令实际上相当于一个内存屏障
解决了volatile的语义问题,再来看看在众多保障并发安全的工具中选用volatile的意义——它能让我们的代码比使用其他的同步工具更快吗?
在某些情况下,volatile 的同步机制的性能确实要优于锁(使用 synchronized 关键字或 java.util.concurrent 包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说 volatile 就会比 synchronized 快上多少。
如果让 volatile 变量与普通变量比较,那可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁来得更低。
总结
对于 volatile 与锁中选择的唯一判断依据,仅仅是 volatile 的语义能否满足使用场景的需求。
volatile是通过MESI缓存一致性协议来保证可见性的,volatile对变量的修改会立马对主内存进行回写,其他线程使用时发现变量已失效,在从主内存中重新读取
什么是MESI协议?
MESI协议如何保证可见性?
首先cpu会根据共享变量是否带有Volatile字段,来决定是否使用MESI协议 保证缓存一致性
如果有volatile,汇编层面会对变量加上Lock前缀,当一个线程修改变量的值后,会马上经过store、write等原子操作修改主内存的值
通过cpu总线嗅探机制监听到这个变量被修改,就会把其他线程的变量副本由共享S置为无效I,当其他线程在使用变量副本时,发现其已经无效,就回去主内存中拿一个最新的值。
内存模型的特性,也就是我们常说的 并发的三大特性 原子性、可见性、有序性
如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在 synchronized 块之间的操作也具备原子性。
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile
变量都是如此。
普通变量与 volatile
变量的区别是,volatile
的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了 volatile
之外,Java还有两个关键字能实现可见性,它们是 synchronized
和 final
。
Java内存模型的有序性在前面讲解volatile时也比较详细地讨论过了,Java程序中天然的有序性可以总结为一句话:
如果在本线程内观察,所有的操作都是有序的; 如果在一个线程中观察另一个线程,所有的操作都是无序的。
前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java语言提供了 volatile
和 synchronized
两个关键字来保证线程之间操作的有序性,volatile
关键字本身就包含了禁止指令重排序的语义,而 synchronized
则是由“一个变量在同一个时刻只允许一条线程对 其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
本文作者:柳始恭
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!