Java6中与Synchronized相关的锁机制

时间:2023-01-18 21:12:05

JDK1.6提供了大量的锁优化技术,其中包括适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)等。通过使用这些方法对Synchronized进行了虚拟机级别的锁优化,从而提高了Synchronized的使用效率。

自旋锁和自适应自旋

在进行互斥和同步时,互斥同步对性能最大的影响是阻塞的的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。自旋锁意味着,当存在两个或者两个以上的线程同时并行执行,让后面请求锁的线程“稍等下”,但是不放弃处理器执行时间,看看持有锁的线程是否很快的释放锁,此时我们让等待锁的线程执行一个忙循环(自旋)进行等待。这种让线程使用忙循环或者自旋的形式等待线程锁的技术就成为自旋锁。

自旋锁的理论基础在于,共享数据的锁定状态只会持续很短一段时间,故不需要为了这段时间去挂起和恢复线程。不过自旋等待虽然避免了线程切换的开销,但是它还是会占用处理器的时间,所以自旋的时间或者次数是有限的。

自适应自旋的自旋时间是不固定的,而是通过前一次在同一个锁上的自旋时间及锁得持有者的状态决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,且持有锁的线程正在运行,那么认为这次也成功获取锁的概率非常大,那么就允许自旋等待更长的时间来获取这个锁。如果对于某个锁,自旋很少成功,那么以后对于这个锁就省略掉自旋过程。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享竞争的锁进行消除。例如我们在单线程中使用Hashtable或者StringBuffer进行编程(它们中的大部分方法都是synchronized的),因为不存在锁竞争,所以这些同步方法上的同步就可以被自动消除。

锁粗化

原则上我们写代码会尽量将同步块的作用范围限制在最小(只对共享数据进行同步)。但是如果一系列连续操作都对同一个对象反复加锁和解锁(类似于在循环体中使用synchronized),即使没有竞争,也会导致性能损失。那么将锁的范围扩展(粗化)到整个操作之外(类似于将synchronized放在循环体之外),这样就只需要一次加锁了。

Synchronized以及锁升级

通过以上几种方式的自动调优,对于程序的性能能够有很大的提升。但是对于JVM来说,还可以有更多的方式从底层来提高程序的性能。

在Java中我们可以对三种方式进行加锁:

对同步方法:此时锁定的是当前的实例对象;

对静态方法进行加锁:此时锁定的是当前对象的class对象;

对同步代码块进行加锁:此时锁定的是synchronized括号内部的对象。

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

HotSpot虚拟机的对象头(Object Header)

HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等,这部分数据的长度在32位和64位虚拟机中分别为32bit和64bit,官方成为“Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型的指针,如果是数组对象,则还会有额外的一部分用于存储数组的长度。

Java对象头
长度 内容 说明
32/64bit Mark Word 存储对象的HashCode、GC年代或锁信息等
32/64bit Class Metadata Address 存储指向对象类型数据的指针
32/64bit Array length(可选) 数组的长度(如果当前对象是数组)

Java对象的Mark Word默认存储的是对象的HashCode,GC年龄和锁标记位。在运行期间Mark Word的存储数据会随着锁的类型的变化而变化。

对象的锁状态
锁状态                            25bit 4bit 1bit 2bit
23bit 2bit 是否是偏向锁 锁标志位
无锁状态 对象的HashCode 对象分代年龄 0 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10
GC标记 11
偏向锁 线程ID 偏向时间戳(Epoch) 对象分代年龄 1 01

锁升级

Java6中的锁一共有四种状态:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它们会随着竞争情况逐渐升级,但是不能够降级。

锁升级的过程大概是这样的,刚开始处于无锁状态,当线程第一次申请时,会先进入偏向锁状态,然后如果出现锁竞争,就会升级为轻量级锁(这升级过程中可能会牵扯自旋锁),如果轻量级锁还是解决不了问题,则会进入重量级锁状态,从而彻底解决并发的问题。

偏向锁

