java并发系列:深入分析Synchronized

1术语:

CAS:Compare and Swap,比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

2同步的基础:

Java中的每一个对象都可以作为锁。

对于同步方法,锁是当前实例对象。

对于静态同步方法,锁是当前对象的Class对象。

对于同步方法块,锁是Synchonized括号里配置的对象。

详见:java同步

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁存在哪里呢?锁里面会存储什么信息呢?

3同步的原理

JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能,从jdk1.6开始对Synchronized进行了各种优化之后,有些情况下它并不那么重了,本文详细介绍了Java SE1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

3.1Java对象头

Java对象的内存布局:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。这里我们关注对象头信息。

对象头(Object Header)包括两部分信息,第一部分用于存储对象自身的运行时数据,Mark Word。另外一部分是类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。另外,如果对象时一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。

下面侧重讲Mark Word。

对象头用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。

锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。32位系统上占用8bytes,64位系统上占用16bytes;即分别为32bit、64bit。

Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下:

(插一句:新建对象分代年龄为0,之后每次在新生代拷贝一次就年龄+1,当年龄超过一个阈值之后,就会被丢入老年代,GC原理不是本文的主题,但至少我们现在知道了,这个阈值<=15,写到这里我再插一句感慨,就是java入门容易精通难,尤其是这种封装了底层的东西,如果只会用而不去看就不知道。)

在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:

3.2锁的升级

Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态,无锁状态偏向锁状态轻量级锁状态重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析(跟原文有所不同,调整下顺序:我觉得偏向锁是对轻量锁的优化,要理解偏向锁,先得理解轻量级锁)。

3.3 轻量级锁

对对象加轻量锁的条件是该对象当前没有被任何其他线程锁住。

先从对象O没有锁竞争的状态说起, 当一个线程A在对象O上申请锁时,它首先检查对象O的Tag,若发现是01且偏向信息为0,表明当前对象还未加锁,或加过偏向锁。在判断对当前对象确实没有被任何其他线程锁住后(Tag为01或偏向线程不具有该对象锁),即可以在该对象上加轻量锁。

加锁:

虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,(这个锁记录并不是我们通常意义上说的锁对象(包含队列的那个)),用于存储锁记录目前的Mark Word的拷贝(称为Displaced Mark Word)

拷贝mark word的作用:为了不想在lock与unlock这种底层操作上再加同步。

修改Object mark word轻量级锁指针作用:告诉其他线程,该object monitor已被占用

owner指向object mark word作用:在下面的运行过程中,识别哪个对象被锁住了。

然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下

如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧。如果指向,说明当前线程已经拥有了这个对象的锁,也算锁申请成功,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为”10”,并生成一个Monitor对象(重量锁对象),再把Mark Word中值改为指向重量级(Monitor)的地址,最后将当前线程塞入该Monitor的等待队列中。

解锁:

如果对象的Mark Word仍然指向着线程的锁记录,通过CAS将lock record中的Object原MarkValue赋还给Object的MarkValue,若替换成功,则解锁完成,若替换不成功,表示在当前线程持有锁的这段时间内,其他线程也竞争过锁,并且发生了锁升级为重量锁,这时需要去Monitor的等待队列中唤醒一个线程去重新竞争锁。

当发生锁重入时,会对一个Object在线程栈中生成多个lock record,每当退出一个synchronized代码块便解锁一次,并弹出一个lock record。

lock\unlock流程图如下

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

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

总结:轻量锁通过CAS检测锁冲突,在没有锁冲突的前提下,避免采用重量锁的一种优化手段。这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

轻量锁的每次锁重入都要进行一次CAS操作,而这个操作是可以避免的,这便是偏向锁的优化手段了,接下来我们看看偏向锁。

3.4 偏向锁

偏向锁与轻量级锁理念上的区别:

轻量级锁:在无竞争的情况下使用CAS操作去消除同步使用的互斥量

偏向锁:在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了

意义:锁偏向于第一个获得它的线程。如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

相关参数:

默认-XX:+UseBiasedLocking=true

-XX:-UseBiasedLocking=false关闭偏向锁 ,那么默认会进入轻量级锁状态 (在存在大量锁对象的创建并高度并发的环境下禁用偏向锁能够带来一定的性能优化)

应用程序启动几秒钟之后才激活

-XX:BiasedLockingStartupDelay = 0关闭延迟

加锁

当锁对象第一次被线程获取的时候,虚拟机把对象头中的标志位设为“01”(就初始状态来说,是不变),即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中的偏向线程ID,并将是否偏向锁的状态位置置为1。

如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,直接检查ThreadId是否和自身线程Id一致, 如果一致,则认为当前线程已经获取了锁,虚拟机就可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。

当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级那样执行。

偏向锁、轻量级锁的状态转化及对象Mark Word的关系如下图:

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

总结:偏向锁是在轻量锁的基础上减少了锁重入的开销。偏向锁可以提高带有同步但无竞争的程序性能。如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体情形分析下,禁止偏向锁优反而可能提升性能。

3.5 重量级锁

