Java synchronized实现原理
synchronized是Java中的关键字,提供了一种对共享资源互斥访问的并发控制,也可以作为线程同步的一个工具。这里我们看下synchronized背后的实现原理。
synchronized 字节码
三个典型的使用方法,修饰实例方法、静态方法、代码块,反编译看字节码:
$ javac SyncTest.java
$ javap -v SyncTest.class
- 代码块中使用 monitorenter、monitorexit 实现加锁、释放锁
- 方法修饰中字节码只能看到 flags修饰符,内部实现也可以理解是 monitorenter、monitorexit
public class SyncTest {
/**
* 修饰实例对象方法
* flags: (0x0020) ACC_SYNCHRONIZED
*/
synchronized void test() {
throw new RuntimeException();
}
/**
* 修饰静态方法
* flags: (0x0028) ACC_STATIC, ACC_SYNCHRONIZED
*/
static synchronized void test1() {
throw new RuntimeException();
}
void test2(SyncTest syncTest) {
/**
* 代码块
* monitorenter ... monitorexit
*/
synchronized (syncTest) {
throw new RuntimeException();
}
}
}
对象头 Mark Word
Java 6以前,synchronized 性能较差,因为监视器锁(monitor)是依赖于底层的操作系统互斥Mutex Lock来实现的,显然加锁和解锁都要进行内核态和用户态的上下文切换,严重影响锁的性能,这也是为什么早期的synchronized效率低的原因。
在Java 6之后,JVM层面对synchronized进行较大优化,为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁、轻量级锁、重量级锁概念。
为了减少对象资源的浪费,Java巧妙利用了对象头的Mark Word来记录锁信息如 线程Id、锁类型、标记位、锁指针等。不同锁类型,Mark Word状态不同,如下:
锁类型
按锁升级的顺序介绍:偏向锁、轻量级锁、重量级锁。
偏向锁
-
优化场景
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。也即偏向锁是对并发时『单线程』场景的优化。
-
使用
默认JVM是开启偏向锁特性,但是默认JVM启动后的的头4秒钟这个feature是被禁止的,这也意味着在此期间,prototype MarkWord会将它们的bias位设置为0,以禁止实例化的对象被偏向。4秒钟之后,所有的prototype MarkWord的bias位会被重设为1,如此新的对象就可以被偏向锁定了,当然也可以通过如下方式缩短这个延迟:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
# 关闭偏向锁
-XX:-UseBiasedLocking=false
-
加锁流程
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁(直到锁竞争出现前,持有该锁的线程不会主动释放偏向锁)。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
-
锁撤销、升级
一旦出现其它线程竞争锁资源时,偏向锁就会被撤销,进入升级锁流程。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
-
加锁和撤销流程图
-
偏向锁总结
偏向锁的核心思想是,锁不存在多线程竞争,且一个线程获取锁后接下来继续获取该锁的概率更大,可见偏向锁模式下线程是不会主动去释放偏向锁,只有其它线程来竞争该偏向锁时才会考虑撤销或膨胀。
偏向锁解决了一次CAS操作可以实现任意多次调用,节省了每次调用申请锁、释放锁性能消耗,避免了轻量级锁产生大量的CAS操作导致的性能消耗,从而提升锁性能。和轻量级锁一样,偏向锁并不能解决锁竞争问题,一旦遇到锁竞争偏向锁就会膨胀为轻量级锁。
轻量级锁
-
优化场景
假设竞争并不激烈,如:线程A和线程B都要访问对象o的同步方法,但是它们之间不会同时访问,线程A访问完成后线程B再去访问,它们之间访问类似于交替访问。此时通过锁优化,可以不必直接使用重量级锁。
-
加锁流程
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间(栈帧指当前线程栈的执行方法的出入栈信息),并将对象头中的Mark Word复制到锁记录中。
线程再尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁(先不会马上升级为重量级锁,自旋一个时间可能锁就被其他线程释放了)。
-
解锁
轻量级解锁时,会使用原子的CAS操作将当前线程栈帧中的锁记录,替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
-
锁升级
如上加锁流程,如果出现锁竞争,并且自旋过后仍未获得锁,将进入锁升级到重量级锁。竞争线程会修改对象头的锁信息,导致持有锁的线程CAS修改回来时失败。
-
加锁解锁和锁升级流程图
重点关注,竞争线程修改对象头的锁信息,导致持有锁的线程CAS修改回来时失败。
-
轻量级锁总结
更适用于线程交替执行这类场景,锁竞争不高频。CAS、自旋提升性能。
重量级锁
重量级锁基于进入和退出管程(Monitor,Monitor是依靠操作系统的Mutex Lock来实现互斥)对象实现的。每个对象实例都会有一个Monitor,Monitor可以和对象一起创建、销毁。
当多个线程同时访问一段同步代码时,多个线程会先被存放在ContentionList和EntryList集合中,处于block状态的线程,都会被加入到该列表。接下来当线程获取到对象的Monitor时,线程申请Mutex成功,持有该Mutex,其它线程将无法获取到该Mutex,竞争失败的线程会再次进入ContentionList被挂起。
如果线程调用wait() 方法,就会释放当前持有的Mutex,并且该线程会进入WaitSet集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放Mutex。 因Monitor是依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。
这里主要值得说的三个数据结构:
-
ContentionList
竞争锁失败的线程都会进入这个集合,意味着多个线程都会操作这个数据结构,需要线程安全。
-
EntryList
这仍然是一个待竞争到锁的线程集合,Owner线程从ContentionList取出一批线程移到EntryList,WaitSet中线程也会进入EntryList。这个结构是持有锁的Owner线程在存储和读取,因此不需要线程安全,没有竞争。一般来说下一个持有锁的线程可能就是EntryList的头节点。
-
WaitSet
当Owner线程执行wait()操作时,底层是释放锁,此时释放锁后就放到这个WaitSet集合中。释放后该线程会阻塞等待直到其他线程执行notify 唤醒,并再一次去获取锁,也是需要放到EntryList 和其他线程竞争锁。
总结
-
偏向锁锁解决的是一个周期内『单线程』访问共享资源问题,连CAS操作都是能节省就尽量节省。
-
轻量级锁解决的是一个周期内多线程交替访问共享资源问题,使用CAS操作消除底层系统的互斥。
轻量级锁也不能解决锁竞争问题,为什么不直接膨胀为重量级锁呢?如果锁竞争不是很激烈或者竞争时间非常短暂,轻量级锁有个自旋模式,可以通过自旋模式补救避免因偶然的误差导致直接膨胀为重量级锁。如果自旋模式也无法解决,说明说竞争可能确实激烈,轻量级锁也无能为力了,只能膨胀为重量级锁。
-
重量级锁解决的是一个周期内同时访问共享资源问题,需要管理等待线程以及依赖于底层系统互斥指令。
参考
- 《Java并发编程的艺术》
- 《Java性能调优实战》
- 并发编程锁之 synchronized 总结