源无极

导航

 

一、volatile的应用

           在多线程并发中synchronized和volatile都扮演着重要的角色,

volatile是经量级的synchronized,它在多处理器开发中保证了共享变量的

“可见性”。可见性的意思就是当一个线程修改变量时,另一个线程能读到

这个修改的值。

          如果volatile使用的恰当,它比synchronized的使用和执行成本更低

,因为它不会引起线程上下文的切换和调度。本文将深入分析在硬件层面

上Intel处理器是如何实现volatile的,通过深入分析帮助我们正确的使用

volatile

(一)、volatile的定义与实现原理

      java 编程语言允许线程访问共享变量,为了确保共享变量能被准确

和一致的更新,线程应该确保通过排他锁单独获得这个变量。

      java提供了volatile,在某一些情况下比锁要更加方便。如果一个子段

被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。
         看一下与其实现原理相关的CPU术语与说明。                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   

 

       volatile是如何保证可见性的能?在X86处理器下通过工具获取JIT编译器生成的汇编指令来查看

对volatile进行写操作时,CPU会做什么。

java 代码如下:

instance =new Singleton(); //instance是volatile变量

转成汇编代码如下

0x01a3deld: movb  $0 X 0,0x1104800(%esi); 0x01a3de24: lock  add1 $0x0,(%esp)

      有volatile 变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查

IA-32架构软件开发者可知,Lock前缀的指令在多核处理器下会引发两件事。

 1.将当前处理器缓存行的数据写回到系统内存

2.这个写回内存的操作使在其他CPU里缓存了该内存地址的数据无效。                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据

读到内部缓存(L1,L2或是其他)  后再进行操作,但是操作完不知道何时会写

到内存。如果对声明了volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的

指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果

其他处理器的缓存的值是旧的,再执行计算操作就会有问题。所以在多处理器下,为

了保证各个处理器的缓存是一致的,就会实现缓存一致协议,每一个处理器通过嗅探

在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存的值

被修改过,就会将当前处理器的缓存设置成无效状态,当处理器对这个数据进行修改

的时候,会重新从系统内存中把数据读取到处理器缓存里。                                                                                                                                                                                                                                                                                                       

下面来具体讲解volatile的两条实现原则。
1)Lock前缀指令会引起处理器缓存回写到内存

Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,

LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存[2]。

但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存

毕竟锁总线开销的比较大。在8.1.4节有详细说明锁定操作对处理器缓存的影响,

对于Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。

但在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,

则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,

并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,

缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

IA-32处理器和Intel 64处理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和

其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器

能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、

系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,

如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,

那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。
(二)、volatile的使用优化

       在jdk7 之后,由于重排序因素,使用该方法作用失效,仅作知识原理学习
著名的Java并发编程大师Doug lea在JDK 7的并发包里新增一个队列集合类

LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来

优化队列出队和入队的性能。LinkedTransferQueue的代码如下:

/** 队列中的头部节点 */
private transient final PaddedAtomicReference<QNode> head;
/** 队列中的尾部节点 */
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference <T> extends AtomicReference T> {
     // 使用很多4个字节的引用追加到64个字节
     Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
     PaddedAtomicReference(T r) {
        super(r);
     }
}
public class AtomicReference <V> implements java.io.Serializable {
     private volatile V value;
     // 省略其他代码

 

     

追加字节能优化性能

       这种方式看起来很神奇,但如果深入理解处理器架构就能理解其中的奥秘。

让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头节点(head)

和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情

,就是将共享变量追加到64字节。我们可以来计算下,一个对象的引用占4个字节,

它追加了15个变量(共占60个字节),再加上父类的value变量,一共64个字节。


为什么追加64字节能够提高并发编程的效率呢?
         因为对于英特尔酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M处理器

的L1、L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着,

如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,

在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,

会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问

自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,

所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加到64

字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、

尾节点在修改时不会互相锁定。

那么是不是在使用volatile变量时都应该追加到64字节呢?

      不是的。在两种场景下不应该使用这种方式。

缓存行非64字节宽的处理器。
      如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。
共享变量不会被频繁地写。
      因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,

这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,

就没必要通过追加字节的方式来避免相互锁定。
      这种追加字节的方式在java7下可能是不生效的,因为java7变得更加智慧,它会淘汰

或是重新排列无用字段,需要使用其他追加字节的方式。除了volatile,java并发编程更多是应用

synchronized。                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  二、   synchronized实现原理与应用                                                                                                                                                                                                                                    

      在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁

但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。

 

先来看下利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是synchronized括号里配置的对象。

       当一个线程试图访问同步代码块时,它首先必须得到锁退出或抛出异常时必须释放锁。

那么所到底存在哪里呢?锁里面会存储什么信息呢?

       从JVM规范中可以看到synchronized在JVM里的实现原理,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种数据,如表所示

 

 (二)锁的升级与对比

      Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,

在Java SE 1.6中,锁一共有4种状态级别从低到高依次是无锁状态、偏向锁状态、

轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,

意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,

目的是为了提高获得锁和释放锁的效率。                                                                                                                                                                                                                                                                                                     1  偏向锁

      HotSpot的作者经常研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,

为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,

会在对象头和栈帧种的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,

只需简单地测试下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。

如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁);如果没有设置,

则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程’                                                                                                                                                                                                                                   1)偏向锁的撤销                                                                                                                                                                                                                                                                                                                                           偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,

持有偏向锁的线程才会释放锁                                                                                                                                                                                                                                                                                                                       

 

 

 

如图,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。

它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,

如果线程不处于活动状态,则将对象头设置成无锁状态;

如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,

栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,

最后唤醒暂停的线程。

下图线程1展示了偏向锁获取的过程,线程2展示了偏向锁撤销的过程

   

 

 

 2)如何关闭偏向锁

        偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,

如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。

如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:

-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  2、轻量级锁                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  加锁                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,

并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。

然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,

当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。                                                                                                                                                                                                                       解锁                                                                                                                                                                                                                                                                                                                                                                    轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,

如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

下图是两个线程同时争夺锁,导致锁膨胀的流程图。                                                                                                                                                                                                                                                                               

 

 

      因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,

就不会再恢复到轻量级锁状态当锁处于这个状态下其他线程试图获取锁时,都会被阻塞住

当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

  锁的优缺点对比

 

 备注:部分内容来自

https://my.oschina.net/zupengliu/blog/1845326

                                                                                                                                                                                                                                                                                                                                                                    

posted on 2019-12-28 19:18  源无极  阅读(278)  评论(0)    收藏  举报