volative的应用
volatile的定义与实现原理
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
术语 | 英文单词 | 术语描述 |
---|---|---|
内存屏障 | memory barriers | 一组处理器指令,用于实现对内存操作的顺序限制 |
缓冲行 | cache line | 缓存中可以分配的最小存储单位。 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
有volatile变量修饰的共享变量进行写操作的时候会多出一些汇编代码,加入Lock前缀。Lock前缀的指令在多核处理器会引发两件事情
1. 将当前处理器缓存行的数据写回到系统内存
2. 这个写回内存的操作会使在其他cpu里缓存了该内存地址的数据无效。
在多处理器下,为了保证各个处理器的缓存是一致的,会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
volatile的两条实现原则
1. Lock前缀指令会引起处理器缓存回写到内存
2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
synchronized的实现原理和应用
Java中的每一个对象都可以作为锁。具体表现为以下3种形式:
1. 对于普通同步方法,锁是当前实例对象。
2. 对于静态同步方法,锁是当前类的Class对象。
3. 对于同步方法块,锁是Synchonized括号里配置的对象。
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
java对象头
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽
(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM
的Mark Word的默认存储结构如下图示
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变
化为存储以下4种数据,如下图示
在64位虚拟机下,Mark Word是64bit大小的,如下图示
锁升级
Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:
1. 无锁状态
2. 偏向锁状态
3. 轻量级锁状态
4. 重量级锁状态
这几个状态会随着竞争情况逐渐升级。
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同
一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
当出现偏向锁竞争的时候,按如下步骤执行
1. 暂停拥有偏向锁的线程
2. 检查持有偏向锁的线程是否还alive,若不是,则将对象头设置为无锁状态,否则执行3
3. 线程仍然活着,执行偏向锁的栈,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么回复到无锁或者标记对象不适合作为偏向锁
4. 唤醒暂停的线程
关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如
有必要可以使用JVM参数来关闭延迟:
-XX:BiasedLockingStartupDelay=0
如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:
-XX:-UseBiasedLocking=false
那么程序默认会进入轻量级锁状态。
轻量锁
轻量锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失 败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
displaced mark word是整个轻量级锁实现的关键,在CAS中的compare就需要用它作为条件。在拷贝完object mark word之后,JVM做了一步交换指针的操作将object mark word里的轻量级锁指针指向lock record所在的stack指针,作用是让其他线程知道,该object monitor已被占用(就像偏向锁中用CAS的方式将mark word的id指向当前尝试获取锁的线程id,这里是将mark word中的轻量级锁指针以CAS的方式尝试指向当前线程的lock record,这样别的线程便知道当前轻量锁已经指向别的线程了)。lock record里的owner指针指向object mark word的作用是为了在接下里的运行过程中,识别哪个对象被锁住了。
轻量锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成
功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
轻量锁的膨胀流程如下图示
所以由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的mark word,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对mark word做了修改,两者比对发现不一致,则切换到重量锁。
因为重量级锁被修改了,所有display mark word和原来的mark word不一样了。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。
锁的优缺点对比
原子操作的实现原理
术语定义
术语名称 | 英文 | 解释 |
---|---|---|
缓存行 | Cache line | 缓存的最小操作单位 |
比较并交换 | Compare and Swap | CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有,这换成新值,否则不交换 |
CPU流水线 | CPU pipeline | 略 |
内存顺序冲突 | Memory order violation | 内存顺序冲突一般是由假共享引起的,假共享是指对个CPU同时修改同一个缓存行的不同部分而引起的其中一个cpu的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线 |
处理器实现原子操作
使用总线锁保证原子性
如果多个处理器同时对共享变量进行读改写操作
(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操
作就不是原子的,操作完之后共享变量的值会和期望的不一致。
所谓总线锁就是使用处理器提供的一个
LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该
处理器可以独占共享内存。
使用缓存锁保证原子性
第二个机制是通过缓存锁定来保证原子性。在同一时刻,我们只需保证对某个内存地址
的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处
理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存
行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效
有两种情况下处理器不会使用缓存锁定:
1. 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行
(cache line)时,则处理器会调用总线锁定
2. 有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的
内存区域在处理器的缓存行中也会调用总线锁定
java如何实现原子操作
在Java中可以通过锁和循环CAS的方式来实现原子操作
1. 使用循环CAS实现原子操作
2. CAS实现原子操作的三大问题
- ABA问题
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化
则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它
的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面
追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从
Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题
- 循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
- 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循
环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。
3. 使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁
机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