阅读 689

volatile、synchronized、final原理浅析

1. 前言

只会使用,不明白原理,就不能灵活运用,深刻理解这几个关键字,对于并发编程来说很有帮助。

2. volatile

2.1 volatile 的作用

volatile 的作用有一下两点:

  • 修改立即可见
  • 禁止指令重排序

可见性是指当一个线程修改了共享变量的值,其它线程能够适时得知这个修改。

可见性

导致线程可见性问题有两个原因:

  1. 线程对变量进行修改未同步到主内存,那么这个线程对改变量的修改就是不可见的。
  2. 重排序。为了提高程序的执行效率,编译器在生成指令序列时和CPU执行指令序列时,都有可能对指令进行重排序。

2.2 原理

实现可见性: 禁用工作内存和 Happens-Before 规则的前三条

实现可见性

由 JMM 可知一般的变量写是先写到工作内存,然后由 buffer 刷到主存中的。volatile 标记的变量禁用了工作内存,即直接写到主存中。其他线程读取该变量时,直接从主内存中读取。

Happens-Before 规则的前三条:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  3. volatile变量规则:对一个 volatile 域的写,happens-before于任意后续对这个volatile 域的读。

Happens-Before 规则可参考:

Java内存模型以及happens-before规则

禁止重排序的实现其实也依赖了 happen-before 原则。

JVM底层是通过一个叫做“内存屏障”的东西来完成。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。该屏障用来保证在 volatile 写之前,其前面所有的普通写操作已经对任意处理器可见
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。避免 volatile 写操作可能与后面可能有的 volatile 写操作重排序
  • 在每个 volatile 读操作的前面插入一个 LoadLoad 屏障。用来避免将 volatile 读和前面的普通读重排序
  • 在每个 volatile 读后面插入一个 LoadStore 屏障。避免后面的普通写和 volatile 读重排序

所谓的保守策略即保证在任何处理器上都能得到正确的语意,实际上编译器会自动优化以省略某些语意(比如因为 X86 不会对读读、读写、写写重排序,就可以省下这三种屏障)

编译器不会对 volatile 读与 volatile 读后面的任意内存操作(包括对普通变量的读写)重排序,也不会对 volatile 写与 volatile 写前面的任意内存操作重排序

2.3 总结

简而言之,volatile 变量自身具有下列特性:

  1. 可见性。对一个 volatile 变量的读,总能看到(任意线程)对这个 volatile 变量最后的写入。
  2. 原子性。对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

3. final

final 修饰变量、修饰方法、修饰类,都有什么作用就不详细讲解了,讲讲原理。

对于final域,编译器和处理器要遵守两个重排序规则:

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(先写入final变量,后调用该对象引用)

原因:编译器会在final域的写之后,插入一个StoreStore屏障

  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序(先读对象的引用,后读final变量)

原因:编译器会在读final域操作的前面插入一个LoadLoad屏障

详细讲解参考:

Java并发(十九):final实现原理

4. synchronized

synchronized 的底层是使用操作系统的 mutex lock 实现的。

  • **内存可见性:**同步快的可见性是由“如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值”、“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store 和 write 操作)”这两条规则获得的。
  • **操作原子性:**持有同一个锁的两个同步块只能串行地进入

synchronized 用的锁是存在 Java 对象头里的。

JVM基于进入和退出 Monitor 对象来实现方法同步和代码块同步。代码块同步是使用 monitorentermonitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。`

根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1;相应地,在执行 monitorexit 指令时会将锁计数器减1,当计数器被减到 0 时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

注意两点:

1、synchronized 同步快对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

2、同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

想要详细了解,下面这篇文章讲德特别棒:

Java synchronized原理总结

5. 小结&参考资料

小结

这三个关键字在 JMM 中起着至关重要的作用,JMM 规范的保证依靠这些关键字实现。同时,这几个关键字的熟练使用也非常非常重要,得深刻理解。

参考资料

Java 并发编程:volatile的使用及其原理

Java并发编程的艺术

从Java多线程可见性谈Happens-Before原则

谈乱序执行和内存屏障

Java final关键字及其内存语义

Final of Java,这一篇差不多了

Java并发(十九):final实现原理

Java synchronized原理总结