【Synchronized】Java 内置锁的使用及原理、锁升级详解

1  前言

这节我们主要讲解下我们平时使用的 Synchronized,它的使用大家应该并不陌生。我们知道 Java 中的锁分为内置锁和显示锁,Synchronized 就是我们的内置锁,内置可以理解为我们只需要标记即可,加锁释放啥的JVM都帮我们做了,那么我们这节就好好来看看这个关键字。

另外我们的版本是:1.8 哈

2  Synchronized 的使用

我们知道锁对象可以是任意的对象,那么关于 Synchronized 的使用大概分三种情况吧:

  • 作用于非静态方法:也就是没有 static 修饰的方法,这时我们的锁对象就是当前执行该方法的对象;
  • 作用于静态方法:就是 static 修饰的方法,这时我们的锁对象是我们的当前类的 Class 对象;
  • 同步代码块:锁对象就是代码块锁的对象;
public class TestSynchronized {
    private Object obj = new Object();
    // 修饰非 static 方法,锁对象为当前对象即this
    public synchronized void test() {
    }
    // 修饰 static 方法,锁对象为当前类 Class
    public synchronized static void say() {
    }
    // 静态代码块,锁对象为 obj
    public void add(int num) {
        synchronized (obj) {
        }
    }
}

3  锁的分类

上节我们在看对象结构的时候,我们看到了对象头中的 Mark Word 中记录着我们锁的信息,我们来回顾下:

其实分类就是为了提效,也算是一种分治思想吧,跟我们平时写业务代码类似,划分场景提高并发甚至每个场景也涉及到切换跟我们这里的锁升级、撤销差不多,那我们就来详细看下每种锁。

3.1  无锁

无锁也可以理解为一种初始状态,就是当我们对象创建出来,我们对象头中的锁信息。那么关于我们创建对象出来,里边的初始所信息又分两种情况:

  • 默认不更改任何 JVM 配置的情况下:前4秒内,创建出来的为无锁(001),4秒钟以后的为偏向锁(101)也可以称为匿名偏向,因为它是偏向锁只是线程 id 都是0;
  • 修改配置-XX:-UseBiasedLocking:关闭偏向锁,那么创建出来的初始锁都是无锁(001),当第一个线程获取锁的时候,会直接变为轻量级锁;
  • 修改配置 -XX:BiasedLockingStartupDelay:修改延迟时间,那么时间内的对象为无锁(001),时间后的为偏向锁(101);

4秒的来源是因为 JVM 启用偏向锁有 4秒 的延迟启动机制,这个可以通过 -XX:BiasedLockingStartupDelay 进行更改,比如 -XX:BiasedLockingStartupDelay=0 即延迟0秒。

我们来运行下看看是不是:

1.8版本哈(1.6以后偏向锁默认开启),我们在不更改任何配置的情况下:

当我们更改:-XX:BiasedLockingStartupDelay=0 即将偏向锁的启用延迟更改为 0 时:

当我们 -XX:-UseBiasedLocking 即关闭偏向锁,这个就不贴图了哈,创建出来的初始就为无锁(001)。

这里解释下为什么 JVM 启动偏向锁要有 4秒延迟,可能因为启动的时候都是自己的东西,哪些地方竞争怎么启动的快,人家自己都知道会不会用到偏向锁,即JVM 本身可能很多地方都用到了 Synchronized,并且竞争明显所以省略无锁到偏向锁到轻量级锁的过程,直接无锁到轻量级锁的优化,减少转换提高速度嘛,我们试试看,当我们关闭了偏向锁,第一次申请锁的时候,锁信息就是轻量级锁了:

HotSpot为所有加载的类型,在class元数据——InstanceKlass中保留了一个MarkWord原型——mark_prototype。这个值的bias位域决定了该类型的对象是否允许被偏向锁定。与此同时,当前的epoch位也被保留在prototype中即prototype中MarkWord会将它们的bias位设置为0,以禁止实例化的对象被偏向。4秒钟之后,所有的prototype MarkWord的bias位会被重设为1,如此新的对象就可以被偏向锁定了。

那么我们这里总结下无锁的情况:

