Java的Synchronized锁-重量级锁

1,461 阅读6分钟

这是我参与8月更文挑战的第11天,活动详情查看:8月更文挑战

轻量级锁适用于临界区的代码执行时间很短的情况,如果临界区的代码执行的时间很长,那么势必会造成其他争抢锁的线程会进行很长时间的CPU空转,这就与轻量级锁的设计背道而驰了,因此,如果临界区代码执行的时间如果很长的话,使用互斥锁将争抢锁的线程先挂起的性能要好于轻量级锁。

重量级锁的原理

在java虚拟机中,JVM会为每个对象都关联一个监视器Monitor对象,这个监视器对象随着对象的创建而创建,随着对象的销毁而销毁,这个监视器对象就是用来保证在同一时刻,只有一个线程可以访问临界资源。

Monitor的主要作用

1. 同步

Monitor相当于是一个"许可证",任何线程想访问临界资源,都必须先获取到这个许可证,并在访问完临界资源后,归还这个"许可证"。

2. 协作

Monitor同时也提供了协作的机制,正在执行同步代码的线程可以释放掉自己获取到的"许可证"并进行休眠,其他线程可以再次获取这个"许可证"并唤醒处于休眠的线程,进而达到线程协作的目的。

Monitor对象在HotSpot中的结构

通过openjdk中的ObjectMonitor.hpp文件可以看到Monitor在虚拟机中的结构如下:

ObjectMonitor::ObjectMonitor() {
    _header = NULL;
    _count = 0;
    _waiters = 0;
    
    // 线程的重入次数
    _recursions = 0;
    _object = NULL;
    
    // 标识拥有该monitor的线程
    _owner = NULL;
    
    // 等待线程组成的双向循环链表
    _WaitSet = NULL;
    _WaitSetLock = 0;
    _Responsible = NULL;
    _succ = NULL;
    
    // 多线程竞争锁进入时的单向链表
    cxq = NULL;
    FreeNext = NULL;
    
    // _owner从该双向循环链表中唤醒线程节点
    _EntryList = NULL;
    _SpinFreq = 0;
    _SPinClock = 0;
    OwnerIsThread = 0;
}

核心属性介绍

  1. owner 持有锁的线程
  2. recursions 线程的重入次数
  3. waitSet 调用wait方法后线程被放入的队列
  4. cxq 请求锁的线程首先被放入的队列
  5. EntryList cxq队列中有资格能获取到锁的线程被移动到的队列

线程抢占锁流程

image.png

  1. Cxq 竞争队列

    每次新加入 Node 会在 Cxq 的队头进行,通过 CAS 改 变第一个节点的指针为新增节点,同时设置新增节点的 next 指向后续节点;从 Cxq 取得元素时,会从队尾获取。因为只有 Owner 线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了 CAS 的 ABA 问题。线程进入 Cxq 前,抢锁线程会先尝试通过 CAS 自旋获取锁,如果获取不到,才就进入 Cxq 队列,这明显对于已经进入 Cxq 队列的线程是不公平的。所以,synchronized 同步块所使用的重量级锁是不公平锁。

  2. EntryList

    EntryList 与 Cxq 在逻辑上都属于等待队列。Cxq 会被线程并发访问,为了降低对 Cxq 队尾的 争用,而建立 EntryList。在 Owner 线程释放锁时,JVM 会从 Cxq 中迁移线程到 EntryList,并会 指定 EntryList 中的某个线程(一般为 Head)为 OnDeck Thread(Ready Thread)。EntryList 中的线程,作为候选竞争线程而存在。

  3. OnDeck Thread 与 Owner Thread

    JVM 不直接把锁传递给 Owner Thread,而是把锁竞争的权利交给 OnDeck Thread,OnDeck 需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在 JVM 中,也把这种选择行为称之为“竞争切换”。在 OnDeck Thread 成为 Owner 的过程中,还有一个不公平的事情,就是后来的新抢锁线程可能直接通过 CAS 自旋成为 Owner 而抢到锁

  4. WaitSet

    Owner 线程被 Object.wait()方法阻塞,则转移到 WaitSet 队列中,直到某个时刻通过 Object.notify()或者 Object.notifyAll()唤醒,在线程会重新进去 EntryList 中。

重量级锁演示

@Test
public void test2() throws InterruptedException {
    TimeUnit.SECONDS.sleep(5);
    Foo foo = new Foo();
    System.err.println(ClassLayout.parseInstance(foo).toPrintable());
    new Thread(new LockTest(foo)).start();
    new Thread(new LockTest(foo)).start();
    TimeUnit.SECONDS.sleep(2);
    System.err.println(ClassLayout.parseInstance(foo).toPrintable());
}

打印结果:

org.ywb.Foo object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000005 (biasable; age: 0)
  8   4        (object header: class)    0xf8011d21
 12   4    int Foo.i                     0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

Thread-1-org.ywb.Foo object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x00007fca4b80d3da (fat lock: 0x00007fca4b80d3da)
  8   4        (object header: class)    0xf8011d21
 12   4    int Foo.i                     1
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

Thread-2-org.ywb.Foo object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x00007fca4b80d3da (fat lock: 0x00007fca4b80d3da)
  8   4        (object header: class)    0xf8011d21
 12   4    int Foo.i                     2
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

org.ywb.Foo object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0xf8011d21
 12   4    int Foo.i                     2
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

解释:

  1. 第一次打印,此时对象处于偏向锁状态,但是并没有偏向某个线程,所以偏向线程id和偏向时间戳都是空的

因为两个线程同时启动,同时去争取锁,导致锁膨胀成为了重量级锁

  1. 第二次打印,线程名称为Thread-1的线程获取到了重量级锁,对变量进行了加1操作
  2. 第三次打印,线程名称为Thread-2的线程获取到了线程Thread-1的锁,对变量进行了加1操作
  3. 第四次打印,因为这时两个线程都释放了重量级锁,所以此时对象处于无锁状态。

重量级锁的系统开销

处于 ContentionListEntryListWaitSet 中的线程都处于阻塞状态,线程的阻塞或者唤醒都 需要操作系统来帮忙,Linux 内核下采用 pthread_mutex_lock 系统调用实现的,进程需要从用户态 切换到内核态pthread_mutex_lock 系统调用,是内核态为 用户态进程提供的 Linux 内核态下互斥锁(Mutex)的访问机制,所以使用 pthread_mutex_lock 系统调用时,进程需要从用户态切换到内核态,而这种切换是需要消耗很多时间的,有可能比用户执行代码的时间还要长。