偏向锁的目的是为了消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程永远不需要再进行同步。
更具体的过程是:当锁对象第一次被线程获取的时候,虚拟将将会把对象头中的标志位设置为“01”,即偏向模式,然后使用CAS操作将这个锁的线程ID记录在对象的Mark Word中,如果操作成功,则持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。当另外一个线程尝试获取这个锁时,偏向锁模式宣告结束。根据对象当前是否处于锁定状态,将会进一步撤销偏向恢复未锁定状态(标志为“01”)或者进入轻量级锁定(状态为“00”)状态。
对象状态变化是这样的(其中一部分是我自己的理解):当线程首次到达并获取锁时会在对象头Mark Word和栈帧中的锁记录中记录偏向锁的线程ID,以后线程进入和退出同步块时只需要检测对象头的Mark Word是否存储着指向当前线程ID的偏向锁,如果成功表示线程已经获得了锁,就不需要额外的操作了。如果测试失败,就需要测试“是否是偏向锁”字段(这个字段表明当前对象是否是偏向锁),如果不是1,那么就说明当前对象,要么是在创建JVM的时候设定不允许使用偏向锁,要么是之前获得过偏向锁但是之后被撤销了,此时就需要使用轻量级锁(即CAS锁)进行竞争(因为不允许锁降级或者压根不允许偏向锁),如果这个字段被设置为1,那么说明当前对象可能是长时间未使用这个偏向锁,此时这个对象是可重偏向的对象,则可尝试使用CAS将对象头的偏向锁指向当前线程。如果CAS替换对象头成功,则重偏向成功,否则说明重偏向时出现了竞争,此时就会导致锁撤销。 需要额外注意的是“是否是偏向锁”字段的初始化值,上面只提到对这个字段的判断,并没有说明这个字段是如何初始化的。根据《深入理解JVM》中的偏向锁和轻量级锁的Mark Word状态描述图来看,如果虚拟机参数设置可以使用偏向锁则“是否是偏向锁”字段的初始值为1,否则就为0。此外,当进行偏向锁撤销时,这个值也会从1转化为0,表示不允许锁降级并再次进入可偏向锁状态。 偏向锁可以提高带有同步但无竞争的程序的性能,但是它不一定总是对程序运行有利,如果程序中大多数锁总是被多个不同的线程访问,那偏向锁模式就是多余的。

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

Java6中与Synchronized相关的锁机制

轻量级锁

轻量级锁加锁过程:当代码进入同步块时,如果同步对象没有被锁定(锁标志位为“01”),JVM会首先在当前线程的栈帧中建立一个用于存储锁记录(Lock Record)的空间,并将对象头中的Mark Word复制到锁记录中,官方称这份拷贝为Displaced Mark Word。然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果更新成功,那么这个线程就拥有了该对象的锁,并且对象的Mark Word的“锁标志位”将转变为“00”,即表示此对象处于轻量级锁定状态;如果失败,JVM会首先检查对象Mark Word是否指向当前对象的栈帧,如果当前线程已经拥有了这个对象的锁,就可以直接进入同步块,否则就表示其他线程竞争锁,当前线程便尝试使用自旋来等待锁。当自旋等待一定次数时,此时属于多个线程竞争同一个锁,轻量级锁就不再有效,就要膨胀为重量级锁。

Java6中与Synchronized相关的锁机制Java6中与Synchronized相关的锁机制

轻量级锁解锁过程:轻量级锁解锁也是通过CAS操作进行的,使用CAS操作将Displaced Mark Word替换回对象头,如果成功,表示没有竞争,同步完成。如果失败,表示当前锁存在竞争,其他线程获取过该锁,就要在释放锁的同时唤醒被挂起的线程。

轻量级锁适用于同步周期内不存在竞争的情况,此时轻量级锁使用CAS避免了使用互斥量的开销,但是如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,此时轻量级锁会比传统的重量级锁慢。

锁的优缺点对比

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

锁升级总结

线程进入同步代码时会发生:

1. 根据Mark Word中的Thread ID是不是自己线程的ID,如果是,则使用偏向锁;

2. 如果Thread ID不是自己的线程ID,则说明出现竞争,偏向模式结束,根据锁对象是否处于被锁定的状态,确定撤销偏向锁还是升级为轻量级锁;

3. JVM将锁对象的Mark Word存储到锁记录的空间中,并使用CAS将Mark Word更新为指向锁记录的指针;

4. 如果CAS操作成功,则获取锁,并进入同步代码中,否则会进入自旋,进行等待;

5. 自旋一定次数之前,成功获取锁,则轻量级锁获取成功,如果自旋后还是失败了;

6. 自旋失败后,进入重量级锁,自旋线程会被阻塞,直到当前线程完成任务并唤醒它。


参考资料:

    1. 聊聊并发(二)Java SE1.6中的Synchronized

    2. 《深入理解Java虚拟机》,周志明