这里简单说下就是匿名偏向状态下,如果调用系统的 hashCode 方法,会使对象回到无锁态,并在 markword 中写入 hashCode 。并且在这个状态下,如果有线程尝试获取锁,会直接从无锁升级到轻量级锁,不会再升级为偏向锁。

3.2  偏向锁

接下来我们看看偏向锁,我们上面已经看到了匿名偏向算是偏向锁的一种初始状态,正常情况下,4秒后,我们创建出来的对象头中锁信息就是匿名偏向锁的,只是线程ID 为0,在这个状态下第一个试图获取该对象的锁的线程,会使用CAS操作(汇编命令 CMPXCHG )尝试将自己的 threadID 写入对象头的 mark word,使匿名偏向转变为偏向锁,这时它的线程ID不为空了,并且偏向锁的时间戳 epoch 为有效值。

那么我们这里先看下偏向锁的状态划分:

  • 匿名偏向:也就是4秒后创建的对象的锁初始状态,特征101,线程ID为空。
  • 可重偏向:在此状态下,偏向锁的epoch字段是无效的(与锁对象对应的klass的mark_prototype的epoch值不匹配)。下一个试图获取锁对象的线程将会面临这个情况,使用原子CAS指令可将该锁对象绑定于当前线程。在批量重偏向的操作中,未被持有的锁对象都被至于这个状态,以便允许被快速重偏向。
  • 已偏向:这种状态下,线程ID非空,且epoch为有效值——意味着其他线程正在只有这个锁对象。

首先说一下获取偏向锁的一个步骤:

  1. 先校验对象自己,验证对象的bias位 (如果是0,则该对象不可偏向,应该使用轻量级锁算法)
  2. 再校验类里的原信息,验证对象所属InstanceKlass的prototype的bias位(确认prototype的bias为是否被设置。如果没有设置,则该类所有对象全部不允许被偏向锁定;并且该类所有对象的bias位都需要被重置,使用轻量级锁替换)
  3. 校验epoch位(校验对象的MarkWord的epoch位是否与该对象所属InstanceKlass的prototype的MarkWord的epoch匹配。如果不匹配,则表明偏向已过期,需要重新偏向。这种情况,偏向线程可以简单地使用原子CAS指令重新偏向于这个锁对象)
  4. 校验所属线程(比较偏向线程ID与当前线程ID,如果匹配,则表明当前线程已经获得了偏向,可以安全返回。如果不匹配,对象锁被假定为匿名偏向状态,当前线程应该尝试使用CAS指令获得偏向。如果失败的话,就尝试撤销(很可能引入安全点),然后回退到轻量级锁;如果成功,当前线程成功获得偏向,可直接返回)

这里解释一下可重偏向状态以及epoch的由来,为什么要有这个东西?就是当对于存在明显多线程竞争的场景下使用偏向锁是不合适的,就会涉及到偏向锁的撤销,相当于增加了复杂度,为了优化这个,jvm 以类为单位的做法,其内部为每个类维护一个偏向锁计数器,对其对象进行偏向锁的撤销操作进行计数。当这个值达到指定阈值的时候,jvm就认为这个类的偏向锁有问题,需要进行重偏向 rebias,对所有属于这个类的对象进行重偏向的操作叫批量重偏向 bulk rebias,之前的做法是对heap进行遍历,后来引入epoch。当需要bulk rebias时,对这个类的epcho值加1,以后分配这个类的对象的时候mark字段里就是这个epoch值了,同时还要对当前已经获得偏向锁的对象的epoch值加1,这些锁数据记录在方法栈里。如果这个类的revoke计数器继续增加到一个阈值,那个jvm就认为这个类不适合偏向锁了,就要进行bulk revoke。于是多了一个判断条件,要查看所属类的字段,看看是否允许对这个类使用偏向锁。

在已偏向状态下,线程指针 threadID 非空,且偏向锁的时间戳 epoch 为有效值。如果之后有线程再次尝试获取锁时,需要检查 mark word 中存储的 threadID 是否与自己相同即可,如果相同那么表示当前线程已经获得了对象的锁,不需要再使用CAS操作来进行加锁。

