第2章 Java并发机制的底层实现原理
第2章 Java并发机制的底层实现原理
Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行。
2.1 Volatile
Volatile是轻量级的synchronized,作用是保存多处理器开发中的共享变量的"可见性"。
- 可见性:当一个线程修改一个共享变量时,另外一个线程可以读到这个修改的值。
如果使用恰当的话,volatile会比synchronized的使用和执行成本更低,因为它不会引起线程的上下文切换和调度。
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量
-
内存屏障:一组处理器指令,用于实现对内存操作的顺序限制
-
缓冲行:CPU高速缓存中可以分配的最小存储单位,处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令
-
原子操作:不可中断的一个或一系列操作
-
缓存行填充:当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3的或所有)
-
缓存命中:如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取
-
写命中:当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数回写到缓存中,则处理器将这个操作数写回到缓存,而不是写回到内存。
-
写缺失:一个有效的缓存行被写入到不存在的内存区域
有volatile关键字修饰的共享变量进行写操作时汇编代码会多出一行:
lock add1 0 x 0 , ( 0x0,( 0x0,(esp);
Lock前缀的指令在多核处理器下引发了两件事
(1)将当前处理器缓存行的数据写回到系统内存
(2)这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效
为了处理效率,处理器不会不会直接和内存进行通信,而是将内存的数据读到内部缓存(L1,L2,或其他)后再进行操作,回写到处理器的时机并不一定。
如果对声明了volatile的变量进行写操作,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据回写到内存中。为了保证其他处理器缓存的值与内存中的新值保持一致(缓存一致性协议),每个处理器通过嗅探在总线上传播的数据来和自己缓存上的数据比对。如果处理器发现缓存行对应的内存地址被修改,就会把当前处理器的缓存行设置为无效状态,之后当该处理器对这个数据进行操作时,发现没有缓存该数据,就会去取内存中的新数据了。
Volatile实现原则的细节
(1)Lock前缀指令会引起处理器缓存回写到内存
Lock前缀指令会在执行指令的过程中声言处理器的LOCK#信号,在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占所有共享内存。(因为他会锁住总线,导致其他CPU不能访问总线,不能访问总线意味着不能访问内存。
在最近的处理器中,LOCK#一般不会锁总线(Intel486 Pentium处理器锁总线),而是锁缓存。在P6和目前的处理器中,如果访问的内存区域已被缓存在处理器内部,则不会声言LOCK#信号,而是会锁定这块内存区域的缓存并回写到内存,并利用缓存一致性机制来确保修改的原子性,这个操作叫做缓存锁定,缓存一致性机制会阻止同时修改(只有一个能修改)由两个以上处理器缓存的内存区域数据。
(2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效
IA-32处理器和Intel64处理器使用MESI控制协议(Modified 修改,Exclusive 独占,Share 分享,Invalid 无效)去维护内部缓存和其他处理器缓存的一致性,在多核处理器进行操作时,IA-32和Intel 64处理器能嗅探其他处理器访问内存的操作,还有这些处理器的内部缓存。**处理器使用嗅探技术来保证它的内部缓存、内存、其他处理器的缓存的数据在总线上保持一致。
如:在Pentium和P6 family处理器中,如果一个处理器嗅探到其他处理器打算写内存地址,而这个内存地址处于共享状态,那么这个处理器就会让它自己的缓存行无效,之后需要操作时就会读取内存中的数据。
Volatile的使用优化
并发大师Doug lea在JDK7的并发包新增了一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。
LinkedTransferQueue使用一个内部类来定义队列的头指针和尾指针,这个内部类PaddedAtomicReference相对于父类AtomiccReference只定义了15个4个字节的变量,加上本身对象引用的四个字节,将共享变量追加到64字节。
追加字节数能提高并发编程效率的原因:现代很多CPU的高速缓存行时64个字节宽,不支持部分填充缓冲行,如果头节点和尾节点都不足64个字节,处理器会把头尾读到同一个高速缓冲行,在多处理器下每个出处理器都会缓存同样的头尾节点,当一个处理器试图修改头节点,在缓存一致性机制下,会将整个缓存行锁住,导致尾节点也被锁住,其他处理器无法访问自己高速缓存中的尾节点,而队列的入队和出队操作都需要不停地修改头节点和尾节点,所以严重影响到队列地入队和出队效率。
Doug Lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头尾节点在修改时不会互相锁定。
但不是所有情况都需要volatile变量追加到64字节,如下
- 缓存行非64字节宽的处理器:如p6和奔腾 32字节宽
- 共享变量不会被频繁地写:追加字节数的方法有一定的性能影响,如果共享变量不被频繁写,则类似头尾节点被同时锁住而导致性能损失的情况比较少,就没必要使用这种方式
在Java7下这种追加字节的方式可能不会生效,因为Java7会淘汰或重新排列无用字段,需要使用其他追加字节的方式。
2.2 Synchronized
在多线程并发编程中Synchronized一直是元老级角色,在从前Synchronized给人们的印象就是重量级锁,但是随着Java SE 1.6对Synchronized进行了各种优化后,有些情况它就变得不那么重量级了。
例用Synchronized实现同步的基础:Java中的每一个对象都可以作为锁。
- 普通同步方法,锁的是实例对象
- 静态同步方法,锁的是类的字节码对象
- 同步方法块,锁的是括号中的对象
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样,代码块同步是使用monitorenter和monitorexit指令实现的,而同步方法是用另一种方式实现的,但是也可以用这两个指令实现。
monitorenter指令在编译后插入到同步代码块的开始位置,而monitorexit是在插入到方法结束处和异常处,JVM要保证每个monitorenter都有对应的monitorexit。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获得对象的锁。