Back
Featured image of post [并发编程]Synchronized优化篇——Java中的各种锁

[并发编程]Synchronized优化篇——Java中的各种锁

synchronized在JDK1.6之后官方对其进行优化,先要了解CAS和Java对象头,再去学习锁的四种状态:无锁偏向锁轻量级锁重量级锁。这篇文章参考了多方资料,算是总结得比较全面,希望可以帮到你。

CAS

CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突。CAS就是compare and swap ,通过比较内存中当前的值等不等于预期值,如果等于就可以赋值成功,如果不等于说明这个值被修改过了不再是预期的旧值。

当多个线程使用CAS操作一个变量的时候,只有一个线程会成功,其他的会因为冲突失败,失败后一般就会自旋重试,多次失败后选择挂起线程。

CAS实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG 指令实现。

synchronized和CAS的区别

未优化的Synchronized最主要的问题是:当存在线程竞争的情况下会出现线程阻塞和唤醒带来的开销问题,这是一种阻塞同步(互斥同步)。而CAS不是直接就把线程挂起,在CAS操作失败后会进行一定的重试,而非直接进行耗时的挂起和唤醒等操作,因此叫做非阻塞同步。

CAS存在的问题

  1. ABA问题

    因为CAS会检测旧的值有没有发生变化,但是假如一个值从A变成了B,然后又变成了A,刚好CAS在检查的时候发现旧值A没有发生变化,但是实际上是发生了变化的。解决办法是添加一个版本号,Java在1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,思路也是这样的。

  2. 自旋时间过长

    CAS是非阻塞同步,会自选(死循环)进行下一次尝试,如果自旋时间过长的话对性能又很大影响。

  3. 只能保证一个共享变量的原子操作

    当CAS对一个变量进行操作时可以保证其原子性,如果对多个变量进行操作就不能保证其原子性,解决办法就是利用对象去整合多个共享变量,然后对整个对象进行CAS操作就可以保证原子性来。atomic包提供来AtomicReference来保证引用对象之间的原子性。

Java对象头

Java的锁是基于对象的,而不是基于线程的(所以wait、notify等方法是Object中的不是Thread中的),那锁的存放自然是在对象中,存储在Java的对象头中。

1字宽在32位处理器中是32位,64位中是64。每个对象都有对象头,非数组类型长度是2个字宽,数组是3个。

长度 内容 说明
1字宽 Mark Word 存储对象的hashCode、分代信息、锁信息等
1字宽 Class Metadata Address 存储到对象类型数据的指针
1字宽 Array length 数组的长度(如果是数组)

Mark Word的格式(64位为例):

当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;

当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针;

当状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针。

无锁

上面介绍的CAS原理及应用即是无锁的实现。

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。

当有一个线程进入同步块时升级为偏向锁。

偏向锁

HotSpot的作者研究发现,大多数情况下锁都不存在多线程竞争,一般总是由同一个线程多次获得,为了让线程获得锁的代价更低,引入了偏向锁。

偏向锁的获取

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。

