第2章 Java并发机制的底层实现原理
Java代码在编译后变成字节码,字节码被类加载器加载到JVM中,JVM执行字节码,最终转换为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
2.1 volatile的应用
在并发编程中synchronized和volatile都具有重要的作用,volatile是轻量级的synchronized,保证了共享变量的可见性。
可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
如果volatile变量修饰符使用得当的话,会比synchronized的使用和执行成本更低,因为volatile不会引起线程上下文切换和调度。
1、volatile的定义和实现原理
在Java语言规范中对volatile的定义如下:Java编程语言中允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁来确保单独获取这个变量。Java提供了volatile,在某些情况下比锁更加方便。
与volatile实现相关的CPU术语:内存屏障(memory barriers)、缓冲行(cache line)、原子操作(atomic operations)、缓冲行填充(cache line fill)、缓冲命中(cache hit)、写命中(write hit)、写缺失(write misses the cache)
volatile如何保证可见性?被volatile修饰的共享变量进行写操作时会多出加了lock的汇编代码,lock指令在多核处理器下会依法2件事情:
1)将当前处理器缓冲行的数据写回到系统内存
2)这个写回内存操作会使在其他CPU里缓存了该内存地址的数据无效
在多处理器下,为了保证各个处理器的缓存一致性,就会实现缓存一致性协议,每个处理器通过嗅探总线上传播的数据来检查自己缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作时,会重新从内存系统中把数据读到处理器缓存里。
volatile的两条实现原则:
1)Lock前缀指令会引起处理器缓存回写到内存。
2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效
2、volatile的使用优化
JDK 7的并发包中新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列的出队、入队的性能。LinkedTransferQueue里的PaddedAtomicRefernce内部类只做了1件事,就是讲volatile共享变量追加到64字节(一个对象引用占4字节、追加15个变量就是60字节,加上父类的value变量,一共64字节)。对于64位的处理器,追加64字节能提高并发编程的效率,因为64位处理器不支持部分填充缓冲行,这就意味着,如果队列的头结点和尾结点不足64字节,处理器会将它们读取到一个缓冲行中,再多处理器下的每个处理器都会缓存同样的头、尾结点,当一个处理器锁定缓冲行进行修改时,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾结点,导致队列的出队入队效率低下。追加字节后,避免了头结点和尾结点在一个缓冲行中,可以使得头尾节点修改时不会相互锁定。
当缓存行不是64字节宽的处理器;共享变量不会被频繁地写(因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这样会带来一定的性能消耗;如果共享变量不被频繁的写的话,锁的几率非常小,也就没必要通过追加字节的方式来避免相互锁定),也就不需要追加到64字节。
不过,这种追加字节的方式在Java7下可能不生效,因为Java7会淘汰或重新排列无用字段,需要使用其他追加字节的方式。
2.2 synchronized的实现原理与应用
synchronized是重量级锁,在Java SE 1.6 对synchronized进行了各种优化,就没那么重了。Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗问题而引入了偏向锁、轻量级锁等优化措施。
synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现形式:对于普通同步方法,锁是当前实例对象;对于静态同步方法,锁是当前类的Class对象;对于同步代码块,锁是synchronized括号里配置的对象。当一个线程试图访问同步代码块/同步方法时,必须先得到锁,退出或者抛出异常时必须释放锁。
synchronized在JVM的实现原理是:JVM基于进入和退出Monitor对象实现方法或者代码块同步。但是二者实现细节不一样。同步代码块是使用monitorenter和monitorexit指令实现,而同步方法时其他方式。但是同步方法也可以使用这两个指令实现。
2.2.1 Java对象头
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽存储对象头,非数组类型使用2字宽存储对象头。1宽等于4字节。
Java对象头:Mark Word(存对象的hashCode、分代年龄、锁信息等)、Class Metadata Address(存储到对象类型数据的指针)、Array Length(数组的长度(如果当前对象是数组))
2.2.2 锁的升级与对比
在JavaSE1.6中,锁的状态有:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级。锁可以升级,但是不可以降级,目的是为了提高获得锁和释放锁的效率。
1 偏向锁
在大多的情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引入了偏向锁 。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里边存储偏向锁的线程ID,以后该线程在进入和退出同步块是不需要进行CAS操作来加解锁,只需要测试一下对象头中的Mark Word中是否存储着指向该线程的偏向锁。若是测试成功,表示线程已经获得了锁;若是测试不成功,则需要在测试一下Mark Word中偏向锁的标志是否设置为1(表示当前已经处于偏向锁状态):若是没有设置,则使用CAS锁竞争机制来竞争锁;若是设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
(1)偏向锁的撤销
偏向锁使用一种等到竞争出现才释放锁的机制,所以当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放。
偏向锁的撤销,需要等到全局安全点(在这个时间点上没有正在执行的字节码)。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
(2)关闭偏向锁
Java 6和Java7默认是启动偏向锁的。是在应用程序启动后几秒钟才激活。可以使用JVM参数 -XX:BiasedLockingStartupDelay=0设置立即启动,关闭延迟。可以使用 -XX:-UsebiasedLocking-false关闭偏向锁,那么程序默认进入轻量级锁状态。
2 轻量级锁
(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为“Displaced Mark Word”。然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS造作将Displaced Mark Word替换回到对象头,如果成功,则表示没有发生竞争。如果失败,表示当前锁存在竞争,锁会膨胀为重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下时,其他线程试图获取锁是都会被阻塞,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
3、锁的优缺点对比
2.3 原子操作的实现原理
原子操作也就是说这个操作是不可以在进行细分的,必须一次性全部执行完成,不可以执行一部分之后被中断去执行另一个操作。
1、相关术语
缓存行(cache line)、比较并交换(Compare and Swap)、CPU流水线(CPU pipeline)、内存顺序冲突
2、处理器是如何实现原子操作的
对于处理器而言,原子操作也就是说在同一时间只能有一个处理器对数据进行处理,而且这个操作是原子性的,不可分割的。常见的有两种实现方式:一种是通过总线锁来保证原子性,另一种是通过缓存锁来保证原子性。
总线锁:总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,这样发出信号的处理器就可以独占内存,保证操作的原子性。
缓存锁:缓存锁是为了优化总线锁而设计出来的。因为总线锁在被锁住期间,其他的处理器是无法处理其他的数据的,只能等待锁释放开。但是若是对缓存进行加锁就可以减少这个影响。他是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作写回到内存时,处理器不在总线上声言Lock信号,就直接修改内部的内存地址,并允许他的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。
但是有两种情况下处理器不会使用缓存锁定:
1)第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定;
2)第二种情况是:有些处理器不支持缓存锁。就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
针对以上两个机制,可以使用Lock前缀的指令来实现。
3、Java如何实现原子操作
在Java中采用了锁和循环CAS的方式来保证原子操作
(1) 循环CAS:JVM的CAS操作正式利用了处理器提供的CMPXCHG指令实现的。基本思路就是通过循环进行CAS操作直到成功为止。
(2)CAS实现原子操作的三大问题
1)ABA问题
解决思路是使用版本号,在变量前面追加上版本号,每次变量更新的时候把版本号加1。JDK1.5使用AtomicStampedReference解决ABA问题;这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
2)循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。
pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3)只能保证一个共享变量的原子操作
解决思路是可以把多个共享变量合并成一个共享变量来操作。即将多个变量放在一个对象里来进行CAS操作。
(3)锁机制:锁机制保证只有获得锁的线程才能操作锁定的内存区域,但是Java中的多个锁,除了偏向锁以外,JVM实现锁的方式都是循环CAS,即当一个线程想进入同步块时使用循环CAS获取所;当退出同步块时使用循环CAS释放锁。