偏向锁、自旋锁、轻量级锁、重量级锁

线程阻塞

操作系统在唤醒或阻塞一个线程的时候,需要在用户态和核心态之间切换,这种切换会消耗大量的系统资源。因为在用户态和核心态都有各自专用的内存空间、寄存器等。用户态切换到核心态需要传递许多变量、参数给内核,内核也需要保存用户态在切换时的一些寄存器值、变量等,以便核心态调用结束后切换回用户态继续工作。

Java对象头

锁存在Java对象头。如果对象是数组类型,则虚拟机用3个word(字宽)存储对象头,如果对象是非数组类型,则用2个字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。对象头的结构如下:

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 组的长度(如果当前对象是数组)

在Java对象头中,与锁相关的结构就是Mark Word了。默认存储了对象的hashcode、分代年龄
锁标记位。32位JVM的Mark Word的默认存储结构如下:

锁状态 25 bit 4bit 1bit(是否位偏向锁) 2bit(锁标志位)
无锁状态 对象的hashCode 对象分代年龄 0 01

最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了Mark Word存储的内容。标志位的情况大致如下:

状态 标志位 存储内容
未锁定 01 对象哈希码、对象分代年龄
轻量级锁定 00 指向锁记录的指针
膨胀(重量级锁定) 10 指向重量级锁定的指针
GC标记 11 空(不需要记录信息)
可偏向 01 偏向线程ID、偏向时间戳、对象分代年龄

在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化,32位虚拟机在不同状态下Mark Word结构如下图所示:
markword

锁种类

synchronized是重量级锁,会导致争用不到锁的线程进入阻塞状态,是悲观锁的一种。java默认开启了自旋锁,它和轻量级锁、偏向锁一样都是乐观锁。在Java中锁一共有四种状态:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。下面来看几种锁机制。

自旋锁

线程的阻塞和唤醒需要从用户态转为核心态,频繁的阻塞和唤醒对CPU来说一件负担很重的工作。很多对象的锁定状态只会持续很短的一段时间,在很短的时间内阻塞并唤醒线程显然是不值得。因此,可以利用自旋锁。让线程去执行一个无意义的循环,循环结束后再去重新竞争锁,如果竞争不到继续循环,循环过程中线程会一直处于running状态,但是基于JVM线程调度,会让出时间片,所以其他线程依旧有申请锁和释放锁的机会。
如果持有锁的线程执行的时间超过自旋等待的最大时间(默认自旋10次)仍没有释放锁,就会导致其它竞争锁的线程在最大等待时间内还是获取不到锁,这时竞争线程会停止自旋进入阻塞状态。
JDK6引入自适应的自旋锁:自旋时间不固定,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋很有可能再次成功,进而允许更长的自旋等待时间。

优缺点

对于锁竞争不激烈且占用锁时间非常短的场景,自旋锁可以尽可能地减少线程的阻塞。但如果锁竞争激烈或者持有锁的线程需要长时间占用锁,这时候就不适合使用自旋锁。因为自旋锁在获取锁前一直都占用CPU,同时还有大量线程在竞争一个锁,导致获取锁的时间很长。自旋的消耗大于线程阻塞挂起的消耗。

设置

在JDK6中,Java虚拟机提供-XX:+UseSpinning参数来开启自旋锁,使用-XX:PreBlockSpin参数来设置自旋锁等待的次数。
在JDK7开始,自旋锁的参数被取消,虚拟机不再支持由用户配置自旋锁,自旋锁总是会执行,自旋锁次数也由虚拟机自动调整。

偏向锁

如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
偏向锁会偏向于第一个获得它的线程(Mark Word中的偏向线程ID信息),如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
偏向锁获取和撤销

偏向锁的获取

当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设为01,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象头的Mark Work之中。

偏向锁的撤销

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断对象是否处于被锁定的状态撤销偏向锁后恢复到未锁定(标志位为01)或轻量级锁(标志位为00)的状态。
偏向锁、轻量级锁状态转换

偏向锁的设置