如果是,直接表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,进行偏向锁的撤销(下面介绍),设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,线程不会主动释放偏向锁,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,过程如下:

  1. 在一个全局安全点(没有字节码在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要将锁记录和Mark Word改为无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

如果程序中的锁通常处于竞争状态,那么偏向锁就起不到提高性能的作用,我们可以用参数把偏向锁关闭:

-XX:UseBiasedLocking=false

下面有个经典的图总结了偏向锁的获得和撤销:

相比轻量级锁的优势

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

轻量级锁

多个线程在不同时段获取同一把锁,锁竞争不激烈的情况,采用轻量级锁,其他想获取锁的线程会通过自旋的形式尝试获取锁,避免线程的阻塞与唤醒,从而提高性能。

轻量级锁的获取

JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间Lock Record , 包含两部分:

  1. Object Reference 存储的是加锁对象的对象头的指针,即指向了加锁对象的对象头
  2. Displaced Mark Word 存储的是加锁对象无锁状态的Mark Word,以便释放锁时恢复Mark Word(例如保留了hash值、分代年龄等信息)

如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word(无锁状态的)复制到自己的Displaced Mark Word里面。然后线程尝试用CAS将锁的Mark Word替换为指向Lock Record的指针。所以CAS要成功,则当前Mark Word的必须为无锁状态,表示此时没有线程占有,也好复制Mark Word过去Lock Record,轻量级锁释放后Mark Word也会还原为无锁状态。

如果CAS成功,当前线程获得锁;如果失败,且Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。(如果失败但是是本线程,那么看下面的可重入实现)

如下图,如果Mark Word成功更新为指向了某一线程栈帧中的Lock Record则证明此线程获得锁成功:

复制Mark Word的原因:

由上面的Mark Word的结构就知道,如果是轻量级锁,Mark Word有62位就都用来存储指向Lock Record指针了,那么原本无锁状态存储的hashcode、分代年龄等信息会被覆盖,所以必须用复制一份到Displaced Mark Word想当于一个备份,之后这个线程释放轻量级锁的时候需要将Displaced Mark Word替换回原来的mark word中。

轻量级锁的可重入实现

synchronized对可重入实现,重量级锁是哟monitor里的计数器,那轻量级锁是怎么实现可重入的呢?

synchronized(obj) {
	synchronized(obj) {
		synchronized(obj) {
    }
  }
}

如上面代码所示,直接锁3次,此时第一次就是正常的轻量级锁获取流程,后面的两次就会往栈中新增一个Displaced Mark wordnullLock Record,所以重入之后栈中就会有多个Lock Record(如下图)(偏向锁也是类似地通过增加lock record来计数)

适应性自旋

自旋过多也会造成CPU较大的开销,JDK采用的是更好的适应性自旋

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能就会减少自选的次数或者省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数依然没有获取到锁;或者一个线程在持有锁,一个在自旋,又有第三个线程来竞争时,轻量级锁升级为重量级锁

轻量级锁的释放

在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。

重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。

在 Java 中每个对象都有一个 monitor 对象与之对应,在重量级锁的状态下,对象的Mark Word存放的是一个指针,指向了与之对应的 monitor 对象。这个 monitor 对象就是实现重量锁的关键。

一个 monitor 对象包括这么几个关键字段:ContentionList,EntryList ,WaitSet,owner。其中 ContentionList、EntryList 、WaitSet 都是由 ObjectWaiter 的链表结构,owner 指向持有锁的线程。

每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:

Contention List:所有请求锁的线程将被首先放置到该竞争队列
Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
Owner:获得锁的线程称为Owner
!Owner:释放锁的线程
  1. JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList 会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM 会将一部分线程移动到 EntryList 中作为候选竞争线程。
  2. Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定EntryList 中的某个线程为 OnDeck 线程(一般是最先进去的那个线程),被选中的线程叫做Heir presumptive ,即假定继承人。
  3. Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交给 OnDeck。
  4. OnDeck 需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM 中,也把这种选择行为称之为“竞争切换”。
  5. OnDeck 线程获取到锁资源后会变为 Owner 线程,而没有得到锁资源的仍然停留在 EntryList中。如果 Owner 线程被 wait 方法阻塞,则转移到 WaitSet 队列中,直到某个时刻通过 notify或者 notifyAll 唤醒,会重新进去 EntryList 中。处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的)。
  6. Synchronized 是非公平锁。 Synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。
  7. 每个对象都有个 monitor 对象,加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加上 monitorentermonitorexit 指令来实现的,方法加锁是通过一个标记位ACC_SYNCHRONIZED来判断的(在synchronized篇讲过了)

转换关系如下图:

总结锁的升级流程

  1. 每一个线程在准备获取共享资源时,第一步,检查Mark Word里面是不是放的自己的Thread ID,如果是,表示当前线程获取了该资源的偏向锁
  2. 如果Mark Word不是自己的Thread ID,这时候用CAS来尝试替换,成功的话就仍然为偏向锁,失败的话就会锁升级,新线程根据Mark Word里面现有的Thread ID通知之前的线程暂停,这个之前的线程进行偏向锁的撤销操作,同时锁升级为轻量级锁
  3. 此时为轻量级锁来竞争资源,两个线程都把锁对象的Hash Code复制到自己新建的用于存储锁的记录空间Lock Record,接着通过CAS,把锁对象的Mark Word内容修改为指向自己Lock Record的地址的方式来竞争锁。
  4. 第三步中的CAS成功的就拿到来锁,失败的就自旋。
  5. 自旋线程在自旋过程中,如果之前先拿到锁的线程释放了锁让它可以获得资源,就仍然保持在轻量级锁,如果自选失败就会膨胀为重量级锁。
  6. 进入重量级锁后,原本自旋的线程就只能进入阻塞状态,等锁被释放后再让别的线程来唤醒自己。

关于锁是否可以降级

大多数文章写的都是锁只能升级而不能降级,其实不然:

  1. HotSpot JVM、JRockit JVM是支持锁降级的
  2. HotSpot JVM、JRockit JVM是偏向锁是可以重偏向的
  3. 重量级锁降级发生于STW阶段,降级对象为仅仅能被VMThread访问而没有其他JavaThread访问的对象

各种锁的优缺点对比

下表来自《Java并发编程的艺术》:

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

参考

9 synchronized与锁

不可不说的Java"锁"事

Synchronized的实现丶Java教程网-IT开发者们的技术天堂

comments powered by Disqus
一辈子热爱技术
Built with Hugo
Theme Stack designed by Jimmy
gopher