Monitor对象全解析

CAS+三大队列(Cxq、EntryList、Waitet)

Posted by Peakiz on November 12, 2021 Views:

0 什么是Monitor?

Monitor是一个同步工具,或者说是一种同步机制;

Monitor的特点:

  • 互斥
  • 提供singnal机制(解决线程间的通信)

可以看到,Monitor的特点刚好对应于锁所要解决的两个问题。事实上Monitor就是一种锁的实现方式。 Monitor的底层实现依赖于操作系统的Mutex Lock。

java中的Monitor:java中的每个对象会关联一个Monitor对象,这个Monitor对象实现了Monitor机制,通常作为管程来控制同步代码的实现。Monitor是JVM内置的重量级锁机制(synchronized重量级锁实现)。

1 Moniotr在JVM中的实现

在JVM中Monitor是线程私有的数据结构,每个线程都有一个Monitor列表,同时还有一个全局的Monitor列表;同时在java中一切皆对象,Monitor也不列外,其被封装成为了Monitor对象(ObjectMonitor)与对应的普通对象对象头中的MarkWord相关联;

ObjectMonitor底层由C++实现,在OpenJDK对应的目录可以查看到其源码(地址:jdk8:runtime)

image-20221103095220551

image-20221103095545775

ObjectMonitor类有几个关键的属性:

  • _owner:指向持有ObjectMonitor对象的线程,同一时刻只能指向一个线程
  • _cxq: 多个线程争抢锁,会先存入这个单向链表
  • _EntryList:存放处于等待锁block状态的线程队列
  • _WaitSet:存放处于wait状态的线程队列,即调用wait()方法的线程
  • _recursions: 记录重入次数
  • _count:约为 _WaitSet 和 _EntryList 的节点数之和

Monitor机制的具体实现

image-20221103170804458

Monitor可以类比为一个特殊的房间,这个房间中有一些被保护的数据,Monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间即为持有Monitor,退出房间即为释放Monitor。

当一个线程需要访问同步代码块的时候,会先通过锁定对象LockObject的对象头中的Markword找到对应的Monitor对象,然后需要获取Monitor对象的所有权;它会首先在 _EntryList 入口队列中排队(这里并不是真正的按照排队顺序),如果没有其他线程正在持有对象的 Monitor,那么它会和 _EntryList 队列中的其他线程进行竞争(即通过CPU调度),选出一个线程来获取对象的 Monitor,执行同步代码块,执行完毕后释放Monitor;如果已经有线程持有对象的 Monitor,那么需要等待其释放 Monitor 后再进行竞争。

当一个线程拥有Monitor后,如果调用Object(指的是LockObkect)的 wait() 方法,线程就释放了Monitor,进入 _WaitSet 队列,等待Object的 notify() 方法。当该对象调用了 notify() 方法或者 notifyAll() 方法后, _WaitSet 中的线程就会被唤醒,放进 _EntryList 队列进行新一轮竞争。

以上只是简单示意。事实上,锁的竞争要比上面描述的更为复杂,除了_EntryList 这个双向链表用来保存竞争的线程,ObjectMonitor中还有另外一个单向链表 _cxq,由两个队列来共同管理并发的线程。

源码详细实现

查看源码可以知道,ObjectMonitor::enter() 和 ObjectMonitor::exit() 分别是ObjectMonitor获取锁和释放锁的方法。整个monitor机制就是操控 _cxq, _EntryList, _Waitet这三大队列实现的。

  • _cxq队列是一个以 _cxq为头节点的队列,单向链表;当一个线程尝试 CAS 获取monitor锁失败后,最终会被封装成一个 ObjectWaiter 对象,并放入 _cxq队列中的头节点的下一位,即会插入到 _cxq 节点的后边(这里会并发竞争,是通过CAS实现指针更改的);因此synchronized是非公平锁,后来的节点反而被插入到头部; 从 Cxq 取得元素时,则会从队尾获取(只有 Owner 线程才能从队尾取元素,也即线程出列操作无争用);
  • _EntryList 与 Cxq 在逻辑上都属于等待队列。Cxq 会被线程并发访问,为了降低对 Cxq 队尾的 争用,而建立 EntryList(正是因为EntryList的存在,cxq才没有出队并发竞争)。在 Owner 线程释放锁时,JVM 会从 Cxq 中迁移线程到 EntryList,并会 指定 EntryList 中的某个线程(一般为 Head)为 OnDeck Thread(Ready Thread)。EntryList 中的线程,作为候选竞争线程而存在。
  • WaitSet是一个环形双向队列,Owner 线程被 Object.wait()方法阻塞,则转移到 WaitSet 队列中,直到某个时刻通过 Object.notify()或者 Object.notifyAll()唤醒,在线程会重新进去 EntryList或 cxq 中。通常只有monitor owner能访问(wait进队,notify出队)。
  • notify的作用:notify()方法其实就是移动waitset中的线程要么到cxq要么到entrylist中,当同步方法结束的时候会触发唤醒机制,根据Qmode不同类型进行不同的规则唤醒。因此notify方法其实并不负责唤醒线程;notify会根据Policy 的不同作出的操作:
    • Policy== 0 :放入到entrylist队列的排头位置
    • Policy== 1 :放入到entrylist队列的末尾位置
    • Policy== 2 :判断entrylist是否为空,为空就放入entrylist中,否则放入cxq队列排头位置(默认 策略)
    • Policy==3 :判断cxq是否为空,如果为空,直接放入头部,否则放入cxq队列末尾位置

