使用方式:
- 方法锁:锁对象为调用者本身。
- 代码块加锁:锁对象为synchronized(Object o)传入的对象。
实现原理:
synchronized是JVM内置锁 ,基于 Monitor 机制实现,依赖底层操作系统的互斥原语 Mutex (互斥量),在早期的JDK版本中,它是一个重量级锁,性能较低。当然, JVM内置锁在1.5之后版本做了重大的优化, 如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操 作的开销 ,内置锁的并发性能已经基本与Lock持平。
Java虚拟机通过一个同步结构支持方法和方法中的指令序列的同步:monitor。 同步方法是通过方法中的access_flags中设置 ACC_SYNCHRONIZED 标志来实现;同步代码块是通 monitorenter和monitorexit 来实现。 两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态 之间来回切换,对性能有较大影响。 不管使用哪一种,本质是对一个对象的监视器进行获取,同一个时刻只能有一个线程获取到synchronized保护对象的监视器。
Monitor(管程/监视器)
Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。 管程是指管理共享变量以及对共享变量操作,让它们支持并发的过程。 在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。
Monitor机制在Java中的实现
java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor 实现,这是 JVM 内部基于 C++ 实现的一套机制。ObjectMonitor 其主要数据结构如下(hotspot源码ObjectMonitor.hpp):
ObjectMonitor() { _header = NULL; //对象头 markOop _count = 0; _waiters = 0, _recursions = 0; // 锁的重入次数 _object = NULL; //存储锁对象 _owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程) _WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点 _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构) FreeNext = NULL ; _EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失 败的线程) _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; _previous_owner_tid = 0; }
线程发生竞争时,只有一个线程会竞争锁成功,其余的会先进入到_cxq中,它是一种栈结构,先进后出。这即是synchronized非公平锁原理。当获取锁的线程调用wait方法时,此时锁会升级为重量级锁,线程会进入了monitor的_waitSet等待池中,当调用notify或者获取锁的线程执行结束需要唤醒其他线程时,进入_waitset中的线程会根据策略进入_cxq或_EntryList中,默认进入EntryList中。当下一次争抢锁的使用权时,会优先分配给_EntryList中的线程。如果_EntryList为空,则将_cxq中的线程按顺序放入_EntryList中并给第一个线程赋予锁的使用权。
MarkWord的结构
特点:
- 无锁态和偏向锁状态的锁标志位都是01,所以有"是否偏向锁"的字段来区别。
- 偏向锁没有地方存储hashcode,轻量级锁的hashcode存在栈帧的锁记录中,重量级锁会在 Monitor中记录 hashCode。所以偏向锁在未锁定状态调用hashcode会降级为无锁。偏向锁在锁定状态调用hashcode方法会升级为轻量级锁,轻量级锁会复制无锁状态的锁记录到栈帧中,以此来记录hashcode。
Synchronized的四种状态
- 无锁状态:锁对象在禁用偏向锁被创建出来未被任何线程所获取或者偏向锁撤销后的状态。
- 偏向锁:当JVM启用了偏向锁模式(jdk6默认开启),新创建对象的Mark Word中的Thread Id为0,说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向状态(anonymously biased)。锁初次被线程获取也是偏向锁模式。在获取锁的过程中,线程在锁对象的markword中记录偏向锁状态和当前线程的线程ID,当该线程再次请求锁时,只需检查锁对象的markword的锁标记是否为偏向锁和记录的线程ID是否为当前线程的ID,无需再做有关申请锁的操作,连CAS都不需要,所以性能较高。偏向锁使用了一种等到出现竞争才会释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁释放时,会将持有偏向锁的线程运行到安全点,然后检查该线程是否存活以及是否需要继续持有该偏向锁,如果线程存活或需要继续持有该偏向锁,则偏向锁升级为轻量级锁。如果线程不存活或不需要继续持有该偏向锁,则会重置锁状态为无锁状态,无锁状态只能升级为轻量级锁或重量级锁,无法再回到偏向锁状态。偏向锁在jdk1.6后默认开启,可以通过jvm参数来关闭偏向锁:-XX:-UseBiasedLocking = false。
偏向锁延迟偏向
偏向锁模式存在偏向锁延迟机制 :HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建 的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等 等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。 为了减少初始化时间,JVM默认延时加载偏向锁。
//关闭延迟开启偏向锁 ‐XX:BiasedLockingStartupDelay=0 //禁止偏向锁 ‐XX:‐UseBiasedLocking //启用偏向锁 -XX:+UseBiasedLocking
- 轻量级锁:线程在获取轻量级锁时会在栈帧中创建一块用于存储锁记录的空间(Lock Record),然后将锁对象头中的markword(无锁状态的markword)复制到这块空间当中,称之为Displaced MarkWord(无家可归的markword)。之后用CAS机制尝试一次(不自旋)将锁对象头的中的markword替换为指向当前栈帧中锁记录的指针。这个操作如果成功,则当前线程获取到轻量级锁。在这个过程中,它还会检查当前锁状态是否已被自己获取,如果是则说明本次获取锁是一个重入操作。对于重入操作,同样的会在栈帧中创建一个区域用于存储锁记录,只是这个锁记录里的markword是空,释放锁时遵循后进先出的原则释放锁。在最后一个Lock Record被释放时将无锁状态的markword放进锁对象头中,锁被重置为无锁状态;如果获取锁不成功,则锁升级为重量级锁。即轻量级锁所适应的场景是线程交替执行同步块的场合 ,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。
- 重量级锁:锁在膨胀成重量级锁的期间会创建一个monitor对象。即重量级锁的原理是monitor机制。重量级锁膨胀的过程分为两步,第一步:获取monitor锁对象。这个过程是通过for循环一直重复进行,首先检查monitor锁是否已存在,如果存在直接返回。如果不存在,则判断锁是否在膨胀中,如果是则让出CPU,如此循环16次,如果仍然在膨胀,则挂起线程。如果没有在膨胀中,如果当前是轻量级锁,则创建monitor对象。第二步,调用monitor的entry方法获取锁。这个过程中通过CAS将markword指向monitor对象,同时将minitor的owner指针指向当前线程,这个过程涉及到多次的自适应自旋获取锁,如果这个过程成功,则锁升级为重量级锁,如果不成功,最终迫不得已而挂起线程。重量级锁的竞争会导致线程在用户态和内核态来回切换,性能急剧下降,除了拥有锁的线程外,其他线程都会阻塞。适用于追求吞吐量,同步块或同步方法执行时间较长的场景。
偏向锁撤销之调用对象HashCode
调用锁对象的obj.hashCode()或System.identityHashCode(obj)方法会导致该对象的偏向锁被撤销。因为对于一个对象,其HashCode只会生成一次并保存,偏向锁是没有地方保存 hashcode的。
- 轻量级锁会在锁记录中记录 hashCode
- 重量级锁会在 Monitor 中记录 hashCode
当对象处于可偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也 无法偏向 :
- 当对象可偏向但未偏向时,MarkWord将变成未锁定状态,并只能升级成轻量锁;
- 当对象正处于已偏向时,调用HashCode将使偏向锁强制升级成重量锁。
偏向锁撤销之调用wait/notify
偏向锁状态执行obj.notify() 会升级为轻量级锁,调用obj.wait(timeout) 会升级为重量级锁
假设有两个线程, A先获取到的就是偏向锁,那么B就会通过CAS尝试锁的获取。当B获取到锁时,会检查A是否还存活,如果不,则锁被重置为无锁状态,此时B竞争到锁,将其设置为轻量级锁。如果存活,则锁升级为轻量级锁。当BCAS失败没获取到锁时,锁就自动升级为重量级锁。重量级锁释放后转为无锁状态,monitor被当作垃圾回收。第二种场景,如果有N个线程同时竞争锁,其中只有一个自旋失败没有获取到锁,那么锁也会升级成重量级锁。
synchronized锁状态变化总结
- 锁对象创建时可能有两种状态,如果禁用了偏向锁,或者开启了偏向锁但在服务启动的前4秒内,此时创建的对象是无锁状态,反之,则是匿名可偏向状态。
- 锁处于匿名可偏向状态时,如果调用hashcode方法则降级为无锁状态;如果此时有线程获取到了匿名可偏向锁,则锁转为偏向锁已偏向状态。
- 锁处于已偏向状态时,即使当前线程释放了锁,锁仍然会处于已偏向状态,不会转为无锁状态,直到出现了另一个线程的锁竞争,才会导致锁状态的转变。
- 偏向锁已偏向时转变锁有三种情况,第一,当出现了线程的竞争时,如果当前线程已经释放了获取的偏向锁,则锁会先重置为无锁状态,然后升级为轻量级锁。第二,如果出现线程竞争时,当前线程仍然没释放锁,则锁直接升级为轻量级锁。第三,即使未出现锁竞争,但当前线程在获取锁的时间段内,执行了hashcode或wait方法,则直接升级为重量级锁。
- 线程一旦转为无锁状态,则永远无法再转为偏向锁状态。在无锁状态时,如果出现轻微锁竞争,即只有一个线程去竞争,且竞争成功了,则升级为轻量级锁。如果出现大量竞争,即有一部分线程直接竞争失败了,则直接升级为重量级锁。
- 线程在竞争轻量级锁失败时,会导致锁升级为重量级锁。无锁、轻量级锁、重量级锁都无法再转为偏向锁。轻量级锁、重量级锁释放后都会转为无锁状态。
- 重量级锁依赖于monitor机制实现,会导致用户再内核态和用户态之间切换,性能很差。
偏向锁批量重偏向&批量撤销
在开启偏向锁的条件下,对象创建出来则是匿名可偏向状态。如果此时线程竞争频繁时,偏向锁升级会先撤销为无锁再升级为轻量级锁。在这种情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。
批量重偏向:当一个类的所有对象锁累计撤销超过20次,即同一个类的多个对象锁累计由偏向锁转为轻量级锁超过20次,则jvm会认为之前的冲偏向策略可能时存在问题的,它会将20次之后的锁升级操作改为再次重偏向的操作偏向新的线程,即20次之前的锁升级为轻量级锁,20次之后的锁仍然是偏向锁。
批量撤销: 当一个类的所有对象锁累计撤销超过40次,则jvm会认定这个类不适合使用偏向锁,之后,对于该class的锁,直接走轻量级锁的逻辑。即新对象创建出来为无锁状态,加锁直接加轻量级锁。
注意: 时间-XX:BiasedLockingDecayTime=25000ms范围内没有达到40次,撤销次数清为0,重新计时
原理
以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id
intx BiasedLockingBulkRebiasThreshold = 20 //默认偏向锁批量重偏向阈值 intx BiasedLockingBulkRevokeThreshold = 40 //默认偏向锁批量撤销阈值
总结
1. 批量重偏向和批量撤销是针对类的优化,和对象无关。
2. 偏向锁重偏向一次之后不可再次重偏向。
3. 当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化(只有重量级锁才有自旋),如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
- Java 7 之后不能控制是否开启自旋功能
注意: 自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)
锁粗化
假设一系列的连续操作都会 对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的 ,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
StringBuffer buffer = new StringBuffer(); /** * 锁粗化 */ public void append(){ buffer.append("aaa").append(" bbb").append(" ccc"); }
上述代码每次调用 buffer.append 方法都需要加锁和解锁,如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
锁消除
锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,通过逃逸分析,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。