偏向锁在JDK6 和JDK 7中默认时开启的,它会在应用程序启动几秒后激活,可以使用-XX:BiasedLockingStartupDelay=0来关闭延迟。如果应用程序中所有的锁通常处于竞争状态情况下,可以使用-XX:-UseBiasedLocking=false来关闭偏向锁,那么所有的锁一开始默认会进入轻量级锁状态。

适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作; 在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用。

轻量级锁

由偏向锁升级而来,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁竞争的时候,偏向锁就会升级为轻量级锁。
轻量级锁膨胀

加锁过程

在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为01状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word拷贝(官方名为Displaced Mark Word),这时候线程堆栈与对象头的状态如下图所示。
轻量级锁MarkWork复制
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位转变为00,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。
轻量级锁定
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧。如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两个以上的线程争用同一个锁,那么轻量级锁就不再有效,要升级为重量级锁,锁标志的状态值变为10,Mark Word中存储的就是指向重量级(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

解锁过程

解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁(这时已经是重量级锁),那就要在释放锁的同时,唤醒被挂起的线程。

重量级锁

如果线程尝试获取锁的时候,轻量级锁正被其他线程占有,那么它就会修改Mark Word,升级为重量级锁。重量锁在JVM中又叫对象监视器(Monitor)。

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

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

  • Contention List:一个先进先出(FIFO)的队列,所有请求锁的线程将被首先放置到该竞争队列。每次新加入Node时都会在队头进行,而取得操作则发生在队尾。

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

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

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

  • Owner:获得锁的线程称为Owner

  • !Owner:释放锁的线程

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

对象监视器
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问。为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般时最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称为“竞争切换”。
OnDeck线程获取锁资源后,会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的。

Synchronized

它可以把任意一个非NULL的对象当作锁:

  • 作用于方法时,锁住的是对象的实例
  • 当作用于静态方法时,锁住的是Class对象,相当于类的一个全局锁。会锁住所有调用该方法的线程
  • 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块

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

Synchronized执行过程

  • 检测Mark Word里面是不是当前线程的ID。如果是,表示当前线程处于偏向锁;如果不是,则使用CAS将当前线程的ID替换Mark Word。替换成功则表示当前线程获得偏向锁,设置锁状态标志位为01;失败则说明发生竞争,撤销偏向锁,升级为轻量级锁。
  • 当前线程使用CAS将对象头的Mark Word替换为锁记录指针。如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。如果自旋成功,则依然处于轻量级状态,如果自旋失败,则升级为重量级锁。另外,等待轻量级锁的线程不会阻塞,它会一直自旋等待锁。

Synchronized与volatile区别

volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

锁的优化策略

  • 减少锁持有时间 
             例如:对一个方法加锁,不如对方法中需要同步的几行代码加锁。

  • 减小锁粒度
            例如:ConcurrentHashMap采取对segment加锁而不是整个map加锁,提高并发性。

  • 锁分离 
            根据同步操作的性质,把锁划分为的读锁和写锁,读锁之间不互斥,提高了并发性。

  • 锁粗化 
      针对一个线程中来说,只有个别地方需要同步,所以把锁加在同步的语句上而不是更大的范围,减少线程持有锁的时间;假如在一个循环里面,重复使用synchronized关键字。需要频繁地获
    取锁、释放锁。要知道锁的取得(假如只考虑重量级MutexLock)是需要操作系统调用的,从用户态进入内核态,开销很大。于是针对这种情况也许虚拟机发现了之后会适当扩大加锁的范围(所以叫锁粗化)以避免频繁的拿锁释放锁的过程。 

  • 锁消除 
            锁消除是编译器做的事,根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程(即不会影响线程空间外的数据),那么可以认为这段代码是线程安全的,虚拟机会直接去掉这个锁。

总结

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
  • 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
  • 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
  • 自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试使用CAS操作获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们。
偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块。如果仍然是同个线程去获得这个锁,尝试偏向锁时会直接进入同步块,不需要再次获得锁。一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁。

优缺点

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,与执行非同步方法相比仅存在纳秒级的差距 如果线程存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程会使用自旋消耗CPU 追求响应时间,锁占用时间很短
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,锁占用时间较长