对于enter():竞争锁

image-20221104003708557

对于exit():唤醒等待竞争的线程,每次同步线程退出时自动唤醒

  • 根据QMode策略进行唤醒:
    • QMode=2,取cxq头部节点直接唤醒
    • QMode=3,如果cxq非空,把cxq队列放置到entrylist的尾部(顺序跟cxq一致)
    • QMode=4,如果cxq非空,把cxq队列放置到entrylist的头部(顺序跟cxq相反)
    • QMode=0,啥都不做,继续往下走(QMode默认是0)默认是0 Qmode=0的判断逻辑就是先判断entrylist是否为空,如果不为空,则取出第一个唤醒,如 果为空再从cxq里面获取第一个唤醒

image-20221104004236309

2 验证notify()的实际作用

用两个线程验证一下wait()notify() 对线程状态的影响;

值得注意的是notify()只会转移线程到Cxq或者EntryList中,并不会立即唤醒执行;只有当前线程执行完毕,退出执行exit()的时候才会唤醒EntryList的首节点线程(如果EntryList不为空的话,默认);

进入WaitSet()的线程,状态为:WAITING;进入cxq/entryList的线程,状态为:BLOCKED;进入sleep()的线程,状态为:TIMED_WAITING;

详细可见代码及注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class Main {
    public static void main(String[] args) throws InterruptedException {

        byte[] lock = new byte[0];
        Runnable task1 = () -> { //开启后休眠3s并调用wait() 然后打印 finish
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + ": begin");
                try {
                    Thread.sleep(3000);
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + ": finish");
            }
        };
        Runnable task2 = () -> {//开启后休眠3s 调用notify() 然后 打印 finish
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + ": begin");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock.notify();
                System.out.println(Thread.currentThread().getName() + ": finish");
            }
        };
        Thread t1 = new Thread(task1, "t1");
        Thread t2 = new Thread(task2, "t2");
        
        t1.start(); //休眠3s后进入WaitSet 并释放掉monitor (此时没有打印finish)
        Thread.sleep(1500);
        
        t2.start(); //此时t1持有monitor在睡眠,因此t2会进入到_cxq队列的队首
        System.out.println("t1: " + t1.getState());  //此时t1处于 TIMED_WAITING 状态(进入了sleep())
        System.out.println("t2: " + t2.getState());  //此时t2处于 BLOCKED状态(进入了cxq队列)
        
        Thread.sleep(2000); //3s结束 t1调用wait() 释放了锁 进入WaitSet队列
        
        System.out.println("t1: " + t1.getState());  //此时t1处于 Waiting状态(进入了WaitSet队列)
        //t2调用的notify会把t1从WaitSet中转移到entrylist中(默认 策略2),
        //然后t2会执行完毕打印finish,退出释放锁的时候执行exit()唤醒entrylist的第一个线程t1继续执行打印finish
        //因此最终t2一定比t1先执行完毕;
        
        // 执行结果:
//        t1: begin
//        t1: TIMED_WAITING
//        t2: BLOCKED
//        t2: begin
//        t1: WAITING
//        t2: finish
//        t1: finish
    }
}

3 总结

Monitor机制是java内置锁的实现方式。具体是通过三个队列Cxq+EntryList+WaitSet结合CAS机制来实现的;

线程在三个队列中的转移是通过wait()notify()方法来实现的;大致转移流向图如下图所示(线程在队列中的实际顺序可能根据采用的唤醒模式及notify()策略有关):

image-20221105233107682

参考资料