前言
代码最终需要转变为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
本文将深入底层去理解并发机制的底层实现原理。会介绍:
- volatile的实现原理
- final的实现原理
- synchronized的实现原理
- 原子操作的实现原理
1. volatile的定义和实现原理
-
volatile定义
Java语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。
如果一个变量被声明成volatile,Java线程内存模型会确保所有线程看到这个变量的值是一致的。它在多处理器开发中通过插入内存屏障保证了共享变量的可见性,并且禁止指令重排序。 -
CPU术语定义和内存屏障类型
CPU术语定义
内存屏障类型 -
volatile是如何保证可见性的呢
通过查看生成的汇编指令发现,对volatile进行写操作时:- Lock前缀指令将当前处理器缓存行的数据写回到系统内存
- 为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(线程工作内存)后再进行操作,但操作完后不会立刻写会到主内存。
- 如果是对volatile变量进行写操作,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是其他处理器缓存还是旧的,再执行计算操作就会有问题。
- 一个处理器的缓存写回到内存的操作会使在其他CPU里缓存了该内存地址的数据无效
- 所以为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
- 所以为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。
- Lock前缀指令将当前处理器缓存行的数据写回到系统内存
-
volatile的实现原理:
在每个volatile写操作前插入一个StoreStore
屏障
在每个volatile写操作后插入一个StoreLoad
屏障
在每个volatile读操作后插入一个LoadLoad
屏障
在每个volatile读操作后插入一个LoadStore
屏障
这是JMM基于保守策略的内存屏障插入策略,在具体实现时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。 -
volatile只保证了并发的可见性和顺序性,并未保证原子性,在使用时要多多小心。
2. final域的内存语义
-
对于final域,编译器和处理器要遵守两个重排序规则:
- 写final域的重排序规则:在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 读final域的重排序规则:初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
- 对于引用类型:在构造函数内对一个final引用对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序(即会先初始化final引用变量的成员变量)。
-
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。
-
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
-
只要对象是被正确构造的,即被构造对象的引用在构造函数中没有逸出,那么不需要使用同步,就可以保证任意线程都能看到这个final域在构造函数中被初始化的值。
-
final的实现原理:
写final域的重排序规则要求编译器在final域的写之后,构造函数return之前插入StoreStore
屏障;
读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad
屏障。
3. synchronized的实现原理
本节介绍对synchronized的优化,在Java6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。
- 当一个线程试图访问同步代码块时,它首先必须要得到锁,退出或抛出异常必须释放锁;Java中的每一个对象都可以作为锁,具体表现为以下3种形式:
- 对于普通同步方法,锁是当前实例对象
- 对于静态同步方法,锁是当前类的Class对象
- 对于同步方法块,锁是Synchronized括号里配置的对象
从JVM规范中可以看到,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。但两者实现细节不一样。
-
代码块同步
在编译后通过将monitorenter
指令插入到同步代码块开始处,将monitorexit
指令插入到方法结束处和异常处;
任何一个对象都有一个monitor与之关联,线程执行到monitorenter
指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁;
当且一个monitor被持有时,它将处于锁定状态。 -
方法同步
synchronized方法在method_info结构
有AAC_synchronized
标记,线程执行时会识别该标记,获取对应的锁,实现方法同步。
两者实现细节不同,但本质上都是对一个对象的监视器(Monitor)的获取。
一个线程在执行到同步代码块或同步方法时,执行方法的线程必须先获得该对象的监视器才能执行;没有获取到监视器的线程将会阻塞,进入同步队列,状态变为Blocked
。当成功获取监视器的线程释放锁后,会唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
下一篇中将介绍锁的种类和升级。
4. 原子操作的实现原理
CPU术语定义首先处理器会自动保证基本的内存操作原子性。而对复杂操作(跨总线宽度、跨多个缓存行和跨页表访问),处理器提供总线锁定和缓存锁定两个机制来保证它的操作的原子性。
-
使用总线锁保证原子性
使用处理器提供的一个Lock#
信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
总线锁定把CPUT和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大。
所以处理器在某些场合下使用缓存锁定来代替总线锁定。 -
使用缓存锁保证原子性
缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。 -
不能使用缓存锁定的两种情况
- 处理器不支持
- 当前操作的数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行时,处理器会调用总线锁定。
5. Java如何实现原子操作
通过 锁 和 循环CAS 的方式来保证操作的原子性。
1.使用CAS实现原子操作
Java中CAS操作利用了处理器的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。
- CAS实现原子操作的缺点
- ABA问题
如果一个值原来是A,变成了B,又变成了A,那么CAS进行检查时会发现它的值没有发生变化,但实际上却变化了。
ABA问题的解决思路上是使用版本号,在变量前追加上版本号,每次变量更新时把版本号加1。
JDK提供了AtomicStampedReference来解决此类问题。 - 循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来很大的执行开销。 - 只能保证一个共享变量的原子操作。
对多个共享变量时,循环CAS就无法保证其原子性,这个时候可以用锁。
取巧的办法时将多个共享变量合成一个共享变量来操作,比如i_j。
JDK提供了AtomicReference类保证引用对象之间的原子性,可以把多个对象放在一个对象里进行CAS操作。
- ABA问题
- 使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能操作锁定的内存区域。
除了偏向锁,JVM实现锁的方式都用了循环CAS,当一个线程进行同步块的时候使用CAS来获取锁,当线程退出同步块时使用CAS释放锁。
结语
本文介绍了volatile、final,synchronized、原子操作的实现原理,了解这些对后续了解并发框架和容器会更有帮助。
参考:
ABA问题示例
网友评论