Synchronized

Synchronized,是 JDK 提供的内置同步锁关键字,早期它的实现原理相对来说性能稍弱,被称为重量级锁。JDK 1.6 之后 synchronized 进行了一些优化,减少锁获得与释放带来的性能消耗,引入了 偏向锁轻量级锁

重量级锁

synchronized 有三种使用方式,可以修饰方法、静态方法、代码块。


public synchronized void test() {
    //
}

public synchronized static void test2() {
    //
}
    
public void test3() {
    synchronized (new Object()) {
        //
    }
}

synchronized ,底层是利用 monitor 对象,CAS 和 mutex 互斥锁来实现的。

  • 修饰代码块,编译后的字节码文件会被加上 monitorentermonitorexit 指令
  • 修饰方法,锁对象就是当前类的实例,该方法会被打上标记 ACC_SYNCHRONIZED,标识是同步方法
  • 修饰静态方法,锁对象则是当前类 Class 对象

利用 javap 查看字节码文件,同步代码块中有两个 monitorexit 是因为需要考虑异常情况也要释放锁。

20210321123429.png

monitor 在 HotSpot 由 c++ 实现,类名 objectMonitor。
objectMonitor.hpp 中,monitor 结构如下 (1.8 版本):

  // initialize the monitor, exception the semaphore, all other fields
  // are simple integers or pointers
  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

objectMonitor 内部会有等待队列(cxq 和 Entrylist)和 条件等待队列 (waitSet)来存放相应的阻塞线程。
未竞争到锁的线程存储到等待队列中,获得锁的线程调用 wait 后存放到条件等待队列中,解锁会唤醒相应队列中的等待线程来竞争锁。

使用 _cxq 和 _EntryList 两个列表来放线程的原因是,多个线程同时竞争锁,先放到 _cxq 单链表基于 CAS 来 hold 住并发,根据策略每次唤醒的时候搬迁一些线程节点到 _EntryList 这个双向链表,降低 _cxq 的尾部竞争。

线程的阻塞和唤醒,需要调用操作系统进行上下文切换,开销比较大,所以称为重量级锁。

优化,锁升级

在上篇文章中,分析了对象的组成部分,其中对象头的 Mark Word 可以动态存储对象的一些状态信息,包括锁信息。
在对象处于不同状态时,Mark Word 前 61 位也会动态变化。

  • 偏向锁 是 54 bit 来保存持有锁线程的地址,2 bit 来保存 epoch 值。
  • 轻量级锁 则用 62 bit 来保存持有锁线程栈帧中 lockRecord 区的地址。
  • 重量级锁 则用 62 bit 保存了指向 monitor 对象的地址。

轻量级锁

在并发不大情况下,此时可能并不需要进行阻塞线程,减少系统开销,大部分情况是不同线程交替持有锁。当线程加锁时,首先将对象锁的 Mark Word 拷贝一份到当前线程栈帧中 lockRecord ,并通过 CAS 将对象锁 Mark Word 指向此 LockRecord。
如果成功则说明抢到锁,失败则说明竞争大,需要膨胀为重量级锁。

在升级重量级锁过程中,不会马上去申请锁,而是会先自适应自旋,看能否获取到锁,如果不能再去申请锁。
自适应自旋操作,也是一种优化手段。自旋,就是空转 CPU,不让出 CPU ,等待执行锁的释放。
在并发低的情况下,自旋几次,大概率就获取到锁了,避免进入阻塞再唤醒的操作。
而在并发高的情况下,自旋后仍然大概率获取不到锁,仍然要进入队列等待,因此自旋次数的设置不好掌握。所以有了自适应自旋,根据上次自旋的次数来动态调整自旋次数,结合历史经验来处理。

20210321150455.png

偏向锁

在没有并发的情况下,减少不必要的频繁 CAS 操作,线程将锁对象的 Mark Word 写入当前线程的 ID,并将偏向锁标志位置为 1。

如果再次有线程来加锁时,先看是否为偏向锁,如果是则比较 Mark Word 中的线程 ID 是否是当前线程 ID,如果是则获取锁成功。 换言之,这个锁偏向这个线程。

如果不是当前线程 ID,则撤销偏向锁,进行锁升级为轻量级锁。

锁升级过程

参考上篇文章,Java 对象头的组成。

20210320213602.png

20210320223659.jpg

升级过程如下:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
  • 1、当没有被加锁时,Mark Word 记录对象的 HashCode,是否偏向锁是 0,锁标志位是01。

  • 2、当对象被作为同步锁,并且线程 A 抢到锁时,锁标志位仍是 01,是否偏向锁标记为 1,前 54 位写入线程 A 的 id,此时进入偏向锁状态。

  • 3、当线程 A 再次尝试加锁时,判断偏向锁标志是 1,Mark Word 中记录的线程 id 就是当前线程 A,表示线程 A 已经获得了这个偏向锁。

  • 4、当线程 B 尝试获取锁时,判断偏向锁标志是 1,但是 Mark Word 中的线程 id 不是线程 B,则进行偏向锁撤销,进行锁升级。

  • 5、偏向锁状态获取失败,代表存在一定的竞争,偏向锁升级为轻量级锁。在当前线程的线程栈中开辟一块单独的空间 LockRecord,保存锁对象 Mark Word,同时在锁对象 Mark Word 中保存指向 LockRecord 地址。如果成功,则把 Mark Word 中的锁标志位改成 00。

  • 6、轻量级锁抢锁失败,进行锁膨胀,升级为重量级锁之前,会先进行自适应自旋,自旋成功则获取到锁。

  • 7、自旋后仍抢锁失败,则升级为重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会被阻塞。