如果 mark word 中存储的 threadID 与当前线程不同,那么将执行CAS操作,试图将当前线程的ID替换 mark word 中的 threadID 。只有当对象处于下面两种状态中时,才可以执行成功:
  • 对象处于匿名偏向状态
  • 对象处于 可重偏向(Rebiasable)状态,新线程可使用CAS将 threadID 指向自己

如果对象不处于上面两个状态,说明锁存在线程竞争,在CAS替换失败后会执行 偏向锁撤销操作。偏向锁的撤销需要等待全局安全点 Safe Point (安全点是 jvm为了保证在垃圾回收的过程中引用关系不会发生变化设置的安全状态,在这个状态上会暂停所有线程工作),在这个安全点会挂起获得偏向锁的线程。

在暂停线程后,会通过遍历当前jvm的所有线程的方式,检查持有偏向锁的线程状态是否存活:

  • 如果线程还存活,且线程正在执行同步代码块中的代码,则升级为轻量级锁;
  • 如果持有偏向锁的线程未存活,或者持有偏向锁的线程未在执行同步代码块中的代码,则进行校验是否允许重偏向:
    • 不允许重偏向,则撤销偏向锁,将 mark word 升级为轻量级锁,进行CAS竞争锁

    • 允许重偏向,设置为匿名偏向锁状态,CAS将偏向锁重新指向新线程

完成上面的操作后,唤醒暂停的线程,从安全点继续执行代码。可以使用流程图总结上面的过程:

3.2  偏向锁的升级

偏向锁说了些理论我们写点代码感受下:

3.2.1  偏向锁升级为轻量锁

 public static void main(String[] args) throws InterruptedException {
     Object obj = new Object();
     // 第一次申请锁,锁是匿名偏向执行完变为已偏向
     synchronized (obj) {
         System.out.println("one" + ClassLayout.parseInstance(obj).toPrintable());
     }
     Thread thread = new Thread(() -> {
         // 第二次申请锁,已偏向状态且不是偏向的线程,偏向锁撤销升级为轻量锁
         synchronized (obj) {
             System.out.println("two" + ClassLayout.parseInstance(obj).toPrintable());
         }
     });
     thread.start();
     thread.join();
     // 在执行完成同步代码后释放锁,变为无锁不可偏向
     System.out.println("end" + ClassLayout.parseInstance(obj).toPrintable());
 }

3.2.2  偏向锁升级为重量级锁

public static void main(String[] args) throws InterruptedException {
    Object obj = new Object();
    synchronized (obj) {
        System.out.println("one" + ClassLayout.parseInstance(obj).toPrintable());
        // 调用 wait 就会升级为重量级锁
        obj.wait(2000);
        System.out.println("two" + ClassLayout.parseInstance(obj).toPrintable());
    }
    System.out.println("end" + ClassLayout.parseInstance(obj).toPrintable());
}

这里是因为 wait 方法调用过程中依赖于重量级锁中与对象关联的 monitor ,在调用 wait 方法后 monitor 会把线程变为 WAITING 状态,所以才会强制升级为重量级锁。除此之外,调用 hashCode 方法时也会使偏向锁直接升级为重量级锁。

在上面分析的基础上,再加上我们上一篇中讲到的轻量级锁升级到重量级锁的知识,就可以对上面的流程图进行完善了:

3.3  批量重偏向、批量撤销

在未禁用偏向锁的情况下,当一个线程建立了大量对象,并且对它们执行完同步操作解锁后,所有对象处于偏向锁状态,此时若再来另一个线程也尝试获取这些对象的锁,就会导偏向锁的 批量重偏向(Bulk Rebias)。当触发批量重偏向后,第一个线程结束同步操作后的锁对象当再被同步访问时会被重置为可重偏向状态,以便允许快速重偏向,这样能够减少撤销偏向锁再升级为轻量级锁的性能消耗。

首先看一下和偏向锁有关的参数,修改jvm启动参数,使用下面的命令可以在项目启动时打印jvm的默认参数值:

  • BiasedLockingBulkRebiasThreshold :偏向锁批量重偏向阈值,默认为20次
  • BiasedLockingBulkRevokeThreshold :偏向锁批量撤销阈值,默认为40次
  • BiasedLockingDecayTime :重置计数的延迟时间,默认值为25000毫秒(即25秒)

