volatile的应用
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的 "可见性" (当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值)。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,不会引起线程上下文的切换和调度。
volatile的定义与实现原理
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
术语 | 英文单词 | 术语描述 |
---|---|---|
内存屏障 | memory barries | 一组处理器指令,用于实现对内存的顺序限制 |
缓冲行 | cache line | CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L2,L3,L3的或所有) |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它会首先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中。 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
通过X86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会进行什么处理
Java代码如下:
ini
instance = new Singleton(); // instance是volatile变量
转变成汇编代码,如下:
css
0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock add1 &0x0, (%esp);
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,Lock前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存。
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行处理,但操作完不知道何时会写到内存。
如果对声明了volatile的变量进行写操作 ,JVM就会向处理器发送一条Lock前缀 的指令,将这个变量所在缓存行的数据写回到系统内存 。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
synchronized的实现原理与应用
利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式:
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是synchronized括号里配置的对象。
synchronized在JVM里的实现原理:基于进入和退出Monitor对象来实现方法同步和代码块同步,都可以使用monitorenter
和monitorexit
指令实现。
monitorenter
指令是在编译后插入到同步代码块的开始位置,而monitorexit
是插入到方法结束处和异常处,JVM要保证每个monitorenter
必须有对应的monitorexit
与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter
指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象所。
Java对象头
synchronized用的锁存在Java对象头里的。Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下表所示:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
锁的升级与对比
锁一共有4种状态,级别从低到高依次 是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级,锁可以升级不能降级。
1.偏向锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
如果测试成功 ,表示线程已经获得了锁,否则 ,再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置 ,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的获得和撤销流程如下:
偏向锁的撤销(在一个没有正在执行的字节码的时间点撤销)发生在另一个线程尝试获取已经偏向于某一线程的锁
2.轻量级锁
-
轻量级加锁
线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头总的Mark Word复制到锁记录中,称为
Displaced Mark Word
。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功 ,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋获取锁 -
轻量级解锁
轻量级解锁时,会使用原子的CAS操作将
Displaced Mark Word
替换回到对象头,如果成功 ,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
线程2中获取锁失败的原因是:线程1已经将Mark Word更改成指向锁记录的指针,所以线程2的CAS会失败
线程1中第二个CAS失败是因为线程2将Mark Word已经更改为指向重量级锁的指针,所以这里的CAS会失败
3.锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳米级差距 | 如果线程存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
原子操作
Java中可以通过锁和循环CAS的方式来实现原子操作。
CAS实现原子操作的三大问题:
-
ABA问题
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,如果一个值原本是A,变成了B,又变成了A,那么使用CAS进行检查时会发现值没有变化,但实际发生了变化。解决思路是使用版本号,在变量前面加上版本号,每次变量更新的时候把版本号加1,那么A->B->A,就变成了1A->2B->3A。
-
循环时间长开销大
-
只能保证一个共享变量的原子操作
对多个共享变量操作时,循环CAS就无法保证操作的原子性。Java1.5后,JDK提供了AtomicReference类来保证引用对象之间的原子性,这样就可以把多个变量放在一个对象里来进行CAS操作。