引言

Java SE 1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”、“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高分别是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几种锁的状态分别适应不同竞争环境,随着竞争状况的升级而不断升级。锁可以升级但不能降级,每一种状态都只能变为更重量级的下一种状态。这种单向策略的目的是提高JVM锁的效率,减少锁带来的性能损失。下面我将从重到轻依次分析各种锁状态的原理、性能、以及适用的场景。

重量级锁

在Java SE 1.6之前,所有使用synchronized的地方都是重量级锁,锁的目的只有一个:保护临界区资源,为代码提供happens-before保证,使得多线程可以按照预想的方式进行。

原理

syncLock

它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

  1. Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
  2. Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
  3. Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
  4. OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
  5. Owner:当前已经获取到所资源的线程被称为Owner;
  6. !Owner:当前释放锁的线程。

JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。

OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。

重量级锁是非公平锁. Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源

性能分析

从重量级锁的原理可以看出,重量级锁在调度过程中最大的性能开销来自于:线程状态的切换。在较坏的情况下,当线程被丢进ContentionList第一次被阻塞后,会多次在运行与阻塞两种状态之间进行切换(未获取到锁的情况下,当进程被选中为OnDeck即从阻塞状态恢复至运行态,尝试获取锁资源;如果没有获取到锁资源,则又会被丢进EntryList,线程重新被阻塞) 。即使在最好的情况下,即当线程被选为OnDeck时立刻获得了锁资源,这种情况下重量级锁仍然会带来线程从运行到阻塞,再从阻塞态重新恢复到运行状态两次切换的性能开销。线程运行状态的切换涉及到内核状态切换、上下文的保存与恢复等许多步骤,所带来的性能开销不用赘述,所以几乎每一本关于Java的教程上都有这么几个字:慎用synchronized!

适用场景

竞争激烈的情况。

轻量级锁

既然重量级锁性能开销主要来自于线程状态的切换,那么线程状态的切换是必要的吗?设想这样一个场景,线程A刚进入synchronized代码块中,正准备获取锁资源,即使此时没有其他线程hold该锁资源,线程A仍然需要进行两次状态的切换。显然,在这种情况下线程切换是不必要的,因为线程切换所带来的开销远远大于了等待锁资源释放的时间。针对于这种情况或大多数情况下仅需等待极短时间就能获取到锁资源的情况下,我们可以使用while(true)或者for(;;)让线程一直保持运行状态,并在循环中尝试去获取锁。这样就能完美避免线程切换所带来的系统开销。这种锁获取方式被称为自旋锁,JDK1.7已经移除了自旋锁的设置,因为锁优化中的轻量级锁实质上就是自旋锁。

原理

  1. 当对象处于无锁状态时(RecordWord值为HashCode,状态位为001),线程首先从自己的可用moniter record列表中取得一个空闲的moniter record,初始Nest和Owner值分别被预先设置为1和该线程自己的标识,一旦monitor record准备好然后我们通过CAS原子指令安装该monitor record的起始地址到对象头的LockWord字段,如果存在其他线程竞争锁的情况而调用CAS失败,则只需要简单的回到monitorenter重新开始获取锁的过程即可。
  2. 对象已经被膨胀同时Owner中保存的线程标识为获取锁的线程自己,这就是重入(reentrant)锁的情况,只需要简单的将Nest加1即可。不需要任何原子操作,效率非常高。
  3. 对象已膨胀但Owner的值为NULL,当一个锁上存在阻塞或等待的线程同时锁的前一个拥有者刚释放锁时会出现这种状态,此时多个线程通过CAS原子指令在多线程竞争状态下试图将Owner设置为自己的标识来获得锁,竞争失败的线程在则会进入到第四种情况(4)的执行路径。
  4. 对象处于膨胀状态同时Owner不为NULL(被锁住),在调用操作系统的重量级的互斥锁之前先自旋一定的次数,当达到一定的次数时如果仍然没有成功获得锁,则开始准备进入阻塞状态,首先将rfThis的值原子性的加1,由于在加1的过程中可能会被其他线程破坏Object和monitor record之间的关联,所以在原子性加1后需要再进行一次比较以确保LockWord的值没有被改变,当发现被改变后则要重新monitorenter过程。同时再一次观察Owner是否为NULL,如果是则调用CAS参与竞争锁,锁竞争失败则进入到阻塞状态。