批量重偏向是以 class 而不是对象为单位的,每个 class 会维护一个偏向锁的撤销计数器,每当该 class 的对象发生偏向锁的撤销时,该计数器会加一,当这个值达到默认阈值20时,jvm就会认为这个锁对象不再适合原线程,因此进行批量重偏向。而距离上次批量重偏向的25秒内,如果撤销计数达到40,就会发生批量撤销,如果超过25秒,那么就会重置在[20, 40)内的计数。

偏向锁通过消除资源无竞争情况下的同步原语,提高了程序在单线程下访问同步资源的运行性能,但是当出现多个线程竞争时,就会撤销偏向锁、升级为轻量级锁。

如果我们的应用系统是高并发、并且代码中同步资源一直是被多线程访问的,那么撤销偏向锁这一步就显得多余,偏向锁撤销时进入Safe Point产生STW的现象应该是被极力避免的,这时应该通过禁用偏向锁来减少性能上的损耗。

4  轻量级锁

4.1  轻量级锁原理

在代码访问同步资源时,如果锁对象处于无锁不可偏向状态,jvm首先将在当前线程的栈帧中创建一条锁记录(lock record),用于存放:

  • displaced mark word(置换标记字):存放锁对象当前的mark word的拷贝
  • owner指针:指向当前的锁对象的指针,在拷贝mark word阶段暂时不会处理它

在拷贝mark word完成后,首先会挂起线程,jvm使用CAS操作尝试将对象的 mark word 中的 lock record 指针指向栈帧中的锁记录,并将锁记录中的owner指针指向锁对象的mark word

  • 如果CAS替换成功,表示竞争锁对象成功,则将锁标志位设置成 00,表示对象处于轻量级锁状态,执行同步代码中的操作

  • 如果CAS替换失败,则判断当前对象的mark word是否指向当前线程的栈帧:
    • 如果是则表示当前线程已经持有对象的锁,执行的是synchronized的锁重入过程,可以直接执行同步代码块
    • 否则说明该其他线程已经持有了该对象的锁,如果在自旋一定次数后仍未获得锁,那么轻量级锁需要升级为重量级锁,将锁标志位变成10,后面等待的线程将会进入阻塞状态

轻量级锁的释放同样使用了CAS操作,尝试将displaced mark word 替换回mark word,这时需要检查锁对象的mark wordlock record指针是否指向当前线程的锁记录:

  • 如果替换成功,则表示没有竞争发生,整个同步过程就完成了
  • 如果替换失败,则表示当前锁资源存在竞争,有可能其他线程在这段时间里尝试过获取锁失败,导致自身被挂起,并修改了锁对象的mark word升级为重量级锁,最后在执行重量级锁的解锁流程后唤醒被挂起的线程

用流程图对上面的过程进行描述:

 

4.2  轻量级锁重入

我们知道,synchronized是可以锁重入的,在轻量级锁的情况下重入也是依赖于栈上的lock record完成的。以下面的代码中3次锁重入为例:

synchronized (user){
    synchronized (user){
        synchronized (user){
            //TODO
        }
    }
}
  • 首次分配的lock recorddisplaced mark word复制了锁对象的mark wordowner指针指向锁对象
  • 之后重入时在栈中分配的lock record中的displaced mark wordnull,只存储了指向对象的owner指针 

轻量级锁中,重入的次数等于该锁对象在栈帧中lock record的数量,这个数量隐式地充当了锁重入机制的计数器。这里需要计数的原因是每次解锁都需要对应一次加锁,只有最后解锁次数等于加锁次数时,锁对象才会被真正释放。在释放锁的过程中,如果是重入则删除栈中的lock record,直到没有重入时则使用CAS替换锁对象的mark word

4.3  轻量级锁升级

在jdk1.6以前,默认轻量级锁自旋次数是10次,如果超过这个次数或自旋线程数超过CPU核数的一半,就会升级为重量级锁。这时因为如果自旋次数过多,或过多线程进入自旋,会导致消耗过多cpu资源,重量级锁情况下线程进入等待队列可以降低cpu资源的消耗。自旋次数的值也可以通过jvm参数进行修改:
-XX:PreBlockSpin

