锁升级流程图

Mark-Word

进入锁升级的学习前,必须明确一个概念,所谓的锁,所著的一定是一个对象,实例也好,类也好。先了解Java对象头中的mark-word

为什么无锁/偏向锁的标志位是01,而轻量级锁的标志位是00?

即按理说,无锁是锁状态的初始情况,为什么标志位不是从00开始?

个人查询到的一个解释,是因为 轻量级锁除了锁标志位外,另外62位都是一个指针地址。

如果将轻量级锁标志位设置为00, 那么在判断标志位为00后, m无需再额外做一次markWord>>2的操作,而是直接将markWord拿来当作地址使用即可!

哈希code去哪了

我们注意到无锁时的hashcode不见了。

对于偏向锁而言, 一旦在对象头中设置过hashcode, 那么进入同步块时就不会进入偏向锁状态,会直接跳到轻量级锁,毕竟偏向锁里没有存放hashcode的地方(下文的轻量级锁和重量级锁则有存储的地方)

因此凡是做过类似hashmap.put(k,v)操作且没覆写hashcode的k对象, 以后加锁时,都会直接略过偏向锁。

偏向锁

偏向锁的意义

假设一个场景,一个同步代码块只有一个线程使用,如果使用多次的话,需要重复获取锁和释放锁的操作,这对性能很有影响,而偏向锁在退出同步代码块时,不会释放锁,而是继续占用,下次进入同步代码块时,只需检查锁中的线程id是否是自己的线程id

无锁升级为偏向锁

首次进入同步代码块时,会通过cas的方式设置锁中的线程id(采用cas是因为存在同时写的可能的),进入偏向锁,即无锁转为偏向锁(个人认为用升级两个字其实不太妥当,因为无锁根本不涉及到同步代码块)

偏向锁的竞争

当一个线程已经持有偏向锁,而另一个线程也想进入该同步代码块时,就会出现竞争,

  1. 第二个线程先等程序进入jvm全局安全点(即当前无字节码执行)
  2. 判断偏向锁占有线程是否还在执行
  3. 如没有执行则退为无锁,第二个线程通过cas尝试获取偏向锁,若还在执行,则进入轻量级锁的升级过程

偏向锁启用延迟

即使你开启了偏向锁,但是这个偏向锁的启用是有延迟,大概 4s左右。即java进程启动的4s内,都会直接跳过偏向锁,有同步代码块时直接使用轻量级锁。

原因是 JVM 初始化的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,jvm团队经过测试和评估, 选择了启动速度最快的方案, 即强制4s内禁用偏向锁,所以就有了这个延迟策略 (当然这个延迟时间也可以通过参数自己调整)

偏向锁的演变历史

偏向锁在JDK6引入, 且默认开启偏向锁优化, 可通过JVM参数-XX:-UseBiasedLocking来禁用偏向锁。但随着时代发展,发现偏向锁带来的维护、撤销成本, 远大于轻量级锁的少许CAS动作。官方说明中有这么一段话: since the introduction of biased locking into HotSpot also change the amount of uncontended operations needed for that relation to remain true。

即随着硬件发展,原子指令成本变化,导致轻量级自旋锁需要的原子指令次数变少,所以自旋锁成本下降,故偏向锁的带来的优势就更小了。于是jdk团队在Jdk15之后, 再次默认关闭了偏向锁。

轻量级锁

轻量级锁的意义

我们需要先明白一下阻塞线程是一个很消耗性能的操作,我们假设现在多个线程不断的轮着执行同步代码块,会不断的发生获取和释放锁的操作,就会不断地阻塞和唤醒线程,非常消耗性能,而轻量级锁则不阻塞线程,而是通过自旋尝试获取锁来达到类似阻塞的效果(这里需要强调一点,自旋是需要消耗cpu的,如果自旋过久的话,性能消耗反而会超过阻塞线程的性能消耗)

阻塞线程为什么消耗性能

因为阻塞线程需要操作系统的介入,导致从用户态切换到内核态,这是一个很消耗性能的过程

偏向锁升级为轻量级锁

如果对象进行过获取hashcode的操作,则直接无锁进入轻量级锁