lock-transform

性能分析

轻量级锁本质上属于自旋锁,从悲观锁/乐观锁来讲,它属于乐观锁。在竞争较少的情况下,轻量级锁所带来的系统开销仅为多次CAS替换而已。相比重量级锁的线程状态切换,开销大大减少了。但是,无用的循环将无限制压榨CPU资源,特别是在单核的CPU上!在轻量级锁(自旋锁)循环以得到锁资源的过程中,整个CPU的资源基本全都耗在循环中。随着竞争的加剧,如果不能在短时间内获取到锁资源,自旋锁将无休止占用CPU,导致CPU使用率飙升。在竞争激烈的情况下,自旋锁的性能甚至赶不上重量级锁,而且给CPU带来巨大的负担。JVM也考虑到这点,因此在自旋一定时间后仍未获取到锁资源的情况下,整个锁将膨胀为重量级锁。

适用场景

相比重量级锁,轻量级锁更适合于竞争较少的情形:在大多数情况下能够直接获取到锁资源不需要等待或者仅经过极少时间的等待就能获取到锁资源的情况。

偏向锁

根据上一章节的分析,我们可以认为轻量级锁的开销主要来自于多次CAS替换。在多核处理器环境下,CAS操作一般是通过总线锁定或者缓存锁定的方式来实现。在总线锁定的情况下,CPU中其他的核将无法使用总线读写数据;缓存锁定比总线锁定略好,它只会造成部分缓存行失效。无论是总线锁定还是缓存锁定,其开销都会远远大于普通CPU逻辑运算。能不能将CAS运算也优化一下呢?这是研究偏向锁的需求之所在。JVM工程师分析了大量JVM的数据,发现许多的锁从始至终只被一个线程所占据(囧)。这是偏向锁存在的基础。在这种情况下,JVM的工程师们实现了偏向锁,连CAS的系统开销也省略了。

原理

获取锁:

  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块

释放锁:

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
  2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;

性能分析

在使用偏向锁时,锁本身所带来的性能开销仅仅为第一次获取锁时所进行的CAS操作。但是请注意,偏向锁的释放却比较苛刻:已经得到锁资源的进程并不会在离开临界区后释放锁资源,它只会在有线程竞争锁资源时才会释放,而且需要所有线程都挂起!(JDK wiki中原文为:All threads must be suspended for this operation)这意味着偏向锁的释放与JVM中的STW(stop the world)是绑定在一起的!第一次锁的竞争可能会导致大量的时间开销。因此,JVM提供了参数-XX:UseBiasedLocking来手动开启或者关闭偏向锁。

适用场景

偏向锁的适用那些使用了synchronized关键字,但又无线程竞争关系的情况。误用synchronized所带来的系统开销也小到可以忽略不计了!不要害怕误用,尽情使用锁吧,JVM会给你搞定一切的!

总结

JVM对于锁的优化已经做得非常完善了,在不同情况下JVM会通过锁膨胀的方式找到适合竞争关系的最优锁。在实际开发的过程中,个人觉得可以注意以下几个方面:

  1. 不要担心锁所带来的性能开销,JVM会帮你搞定!(当然,选用适合当前场景的锁模型肯定更好)
  2. 在能够准确把握程序运行状态的情况下(保证不会误用synchronized时),请将偏向锁关闭,因为偏向锁膨胀所带来的性能损耗是非常大的。

References

  1. java 中的锁 – 偏向锁、轻量级锁、自旋锁、重量级锁
  2. 【死磕Java并发】—–深入分析synchronized的实现原理
  3. Java中synchronized的实现原理与应用
  4. 方腾飞:Java并发编程的艺术
  5. OpenJDKWiki
  6. Biased Locking in HotSpot