1.锁基础概念
1.1 临界区 critical section
一段程序代码内如果存在对共享资源的多线程访问,称这段代码块为临界区
,共享资源为临界资源
。
1.2 竞态条件 race condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。解决竞态条件的发生,可以有多挣手段可以解决:
- 锁
- 原子变量
1.3 公平/非公平锁
线程是否根据抢锁顺序执行这并不是公平、非公平的判断依据。公平锁和非公平锁的区别在于在入队之前是否尝试加锁
。只要入队以后,就不存在公不公平的问题了。内置锁和AQS都是这个意思,只不过内置锁和AQS对抢锁顺序的策略有所不同罢了。
2.synchronized的底层原理
synchronized又称Java内置锁
,底层是基于Monitor监视器机制实现,其内存原语是基于操作系统的Mutex互斥量
进行的。JDK5之前内置锁是重量级的,性能较低,在JDK5之后JVM进行了对其优化,例如锁粗化、锁消除、轻量级锁、偏向锁、自适应自旋等技术来避免了许多重量级开销,性能大幅提高。
synchronized在JVM层面是由两个指令Monitorenter
和Monitorexit
实现。
2.1 管程/监视器 Monitor
管程
是指管理共享变量以及对共享变量操作的过程,让其支持并发。管程的思想不仅仅局限于Java,操作系统都管程的思想。synchronized、wait()/notify()/notifyAll()都是管程技术的范畴。
2.2 MESA模型
管程的概念模型从发展上总共分为3大模型:Hasen模型、Hoare模型、MESA模型。目前使用实现最广泛的模型就是MESA模型。
2.3 synchronized管程和Monitor机制
JDK借鉴了MESA模型,对MESA进行了精简,Java内置锁只有1个条件变量来实现等待唤醒机制。java.lang.Object中的wait()/notify()/notifyAll()方法依赖于C++实现的ObjectMonitor
对象实现,其ObjectMonitor的主要数据结构包括:对象头mark指针、锁的重入次数、锁对象、拥有监视器的线程ID、WaitSet等待队列、CXQ等待栈、EntryList等待队列。
在尝试获取锁的时候,将当前线程入栈CXQ,当释放锁时,如果EntryList为空,就将CXQ的线程出栈插入到EntryList中,并唤醒第一个线程;如果EntryList不为空,则直接从EntryList中唤醒线程。
2.4 锁记录和对象头
锁的状态被记录在对象头中MarkWord中:
2.4.1 MarkWord中锁标记枚举
2.4.2 锁状态
2.5 偏向锁
偏向锁
是一种针对加锁操作的优化手段,在一般情况下线程并没有竞争,而是由同一个线程多次获得锁资源,为了消除无竞争产生的性能消耗,JDK引入了偏向锁,提高性能。
2.5.1 匿名偏向
JDK6开始默认开启了偏向锁模式,新new出来的对象MarkWord的ThreadID为0,说明该对象处于可以偏向,但未偏向任何线程的状态,称之为匿名偏向。
2.5.2 延迟偏向
HotSpot虚拟机在JVM启动后有4s的延迟才会对新new出来的对象开启偏向模式,这是因为JVM在启动过程中会有很多系统配置,这些类里面有很多内置锁,为了减少JVM启动时间,JVM提出了延迟偏向
的功能。可以通过-XX:BiasedLockingStartupDelay=0
来控制延迟时间。-XX:-UseBiasedLocking
可以禁止偏向锁。-XX:+UseBiasedLocking
开启偏向锁。
2.5.3 偏向撤销 - hashCode()
当调用对象的#hashCode()
orSstem.identityHashCode()
方法时,会导致偏向对象的偏向撤销。因为hashCode没有地方保存,所以撤销以后,这些hashCode等记录会根据不同的锁状态存在不同地方:轻量级锁存储在锁记录中。重量级锁存储在Monitor对象中。
当对象处于匿名偏向或已偏向状态下,调用对象的hashCode方法会导致对象再也无法偏向。
1.当对象处于匿名偏向时,调用hashCode()会让锁升级为轻量级锁。
2.当对象处于偏向锁时,在同步代码块中调用hashCode()会使偏向锁强制升级为重量级 锁。
偏向撤销是一个消耗性能的过程,一个好的程序流程不应该频繁的偏相关撤销,偏向撤销要等待全局安全点,会造成JVM的STW。
2.5.4 偏向撤销 - wait()/notify()
在偏向锁状态下执行notify(),会让锁升级为轻量级锁。执行wait()时,升级为重量级锁,因为wait()本来就是基于Monitor监视器实现。
2.5.5 锁重入
通过在栈中创建lock record记录来标识锁的重入次数,当同一个线程再次获取锁时,如果对象头中的线程ID是自己的话,无需CAS修改对象头。
2.6 轻量级锁
如果偏向锁失败,JVM并不会立即升级为重量级锁,而是通过轻量级锁来进行优化。轻量级锁的场景就是线程交替执行代码块
。也就是说不存在锁的竞争,如果同一时刻多个线程抢锁,就会导致轻量级锁膨胀为重量级锁。
当偏向锁释放以后,锁状态仍然为偏向锁。此时,如果有另一个线程来加锁,则会升级为轻量级锁,会在当前线程中创建lcok record
结构指向对象,存储锁的状态信息。通过CAS来修改对象头的指针信息。轻量级锁释放后会降级为无锁
,将lock record中的信息拷贝回对象头。
2.7 重量级锁
如果在轻量级锁的模式下,发生了线程竞争,也即是说CAS修改对象头失败,那么当前竞争线程就会膨胀为重量级锁。重量级锁就会进入Monitor监视器模式。重量级锁的锁记录等信息保存在ObjectMonitor对象中,重量级锁释放后变为无锁。
2.8 锁升级/锁状态转移主流程
3.synchronized锁优化
3.1 批量重偏向
如果锁对象一直是同一个线程进行加锁,那么偏向锁的性能很高,但是当有竞争时,就会发生偏向撤销,转而升级为轻量级锁or重量级锁,这个开销蛮大。JDK为此进行了优化,方案就是批量重偏向和批量撤销。
批量重偏向的原理是:
以class为单位,每个class都会维护一个偏向锁撤销计数器,每当这个class的对象发生过一次偏向撤销,计数器就+1,当达到一个阈值(默认20次),JVM就认为该class的偏向锁有问题,转而进行批量重偏向。每次锁对象发生批量重偏向后,对象的epoch值就会+1,同时遍历JVM所有线程栈,找到这个class的所有被持有的
锁对象,将其epoch值更新(只会更新正在锁定的对象)。线程下次获取锁时,发现当前对象的epoch和class维护的epoch值不相等,说明这个锁对象的偏向锁模式已失效,进而重偏向。
批量重偏向
的机制是为了解决一个线程创建了大量对象进入偏向模式后,另外线程也将这些对象进行加锁操作,这些对象就会频繁偏向撤销,偏向撤销会消耗性能。偏向锁重偏向一次之后不可再次重偏向
3.2 批量偏向撤销
当class维护的偏向锁撤销计数器达到了阈值(默认40),JVM就认为这个class的所有锁对象的偏向模式有问题,将这个calss的所有锁对象置为不可偏向,后面有线程加锁,直接就是轻量级锁。新new出来的对象同样是不可偏向。但是这个计数器会有时间范围(默认25秒),过了这个时间就会重置清0。
3.3 自适应自旋
自旋发生在膨胀为重量级锁的过程中,因为最坏的情况,重量级锁是内核态,性能消耗大。在JDK6后,膨胀为重量级锁的过程中,尝试多次加锁,这个自旋是自适应次数的。
3.4 锁粗化
在一段没有线程竞争的程序中,例如方法体内局部变量,多次的加锁解锁,例如StringBuffer的#append(),JVM就会优化,进而扩大加锁范围,避免频繁加解锁。
3.5 锁消除 - JIT及时编译优化
4.Monitor、重量级锁原理
4.1 CXQ竞争队列
这是一个栈结构,线程在入栈之前会通过CAS自适应自旋操作来获取锁,实在获取不到才进入CXQ栈中(说明synchronized是非公平锁
)。
4.2 EntryList等待队列
和CXQ一样时等待的队列,不过EntryList是队列结构FIFO,为了避免多线程并发修改CXQ问题,JVM引入了EntryList等待队列。当持有锁的线程释放后,JVM从EntryList中弹出一个就绪的线程作为竞争锁的线程Reday Thread,此时就绪线程并非owner,因为synchronized是非公平的,reday Thread不一定就能拿到锁。
4.3 WaitSet等待队列
持有锁的线程调用wait()方法时,就会放弃锁,进入WaitSet等待队列,等待其他线程调用锁对象的notify()、notifyAll()或超时等方法来唤醒,唤醒后会立即进入EntryList,走EntryList的流程。
4.4 park操作(Linux#Mutex)
线程的挂起操作是调用操作系统的API完成,这是一个系统调用,需要用户态到内核态的切换,Linux提供了pthread_mutex_lock
函数来实现线程的park。用户态到内核态的切换有时比用户的同步代码执行时间还要长,所以synchronized才如此复杂繁琐的优化,尽可能避免park。