在上文提到了偏向锁升级为轻量级锁的时机,那升级过程究竟是怎样的呢,我们注意到,当进入轻量级锁时,mark-word的一些属性不存在了,其实这些属性被存储到对应线程栈帧中的lockRecord(即轻量级锁)中了

  1. 当其他线程将锁升级为轻量级锁后,当前持有锁的线程会升级为轻量级锁,复制对象头中的mark-word到lockRecord中,然后将mark-word中的内容替换为指向当前lockRecord的指针
  2. 其他线程通过轻量级锁的机制竞争锁,即自旋的方式,如果自旋的次数超过阈值,则升级为重量级锁

轻量级锁的重入机制

首先提一下为什么偏向锁没有重入机制,因为偏向锁的本质是一个线程持有锁,所以无论重入多少次,也只有一个线程访问,也没有重入机制的必要了

线程的lockRecord不是一个单一的lockRecord,而是lockRecord集合,每次重入会添加一个存储null的lockRecord,所以除了第一个(其实应该叫最后一个,因为是最后访问到的)lockRecord存储的是对象的mark-word,其他的lockRecord存储的都是null,每一次退出重入时,删除一个lockRecord(这里是后进先出),计数减一

轻量级锁的加锁过程

  1. 每次线程进入同步代码块前,判断mark-word的指针是否指向自己,如果是,则直接走重入机制,添加一个lockRecord
  2. 如果指针不是指向自己,则通过cas替换对象mark-word中的指针使指针指向自己,如果cas的次数超过阈值(默认10次),则升级为重量级锁
  3. 如果mark-word还没有指向线程,说明还没被占有,复制mark-word到lockRecord,尝试通过cas将mark-record替换为指向lockRecord的指针
  4. 如果cas失败,则继续自旋,超出阈值,升级为重量级锁

轻量级锁的解锁过程

  1. 退出同步代码块时,获取当前lockRecord的值,如果是null,表示是退出重入,则走退出重入机制
  2. 如果不为null,证明是退出锁,通过cas将mark-word复制到对象的mark-word中
  3. 如果cas成功,解锁成功
  4. 如果cas失败,证明已经被其他线程膨胀到重量级锁,走重量级锁解锁机制

重量级锁

objectMonitor

每个对象的重量级锁指向一个独有的objectMonitor,这个对象是C++实现的,其内部维护了以下对象

  1. _owner:初始值为null。当有线程获取到该对象锁(即获取到该对象的Monitor对象)时,owner变量便将值置为该线程的ID;当线程释放对象锁时,owner恢复为null。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全;
  2. cxq:竞争队列,所有请求对象锁的线程都会首先被放置于这个队列当中(单向)。__cxq 是一个临界资源,JVM通过CAS原子指令来修改cxq队列。新入节点采用头插法,置于队列头部,__cxq指针会指向该新入队的节点。
  3. _EntryList:__cxq队列中有资格成为候选资源的线程会被移动到该队列;该队列主要存放获取锁或者重新获取锁时被阻塞的线程(也存放竞争锁失败的线程);
  4. _WaitSet:调用wait()阻塞的线程会被移动至该等待队列中,等待线程组成双向循环链表,WaitSet所指向的是第一个节点;
  5. _recursions:synchronized是一个可重入锁,该变量用于记录锁重入(获取)的次数;

轻量级锁升级为重量级锁

升级为重量级锁的条件

  1. 轻量级锁状态下,自旋超出阈值(默认10次)
  2. 从无锁/偏向锁直接升级为重量级锁的条件:调用了object.wait()方法,则会直接升级为重量级锁!

升级过程

当触发轻量级锁升级为重量级锁时,将对象头中的mark-word复制到objectMonitor的对象头中,并将owner属性设置为当前持有锁的线程的id,其他线程便进入阻塞队列中进行阻塞等待,并进入BLOCKED状态;

重量级锁加解锁过程

1、当线程第一次调用monitorEntry执行且是重量级锁的情况下,会先进入cxq队列

2、当涉及锁的频繁竞争且需要阻塞时,需要进入entryList队列中。

3、如果线程能CAS竞争到onwer指针,就说明占有同步代码块成功, 如果CAS竞争不到,则block阻塞。

4、monitorExit退出时,会让entryList中block阻塞的线程唤醒重新竞争

5、如果调用了object.wait()方法, onwer线程会进入等待队列(注意,因为竞争失败的线程,不会进入waitSet,waitSet只服务于那些wait()方法引发的线程)

6、当调用的object.notify()或者notifyAll, 等待队列中的线程会根据qmod模式的不同,进入cxq或者进入entryList。

简要版流程如下:


本站总访问量:次本站总访问数:人
本站由 Peng Tong 使用 Stellar 1.29.1 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。