前几天看到AQS的一个问题:
有了 synchronized 为什么还要重复造轮子?
我们顺便来回顾下概念
Synchronized 原理
Synchronized在1.6之前也叫重量级锁,随着Java SE 1.6对Synchronized进行了各种优化之后,有些情况下它就并不那么重了,它的作用主要有两个:
- 原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性
- 可见性:一个线程对共享变量的修改,另外一个线程能够看到,我们称为可见性
了解这个先来了解下它存在的三种模式:
- 对于普通同步方法,锁是当前实例对象
- 对于静态同步方法,锁是当前类的Class对象
- 对于同步方法块,锁是Synchonized括号里的对象
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁;synchronized的锁是存在Java对象头里的,对象头包括两部分:Mark Word 和 类型指针
Java对象头
标记字段 (Mark Word)
MarkWord 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
64位标记字段详情
|-------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|-------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:01 | Normal |
|-------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:01 | Biased |
|-------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:00 | Lightweight Locked |
|-------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:10 | Heavyweight Locked |
|-------------------------------------------------------------------------------|--------------------|
| | lock:11 | Marked for GC |
|-------------------------------------------------------------------------------|--------------------|
- lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记
- biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁
- age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因
- identity_hashcode:31位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中
- thread:持有偏向锁的线程ID
- epoch:偏向时间戳(用这个字段来标记偏向锁是否处于可重偏向状态)
- ptr_to_lock_record:指向栈中锁记录的指针
- ptr_to_heavyweight_monitor:指向管程Monitor的指针
类型指针
类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例,这一部分用于存储对象的类型指针,该指针指向在元数据区域的类元数据
JVM通过这个指针确定对象是哪个类的实例。该指针的位长度32位的JVM为4字节,64位的JVM为8字节;为了节约内存可以使用选项+UseCompressedOops开启指针压缩(默认开启)
数组长度(Array Length)
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度。这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位
当然:64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位
Synchronized的锁升级
Java1.6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,锁一共有4种状态,级别从低到高依次是:无锁状态(01)、偏向锁状态(01)、轻量级锁状态(00)和重量级锁状态(10),这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁
偏向锁的三种状态
- 匿名偏向状态:新创建对象的Mark Word中的Thread Id为0,说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向状态(anonymously biased)
- 可重偏向状态:在此状态下,偏向锁的epoch字段是无效的(与锁对象对应的klass的mark_prototype的epoch值不匹配)
每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值,每次发生批量重偏向时,就将该值(类中)+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id
- 已偏向状态: 这种状态下,thread ptr非空,且epoch为有效值——意味着其他线程正在只有这个锁对象
获取锁
获取偏向锁的流程如下:
- 检测Mark Word中锁的标志位是否是01(偏向锁标志为1,即低三位为101),即是否为可偏向,如果不是,直接走轻量级锁(CAS方式竞争锁)逻辑
- 判断Mark Word中线程Id是否为当前线程,如果是则是已获取锁状态执行同步块重入锁状态,以后该线程进入同步块时,不需要CAS进行加锁,只会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,用来统计重入的次数(如图为当对象所处于偏向锁时,当前线程重入3次,线程栈帧中Lock Record记录)
退出同步块释放偏向锁时,则依次删除对应Lock Record,但是不会修改对象头中的Thread Id
- 如果不是当前线程,此时需判断当前偏向锁的状态对象处于可重偏向状态,若为可偏向状态,即epoch是无效的,则会使用CAS的方式更新当前线程Id进入对象的Mark Word中,更新成功则会找到栈内存中(从低往高)的可用Lock Record设置obj的值,获取锁,执行同步代码块
- 如果对象锁已经被其他线程占用,则会替换失败,开始进行偏向锁撤销,这也是偏向锁的特点,一旦出现线程竞争,就会撤销偏向锁
- 偏向锁的撤销需要等待全局安全点(safe point,代表了一个状态,在该状态下所有线程都是暂停的),暂停持有偏向锁的线程,检查持有偏向锁的线程状态(遍历当前JVM的所有线程,如果能找到,则说明偏向的线程还存活),如果线程还存活,会检查线程是否在执行同步代码块中的代码,如果是,则升级为轻量级锁,进行CAS竞争锁
- 如果持有偏向锁的线程未存活,或者持有偏向锁的线程未在执行同步代码块中的代码,则会继续判断是否允许重偏向,如果不允许重偏向,则撤销偏向锁,将Mark Word设置为无锁状态(未锁定不可偏向状态),然后升级为轻量级锁,进行CAS竞争锁;如果允许重偏向,设置为匿名偏向锁状态,CAS将偏向锁重新指向线程A(在对象头和线程栈帧的锁记录中存储当前线程ID)(此处逻辑分为VM线程和获取锁线程,VM线程全部在全局安全点执行,判断是否允许重偏向一直为false,即全部执行偏向锁撤销;获取锁线程判断允许重偏向,则是判断Class.Epoch != 对象.Epoch即为可以重偏向)
- 唤醒暂停的线程,从安全点继续执行代码
释放锁
偏向锁的不会去主动释放锁,依赖于其他线程竞争时来判断偏向锁线程是否执行完成逻辑来执行偏向锁撤销,偏向锁的撤销需要等待全局安全点。偏向锁撤销逻辑和步骤在上面获取锁中第5-6点。
延升
偏向锁在单线程场景时性能几乎可以认为忽略不计,但当有其他线程产生竞争尝试获取锁时,就需要等待safe point,再将偏向锁撤销为无锁状态并升级为轻量级,所以在高并发场景下偏向锁会降低一部分性能(发生 stop the world后,开启偏向锁会带来更大的性能开销,这就是 Java 15 取消和禁用偏向锁的原因)。偏向锁则是在只有一个线程执行同步块时进一步提高性能
这里在上面涉及重偏向概念:对于类的重偏向也叫批量重偏向,当一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作(偏向锁撤销),会导偏向锁重偏向的操作。注意:偏向锁重偏向一次之后不可再次重偏向
批量撤销:在多线程竞争剧烈的情况下,使用偏向锁将会降低效率(需要safe point撤销),于是乎产生了批量撤销机制;即重偏向的锁又经过多次多线程变更后,就会产生批量撤销,此类的所有对象锁升级为轻量级锁(批量重偏向和批量撤销是针对类的优化,和对象无关)
因为偏向锁对象需要使用hashcode字段作为偏向线程id标识,被调用hashCode的对象不可被用作偏向锁,而是直接升级为重量级锁。仅仅适用于“identity hashcode(使用Object类的hashcode()方法进行计算)”。普通Java类型hashcode的计算需要重载Object的hashcode()方法,但不必要去显示调用这个方法;同理的还有wait方法,因为wait()方法调用过程中依赖于重量级锁中与对象关联的monitor,在调用wait()方法后monitor会把线程变为WAITING状态,所以才会强制升级为重量级锁
轻量级锁
轻量级锁解决的问题是大部分锁是线程间交替执行,整个执行周期内不存在竞争,可能是交替获取锁然后执行
轻量级锁与偏向锁的区别是,引入偏向锁是假设同一个锁都是由同一线程多次获得,而轻量级锁是假设同一个锁是由n个线程交替获得;相同点是都是假设不存在多线程竞争
在关闭偏向锁或者多线程竞争偏向锁升级为轻量级锁时,都会尝试获取轻量级锁。此时的Mark Word的结构也会变为轻量级锁,如果存在多个线程同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。引入轻量级锁的目的就是 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗
获取锁
获取轻量级锁的主要流程如下:
- 判断Mark Word的标志位为无锁不可偏向(001)时,jvm首先将在当前线程的栈帧中创建一条锁记录(lock record),用于存放:
- displaced mark word(置换标记字):存放锁对象当前的mark word的拷贝
- owner指针:指向当前的锁对象的指针 (偏向锁也会在当前线程栈中找一条lock record记录重入次数,并用owner记录锁对象指针,但不会拷贝Mark Word)
- 在拷贝完成Mark Word时,JVM会尝试将锁对象头中的Mark Word更新为线程中的Lock Record指针:
- 更新成功则代表获取到轻量级锁,并更新锁的标志位为00轻量级锁,执行同步代码块
- 判断持有锁是否为当前线程,如果是则执行重入锁逻辑:每次重入在栈中分配一个Lock Record,其中owner指向锁对象,displaced mark word为null,每多一次重入创建一个Lock Record(重入的次数为该锁对象在当前线程Lock Record中的数量)(和偏向锁重入类似);否则说明其他线程已经获取到了锁,那么此时会膨胀成重量级锁,并且使用CAS的方式将锁的标志位更新为10,此处会不断自旋,自旋成功后后面进入的线程会进入阻塞状态(获取锁不存在自旋,膨胀逻辑才会自旋,具体实现可参考ObjectSynchronizer::inflate)
释放锁
- 轻量级锁释放仍然使用CAS的方式,将当前线程中的displaced mark word替换到锁对象的Mark word字段中,此时锁对象Mark word的Lock Record需要指向当前线程的锁记录;同理,也会出现:
- 替换成功则代表释放成功,那么此时其他线程就可以竞争锁
- 替换失败,说明存在线程竞争,可能其他线程在这段时间竞争锁失败,锁已经膨胀为重量级锁,同时唤醒 Monitor entry set 中被挂起的线程
- 如果是重入锁释放,则直接删除一次当前线程栈中对应锁对象的Lock Record记录,只有在最后一次释放时走1中逻辑
轻量级锁执行流程图如下:
重量级锁
重量级锁依赖对象内部的monitor锁来实现,而monitor又依赖操作系统的MutexLock(互斥锁);Mutex变量的值为1,表示互斥锁空闲,这个时候某个线程调用lock可以获得锁,而Mutex的值为0表示互斥锁已经被其他线程获得,其他线程调用lock只能挂起等待。(和AQS的独占锁state类似,state代表锁状态)
为什么重量级锁开销比较大
当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的
内置锁(ObjectMonitor)
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁
通常所说的对象的内置锁,是对象头Mark Word中的重量级锁指针指向的monitor对象,简单看一下对象结构:
//结构体如下
ObjectMonitor::ObjectMonitor() {
_header = NULL; // 轻量级锁膨胀时会存入displaced mark word,
_count = 0;
_waiters = 0,
_recursions = 0; // 线程的重入次数
_object = NULL;
_owner = NULL; // 标识拥有该monitor的线程,初始时和锁被释放后都为null
_WaitSet = NULL; // 等待线程组成的双向循环链表,_WaitSet是第一个节点,类似于AQS中的条件队列
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // ConnectionList 线程竞争锁进入时的单向链表
FreeNext = NULL ;
_EntryList = NULL ; // _EntryList是第一个节点,当owner解锁时会将cxq队列中的线程移动到该队列中,类似于AQS中的CLH队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
当升级为重量级锁的情况下,锁对象的mark word中的指针不再指向线程栈中的lock record,而是指向堆中与锁对象关联的monitor对象
轻量级锁膨胀流程
- 调用omAlloc分配一个ObjectMonitor对象(以下简称monitor),初始化monitor对象
- 使用CAS的方式将状态设置为膨胀中(INFLATING)状态
- 设置monitor的header字段为displaced mark word,owner字段为Lock Record,obj字段为锁对象
- 设置锁对象头的mark word为重量级锁状态,指向第一步分配的monitor对象
假设两个线程竞争,锁膨胀后如图
线程1和线程2同时进来,但首先是线程1获取轻量级锁成功(CAS只会保证一个线程将对象头的Mark Word更改为自己的Lock Record指针)(暂时跳过偏向锁逻辑),将Mark Word更改为" Thread1 Lock Record | 00 ",此时Thread1的Lock Record为锁对象头的Mark Word,Thread1加锁完成。
此时Thread2来加锁,线程栈里找一个可用的 lock record,将obj 指针指向锁对象、lock record 中 mark word把它设置为锁对象的 mark word,随后操作锁对象的Mark Word,CAS失败,此时将判断锁对象头的Mark Word不是自己线程,发生锁膨胀逻辑:
- 将线程栈中的Lock Record里的Mark Word设置为3,即0000...11
- CAS方式将锁对象的Mark Word更改为0,标识锁膨胀中
- 堆上面分配一块内存,创建 ObjectMonitor 对象
- ObjectMonitor 对象里的 mark word,把它设置为锁对象的 mark word,即 t1 的第一个 lock record 指针 + 00
- ObjectMonitor 对象里的 owner,把它设置为 t1 的第一个 lock record 的指针
- ObjectMonitor 对象里的 object,把它设置为锁对象的指针
- 把锁对象的 mark word 改为 ObjectMonitor 对象的指针 + 10(此时 t1 设置的 mark word 被覆盖掉了),膨胀完成
接下来会进入ObjectMonitor的获取锁逻辑中:
尝试获取锁逻辑
- 如果当前是无锁状态、锁重入、当前线程是之前持有轻量级锁的线程则进行简单操作后返回
- 尝试先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销
- 线程封装成ObjectWaiter对象,插入到CXQ队列中
- 如果是头结点,再次使用CAS的方式抢占一次锁,如果还没有抢到,调用park方法将自己阻塞 (由此可见,此处的逻辑和AQS中独占锁逻辑类似的)
- 至此等待其他线程唤醒,唤醒后 删除自己在链表中的节点
释放锁逻辑
- 解除线程栈中Lock Record中与锁对象的关联
- 如果是重入锁,将recursions递减即可
- 将ObjectMonitor 对象 owner 置空,从 cxq 或者 EntryList 里面唤醒一个阻塞等锁的线程 (由于入队的时候 cxq 链表是头插的,所以 synchronized 默认的唤醒策略是,最后阻塞等锁的最先唤醒)
Lock Record中的Mark Word区别
不论是什么类型的锁,在进入时都会在栈空间增加lock record记录,并且obj指向锁对象,但是mark word字段会有一些不同:
- 偏向锁:不管是不是重入,lock record 里面都不记录 mark word
- 轻量锁:第一次加锁,lock record 里面会记录锁对象的 mark word,重入的 lock record 没有 mark word
- 重量锁:不管是不是重入,lock record 里面的 mark word 固定是 3
锁优化
JDK1.6之后还对锁的实现实现了优化:自旋锁、适应性自旋锁、锁消除、锁优化。这里做简要整理:
- 自旋锁:重量级锁在获取锁时,会默认自旋CAS尝试10次,可以使用XX:PreBlockSpin参数调整(因为一旦获取锁失败就需要进入等待队列等待唤醒,线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对性能的影响比较大)
- 适应性自旋锁:自适应其实就是对自旋锁的优化,自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源
- 锁消除:JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持
- 锁粗化:如果多个连续的加锁、解锁操作连接在一起,将扩展成一个范围更大的锁,避免重复加锁解锁
在语言实现上Synchronized和ReentrantLock的区别
- Synchronized是JVM层次的锁实现,ReentrantLock是JDK层次的锁实现
- Synchronized的锁状态是无法在代码中直接判断的,但是ReentrantLock可以通过ReentrantLock#isLocked判断
- Synchronized是非公平锁,ReentrantLock是可以是公平也可以是非公平的
- Synchronized是不可以被中断的,而ReentrantLock#lockInterruptibly方法是可以被中断的(响应中断)
- 在发生异常时Synchronized会自动释放锁,而ReentrantLock需要开发者在finally块中显示释放锁
- ReentrantLock获取锁的形式有多种,如立即返回是否成功的tryLock(),以及等待指定时长的获取
- Synchronized的等待队列中是后来的先获取锁,而ReentrantLock在等待队列中是先来的获取锁(虽然都存在来的线程直接获取锁的情况,但它们对于队列的处理也是不同的)
小节
总体来说Synchronized和ReentrantLock的非公平锁模式是非常相似的,包括CAS方式获取资源、等待队列、条件队列、阻塞和唤醒机制,学习一种实现的同时也能为接下来理解类似机制做下良好铺垫,同时扩展学习是有好处的,可以方便我们更深层次的思考问题,从而收获更多的惊喜
参考
JDK 源码 / OpenJDK8 源码
JAVA 对象头分析及Synchronized锁
偏向锁
死磕Synchronized底层实现--偏向锁
偏向锁
Java并发系列(15)——synchronized之HotSpot源码解读
再谈synchronized锁升级