受篇幅所限,大神方腾飞原文到此结束了,我觉得本文是分析同步背后的锁膨胀机制,就把重量级锁部分补上,也能跟以后的基于aqs实现的juc下面的锁有所对比。以下部分主要转载自:http://blog.csdn.net/chen77716/article/details/6618779

重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex互斥的功能,它还负责实现了Semaphore的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

3.5.1 线程状态及状态转换

当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:

Contention List:所有请求锁的线程将被首先放置到该竞争队列

Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List

Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set

OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck

Owner:获得锁的线程称为Owner

!Owner:释放锁的线程

下图反映了个状态转换关系:

ContentionList虚拟队列

ContentionList并不是一个真正的Queue,而只是一个虚拟队列,原因在于ContentionList是由Node及其next指针逻辑构成,并不存在一个Queue的数据结构。ContentionList是一个后进先出(LIFO)的队列,每次新加入Node时都会在队头进行,通过CAS改变第一个节点的的指针为新增节点,同时设置新增节点的next指向后续节点,而取得操作则发生在队尾。显然,该结构其实是个Lock-Free的队列。

因为只有Owner线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了CAS的ABA问题。

EntryList

EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程。Owner线程并不是把锁传递给OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在Hotspot中把OnDeck的选择行为称之为“竞争切换”。

OnDeck线程获得锁后即变为owner线程,无法获得锁则会依然留在EntryList中,考虑到公平性,在EntryList中的位置不发生变化(依然在队头)。如果Owner线程被wait方法阻塞,则转移到WaitSet队列;如果在某个时刻被notify/notifyAll唤醒,则再次转移到EntryList。

3.5.2自旋锁

那些处于ContetionList、EntryList、WaitSet中的线程均处于阻塞状态,阻塞操作由操作系统完成(在Linxu下通过pthread_mutex_lock函数)。线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

缓解上述问题的办法便是自旋,其原理是:当发生争用时,若Owner线程能在很短的时间内释放锁,则那些正在争用线程可以稍微等一等(自旋),在Owner线程释放锁后,争用线程可能会立即得到锁,从而避免了系统阻塞。但Owner运行的时间可能会超出了临界值,争用线程自旋一段时间后还是无法获得锁,这时争用线程则会停止自旋进入阻塞状态(后退)。基本思路就是自旋,不成功再阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有非常重要的性能提高。自旋锁有个更贴切的名字:自旋-指数后退锁,也即复合锁。很显然,自旋在多处理器上才有意义。

还有个问题是,线程自旋时做些啥?其实啥都不做,可以执行几次for循环,可以执行几条空的汇编指令,目的是占着CPU不放,等待获取锁的机会。所以说,自旋是把双刃剑,如果旋的时间过长会影响整体性能,时间过短又达不到延迟阻塞的目的。显然,自旋的周期选择显得非常重要,但这与操作系统、硬件体系、系统的负载等诸多场景相关,很难选择,如果选择不当,不但性能得不到提高,可能还会下降,因此大家普遍认为自旋锁不具有扩展性。

对自旋锁周期的选择上,HotSpot认为最佳时间应是一个线程上下文切换的时间,但目前并没有做到。经过调查,目前只是通过汇编暂停了几个CPU周期,除了自旋周期选择,HotSpot还进行许多其他的自旋优化策略,具体如下:

如果平均负载小于CPUs则一直自旋

如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞

如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞

如果CPU处于节电模式则停止自旋

自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)

自旋时会适当放弃线程优先级之间的差异

那synchronized实现何时使用了自旋锁?答案是在线程进入ContentionList时,也即第一步操作前。线程在进入等待队列时首先进行自旋尝试获得锁,如果不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平。还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。自旋锁由每个监视对象维护,每个监视对象一个。

**********************************总结********************************************

偏向锁—>轻量锁—>自适应自旋锁—>重量锁

1.如何使用synchronized更高效?

使用synchronized要遵从上篇博客中提到的三个原则,另外如果业务场景允许使用CAS,倾向使用CAS,或者JDK提供的一些乐观并发容器(如ConcurrentLinkedQueue等)。

2.与ReentrantLock(JDK1.5之后提供的锁对象)一类的锁相比有什么优劣?

ReentrantLock代表了JDK1.5之后由JAVA语言实现的一系列锁的工具类,而synchronized作为JAVA中的关键字,是由native(根据平台有所不同,一般是C)语言实现的。ReentrantLock虽然也实现了 synchronized中的几种锁优化技术,但与synchronized相比,性能未必好,毕竟JAVA语言效率和native语言效率比大多数情况总有不如。ReentrantLock的优势在于为程序员提供了更多的选择和更好地扩展性,比如公平性锁和非公平性锁,读写锁,CountLatch等。

总结一点,在业务并发简单清晰的情况下推荐synchronized,在业务逻辑并发复杂,或对使用锁的扩展性要求较高时,推荐使用ReentrantLock这类锁。

后记,我觉得从volatile开始,了解底层就比较头大,加油坚持下去。

转自:https://blog.csdn.net/bohu83/article/details/51141836

推荐阅读更多精彩内容