jdk1.6以后加入了自适应自旋锁 (Adapative Self Spinning),自旋的次数不再固定,由jvm自己控制,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:

  • 对于某个锁对象,如果自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而允许自旋等待持续相对更长时间
  • 对于某个锁对象,如果自旋很少成功获得过锁,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

4.4  轻量级锁小结

轻量级锁与偏向锁类似,都是jdk对于多线程的优化,不同的是轻量级锁是通过CAS来避免开销较大的互斥操作,而偏向锁是在无资源竞争的情况下完全消除同步。

轻量级锁的“轻量”是相对于重量级锁而言的,它的性能会稍好一些。轻量级锁尝试利用CAS,在升级为重量级锁之前进行补救,目的是为了减少多线程进入互斥,当多个线程交替执行同步块时,jvm使用轻量级锁来保证同步,避免线程切换的开销,不会造成用户态与内核态的切换。但是如果过度自旋,会引起cpu资源的浪费,这种情况下轻量级锁消耗的资源可能反而会更多。

5  重量级锁

5.1  Monitor

重量级锁是依赖对象内部的monitor(监视器/管程)来实现的 ,而monitor 又依赖于操作系统底层的Mutex Lock(互斥锁)实现,这也就是为什么说重量级锁比较“重”的原因了,操作系统在实现线程之间的切换时,需要从用户态切换到内核态,成本非常高。在学习重量级锁的工作原理前,首先需要了解一下monitor中的核心概念:
  • owner:标识拥有该monitor的线程,初始时和锁被释放后都为null
  • cxq (ConnectionList):竞争队列,所有竞争锁的线程都会首先被放入这个队列中
  • EntryList:候选者列表,当owner解锁时会将cxq队列中的线程移动到该队列中
  • OnDeck:在将线程从cxq移动到EntryList时,会指定某个线程为Ready状态(即OnDeck),表明它可以竞争锁,如果竞争成功那么称为owner线程,如果失败则放回EntryList
  • WaitSet:因为调用wait()wait(time)方法而被阻塞的线程会被放在该队列中
  • count:monitor的计数器,数值加1表示当前对象的锁被一个线程获取,线程释放monitor对象时减1
  • recursions:线程重入次数
用图来表示线程竞争的的过程:
当线程调用wait()方法,将释放当前持有的monitor,将owner置为null,进入WaitSet集合中等待被唤醒。当有线程调用notify()notifyAll()方法时,也会释放持有的monitor,并唤醒WaitSet的线程重新参与monitor的竞争。

5.2  重量级锁原理

当升级为重量级锁的情况下,锁对象的mark word中的指针不再指向线程栈中的lock record,而是指向堆中与锁对象关联的monitor对象。当多个线程同时访问同步代码时,这些线程会先尝试获取当前锁对象对应的monitor的所有权:
  • 获取成功,判断当前线程是不是重入,如果是重入那么recursions+1
  • 获取失败,当前线程会被阻塞,等待其他线程解锁后被唤醒,再次竞争锁对象

在重量级锁的情况下,加解锁的过程涉及到操作系统的Mutex Lock进行互斥操作,线程间的调度和线程的状态变更过程需要在用户态和核心态之间进行切换,会导致消耗大量的cpu资源,导致性能降低。

6  小结

在jdk1.6中,引入了偏向锁和轻量级锁,并使用锁升级机制对synchronized进行了充分的优化。其实除锁升级外,还使用了锁消除、锁粗化等优化手段,所以对它的认识要脱离“重量级”这一概念,不要再单纯的认为它的性能差了。在某些场景下,synchronized的性能甚至已经超过了Lock同步锁。

尽管java对synchronized做了这些优化,但是在使用过程中,我们还是要尽量减少锁的竞争,通过减小加锁粒度和减少同步代码的执行时间,来降低锁竞争,尽量使锁维持在偏向锁和轻量级锁的级别,避免升级为重量级锁,造成性能的损耗

posted @ 2023-03-24 08:04  酷酷-  阅读(564)  评论(0编辑  收藏  举报