【java】java wait 原理 synchronized ReentrantLock 唤醒顺序

x33g5p2x  于2022-03-21 转载在 Java  
字(11.3k)|赞(0)|评价(0)|浏览(248)

1.概述

转载:并发编程系列——wait原理的讨论(1) 学习一

网友交流
请问下,synchronized大并发的情况
下,等待区的线程entrySet和waitSet里的线程分别对
应Thread的哪个状态啊,都是wating吗?

entryList是blocking状态吧,waitSet是waiting状态

线程的生命周期,blocking和waiting不是同一种状态吗

总结如下:synchronized关键字的调用wait方法进入等到的线程和因为拿不到锁而等待线程是否同一种状态?blocking?waiting?

别小看这个问题,要扯清这个问题需要大篇幅的文字,所以再次长文警告;而且笔者可以很负责任的告诉读者如果你能看懂这篇文章绝对会燃起你对并发编程学习的兴趣

2.关于blocking状态的线程

2.1 synchronized关键字的blocking

在多线程编程的情况下,假设我们定义了一把锁,如果现在有10个线程来获取这把锁那么肯定只有第一个线程可以获取到锁,从而进入临界区(所谓临界区就是被锁保护起来的代码块);其他获取不到锁的线程都会被阻塞(关于阻塞你就可以理解为CPU放弃调度这个线程了),但是这些被阻塞的线程JVM是怎么处理的呢?先看一张图

上图t1获取锁,如果在t1没有释放的情况下其他线程也来获取锁,结果肯定是获取不到,从而进入阻塞状态,但是这些被阻塞的线程如果不存某种关系将来唤醒的时候就很麻烦(比如先唤醒谁呢?有人肯定会说那肯定先唤醒最先阻塞的那个线程啊,关键是JVM如何知道哪个线程最先阻塞的呢?)为了解决这个麻烦JVM设计了一个EntryList的双向链表的队列来维护这些阻塞的线程;如上图这样 t2到tn被维护到了这个队列,当t1释放锁之后会去这个队列当中唤醒一个线程来获取锁,这里请读者们思考一些问题;到底是唤醒一个,还是全部唤醒呢?如果唤醒一个是随机唤醒还是顺序唤醒,如果是顺序唤醒是正序还是倒序呢?笔者直接给出答案,当t1释放锁的时候会从EntryList当中唤醒一个线程,而且顺序唤醒,而且倒序的,也就是先唤醒tn这个线程;但是值得注意的synchronized关键字是倒序唤醒,但是如果你使用ReentrantLock那么则是正序唤醒;那么这个结论如何证明了----笔者将会通过三个角度来证明

1、通过一个简单java应用来证明

2、是通过JDK内部关于ReentrantLock锁的实现来证明;(因为ReentrantLock和synchronized关键字都是实现同步锁,他们都有这么一个队列,原理差不多,其实就算我能通过ReentrantLock来证明这个双向列表的队列真实存在也不能说明synchronized关键字也有这么一个队列啊,确实是这样,但是由于ReentrantLock的这个队列是java语言实现的,比较容易看懂,毕竟是母语啊,所以先看懂java语言级别的实现——synchronized关键字是没有java级别源码可看的,他是通过C代码来实现的,先看懂java的实现再来看C会轻松点)

3、如果有可能我会通过JVM源码中来证明这个队列的存在

首先来看一个简单的java应用,代码如下(先仔细阅读以下代码,下文我会对代码做解释,博客里面所有代码放到文末链接,读者可以自己下载);

package com.shadow.test;

import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Slf4j(topic = "shadow")
public class TestSynchronized {
    static List<Thread> list = new ArrayList<>();
    static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(() -> {
                synchronized (lock) {
                    log.debug("thread executed");
                    try {
                        //这里的睡眠没有什么意义,仅仅为了控制台打印的时候有个间隔 视觉效果好
                        TimeUnit.MILLISECONDS.sleep(200);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }, "t" + i);//给每个线程去了一个名字 t1 t2 t3 ....

            list.add(t);
        }

        log.debug("---启动顺序 调度顺序或者说获取锁的顺序讲道理是正序0--9----");
     
        synchronized (lock) {
            for (Thread thread : list) {
                //这个打印主要是为了看到线程启动的顺序
                log.debug("{}-启动顺序--正序0-9", thread.getName());
                thread.start();//  CPU 调度?
                
                //这个睡眠相当重要,如果没有这个睡眠会有很大问题
                //这里是因为线程的start仅仅是告诉CPU线程可以调度了,但是会不会立马调度是不确定的
                //如果这里不睡眠 就有有这种情况出现
                // 主线程执行t1.start--Cpu没有调度t1--继续执行主线程t2-start cpu调度t2--然后再调度t1
                //虽然我们的启动顺序是正序的(t1--t2),但是调度顺序是错乱的  t2---t1
                
                TimeUnit.MILLISECONDS.sleep(1);
             }
             log.debug("-------执行顺序--正序9-0");
        }
    }
}

代码非常简单主线程main启动,然后实例化了10个线程对象t0-t9;继而把这个10个线程添加到一个List当中(注意这里仅仅是实例化了十(10)个线程,并没有启动,如果将来启动这10个线程他们的run方法里面的代码也非常简单,就是获取lock这把锁,然后打印一句话);添加到数组之后main线程接着往下执行;mian线程获取锁(这里一定能获取成功,因为那10个线程还没启动,锁处于自由状态,所以能被main获取);获取到锁之后main线程执行了一个for循环从list当中依次顺序获取到上面存入到list当中的那10个线程(由于ArrayList是有序的)故而取出的顺序肯定是有序的(t0-t9);取出来之后依次调用star方法启动这些线程;但是这里需要注意的是虽然我们已经保证取出来的线程是顺序的(t0-t9),而且我们也保证了这些线程的start方法是顺序调用的,但是你依然没法保证这些线程的调度(也就是我们常说的执行)顺序;为了保证t0-t9的调度顺序我这里在线程start之后,让main线程sleep了1毫秒;这样就能保证t0-t9线程的调度或者说执行顺序;至于为什么要保证他们的调度顺序?

来解释一下为什么需要保证这个调度顺序呢?
             这里所有代码的意图就是顺序启动线程(顺序调度线程),这些线程启动之后会去拿锁(lock)
             肯定拿不到,因为这个时候锁被主线程持有
             主线程还在for循环没有释放锁,所以在for循环里面启动的线程都是拿不到锁的
             那么这些那不到锁的线程就会阻塞
             也就t0----t9阳塞之后他们被存到了一个队列当中
             这个JVM的源码中可以证明,我后面给大家看源码,
             总之你现在记住所有拿不到锁的线程会阻塞进入到Entrylist这个队列当中
             然后主线程执行完for循环后会释放放锁
             继而会去这个队列当中去唤醒一个个线程————随机唤醒还是顺序唤醒呢?
             假设是顺序唤醒,是倒序还是正序唤醒呢?
             需要证明这个问题,就要保证所有因为拿不到锁而进入到这个队列当中的线程
             他们的顺序必须是有序的,这样后面从他们的执行结果才能分析;
             假设你 进入到阻塞队列的时候都是随机的,那么后面唤醒线程执行的时候必然也是随机的
             那么则无法证明唤醒是否具备有序性
             为了保证进入到队列当中的线程调度是有序的,主线程睡眠很有必要  
             那么为什么主线程睡眠1下就能保证这些线程的顺序调度呢?这个问题读者可以思考一下后而我会重点分析
             好了现在我们来看结果

运行结果如下

20:50:28.452 [main] DEBUG shadow - ---启动顺序 调度顺序或者说获取锁的顺序讲道理是正序0--9----
20:50:28.458 [main] DEBUG shadow - t0-启动顺序--正序0-9
20:50:28.463 [main] DEBUG shadow - t1-启动顺序--正序0-9
20:50:28.465 [main] DEBUG shadow - t2-启动顺序--正序0-9
20:50:28.467 [main] DEBUG shadow - t3-启动顺序--正序0-9
20:50:28.469 [main] DEBUG shadow - t4-启动顺序--正序0-9
20:50:28.471 [main] DEBUG shadow - t5-启动顺序--正序0-9
20:50:28.473 [main] DEBUG shadow - t6-启动顺序--正序0-9
20:50:28.475 [main] DEBUG shadow - t7-启动顺序--正序0-9
20:50:28.477 [main] DEBUG shadow - t8-启动顺序--正序0-9
20:50:28.478 [main] DEBUG shadow - t9-启动顺序--正序0-9
20:50:28.480 [main] DEBUG shadow - -------执行顺序--正序9-0
20:50:28.480 [t9] DEBUG shadow - thread executed
20:50:28.683 [t8] DEBUG shadow - thread executed
20:50:28.888 [t7] DEBUG shadow - thread executed
20:50:29.090 [t6] DEBUG shadow - thread executed
20:50:29.295 [t5] DEBUG shadow - thread executed
20:50:29.496 [t4] DEBUG shadow - thread executed
20:50:29.701 [t3] DEBUG shadow - thread executed
20:50:29.902 [t2] DEBUG shadow - thread executed
20:50:30.105 [t1] DEBUG shadow - thread executed
20:50:30.308 [t0] DEBUG shadow - thread executed

从上图可以看出首先10个线程的启动顺序(由于主线程睡眠了1毫秒故而启动顺序其实等于调度顺序)是t0-t9;因为在启动线程的时候主线程没有释放锁,所以t0-t9都因为拿不到锁进入了队列(EntryList),又因为t0-t9的调度(启动)顺序保证了,所以进入队列的顺序也保证了(t0先进入队列,t9最后进入队列);但是在主线程释放锁的时候,唤醒线程的顺序是都倒序的,先唤醒t9,最后唤醒t0;这里的结果可以说明JVM在从队列当中唤醒的时候是唤醒一个,而不是全部唤醒,因为如果是全部唤醒,那么这些线程的执行顺序肯定是乱的,只有唤醒一个,而且还是顺序唤醒才能保证执行顺序是具备规则的(t9-t0),而且是倒序唤醒的;那么这队列存在哪里呢?在java语言里使用synchronized关键字如果变成了一把重量锁(关于什么是重量锁下次分析),那么这个锁对象(本文当中的lock对象——Object lock = new Object())会关联一个C++对象——ObjectMonitor对象;这个监视器对象当中记录了持有当前锁的线程,记录了锁被重入的次数,同时他还有一个属性EntryList用来关联那些因为拿不到锁而被阻塞的线程;如下图所示(先不要关心WaitSet)

好了到此为止你应该明白了我们在程序当中使用synchronized关键字的大概原理了吧,总结一下。当我们使用synchronized关键字来保护临界区的代码的时候,如果多线程并发情况下,持有锁的线程只有一个;其他竞争锁而不得线程会进入到当前锁对象关联的ObjectMonitor对象当中的双向链表的一个队列当中,并且是顺序进入的;但是当持有锁线程释放锁之后会从这个队列当中唤醒一个线程;从队列的末尾唤醒一个(先进后出);如果你没有看懂,请务必把文章翻上去在去看一遍或者从文章末尾把代码下载过去自己运行一遍,然后好好体会一下;因为只有把这里搞懂了下面笔者要写的内容才有意义,否则很难看懂下面的文章;

2.2 ReentrantLock的Blocking

这次代码改一下,不用synchronized关键来保护临界区,而是换成ReentrantLock,代码如下

package com.shadow.test;

import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

@Slf4j(topic = "s")
public class TestReentrantLock {
    
    static List<Thread> list = new ArrayList<>();
    //代码都没有变,只是把synchronized关键字变成了ReentrantLock
    static ReentrantLock lock = new ReentrantLock();
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(() -> {
                lock.lock();
                    log.debug("thread executed");
                    try {
                        TimeUnit.MILLISECONDS.sleep(200);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
               lock.unlock();
            }, "t" + i);
            list.add(t);
        }
        log.debug("---启动顺序 调度顺序或者说获取锁的顺序讲道理是正序0--9----");
        lock.lock();
            for (Thread thread : list) {
                log.debug("{}-启动顺序--正序0-9", thread.getName());
                thread.start();//  CPU 调度?
                TimeUnit.MILLISECONDS.sleep(1);
            }

            log.debug("-------执行顺序--正序9-0");

        lock.unlock();
    }
}

看看运行结果

21:04:24.320 [main] DEBUG shadow - ---启动顺序 调度顺序或者说获取锁的顺序讲道理是正序0--9----
21:04:24.327 [main] DEBUG shadow - t0-启动顺序--正序0-9
21:04:24.332 [main] DEBUG shadow - t1-启动顺序--正序0-9
21:04:24.334 [main] DEBUG shadow - t2-启动顺序--正序0-9
21:04:24.335 [main] DEBUG shadow - t3-启动顺序--正序0-9
21:04:24.337 [main] DEBUG shadow - t4-启动顺序--正序0-9
21:04:24.338 [main] DEBUG shadow - t5-启动顺序--正序0-9
21:04:24.339 [main] DEBUG shadow - t6-启动顺序--正序0-9
21:04:24.341 [main] DEBUG shadow - t7-启动顺序--正序0-9
21:04:24.343 [main] DEBUG shadow - t8-启动顺序--正序0-9
21:04:24.345 [main] DEBUG shadow - t9-启动顺序--正序0-9
21:04:24.346 [main] DEBUG shadow - -------执行顺序--正序9-0
21:04:24.347 [t0] DEBUG shadow - thread executed
21:04:24.547 [t1] DEBUG shadow - thread executed
21:04:24.750 [t2] DEBUG shadow - thread executed
21:04:24.955 [t3] DEBUG shadow - thread executed
21:04:25.156 [t4] DEBUG shadow - thread executed
21:04:25.362 [t5] DEBUG shadow - thread executed
21:04:25.567 [t6] DEBUG shadow - thread executed
21:04:25.774 [t7] DEBUG shadow - thread executed
21:04:25.976 [t8] DEBUG shadow - thread executed
21:04:26.178 [t9] DEBUG shadow - thread executed

从结果可以看到使用ReentrantLock来保护临界区的时候效果几乎和synchronized关键字相同,唯一不同的是当主线程释放锁之后去EntryList(EntryList其实是C++的队列,ReentrantLock其实不存在EntryList这个队列,但是他有一个对象FairSync或者NonfairSync这个对象维护了一个队列类似EntryList,文中为了方便都称之为EntryList吧)当中唤醒线程的时候是正序的(先进先出从);由于ReentrantLock是用java语言实现的,可以通过查阅JDK源码来看看他的原理;

synchronized需要查询JVM的C源码;如果大家对并发编程感兴趣可以给我留言,我会持续更新,看有么有机会来写一篇关于synchronized关键的底层C代码实现;

好了现在我们来翻翻JDK源码中对ReentrantLock的源码实现吧(其实,笔者早先写过一篇关于ReentrantLock的源码实现,而且你可以去b站search关键字"子路 AQS"有视频版的AQS源码分析),这里不做特别细致的源码分析,只做简单的源码分析;

首先看一下Node类设计,由于Node类是AbstractQueuedSynchronizer的一个内部类,我没有截取到类名,只截取到父类的名字(JDK源码中Node主要用来封装线程,因为node类里面有一个属性就是Thread类型的,你可以理解一个node对象就是一个线程)

volatile Node prev;

       // 后继结点
        volatile Node next;

        /**
         * The thread that enqueued this node.  Initialized on
         * construction and nulled out after use.
         *
         * 进入该节点队列的线程。在构造时初始化,使用后为空。
         * 结点所对应的线程
         */
        volatile Thread thread;

主要关心三个属性

Node  prev    双向列表用来指向上一个node(也就是上一个线程)
Node  next    双向链表用来指向下一个node(也就是下一个线程)
Thread thread  当前node所封装的线程

如果单纯看这三个属性,可以理解Node类主要是为了线程之间有关联而设计的,因为如果没有这个Node那么单独一个Thread是很难描述清楚线程之间的关系的;比如上面代码中t0-t9他们的阻塞顺序靠一个Thread是很难表示的;有了这个Node就很好表示了,比如node0对象当中的thread=t0,prev=null;next=node1;而node1当中的thread=t1,prev=node0,next=node2…以此类推吧(实际当中Node类有很多属性的,这里不做讨论);

再来看AQS这个同步框架最核心的类的设计;同样我们只关心他的几个属性

/**
     * Head of the wait queue, lazily initialized.  Except for
     * initialization, it is modified only via method setHead.  Note:
     * If head exists, its waitStatus is guaranteed not to be
     * CANCELLED.
     */
    private transient volatile Node head;

    /**
     * Tail of the wait queue, lazily initialized.  Modified only via
     * method enq to add new wait node.
     */
    private transient volatile Node tail;

    /**
     * The synchronization state.
     *
     * AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。
     * AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
     */
    private volatile int state;//共享变量,使用volatile修饰保证线程可见性
Node head	//队列当中的对头,也就是第一个阻塞的线程封装出来的node对象
Node tail	//队列当中的对尾,也就是最后一个阻塞的线程封装出来的node对象
int state	//锁的重入次数

如果我们使用ReentrantLock来保护临界区当一个线程拿不到锁的时候,会把这个线程封装成为一个Node对象;比如当t7来获取锁的时候则持有锁的线程是main,重入次数为1,对头为t0,队尾为t6;自己被封装成为一个node对象(此时还没有进入队列,也就是还没有关联之前)如下图(介于图片大小问题中间的Node忽略了,比如t2所代表的node,t3所代表的node)

当封装好t7之后,这个时候t7所代表的node会进入到队列,进入队列之后如下图(介于图片大小问题中间的Node忽略了,比如t2所代表的node,t3所代表的node)

接下来通过idea当中的debug来说明一下上面的理论是否正确

上图的debug过程对于新手来说比较晦涩,可以多看几遍,或者文章末尾拿到笔者的代码自己去调试;主要需要说明的队列当中的对头关联并不是t0,而是一个thread=null,这个读者可以忽略(其实我在另一篇博客里面解释过了),也是就结果和我上面讲的有一些偏差,但是不影响,因为要解释这个thread=null代价比较大,读者可以把图换一下就一模一样了(实际情况如下图)

ReentrantLock当中的这个AQS双向链表队列相当于synchronized关键字当中的那个EntryList双向链表队列只不过ReentrantLock这个队列是先进先出,而EntryList则相反是先进后出;这个上面已经通过例子证明了;

其实ReentrantLock的先进先出可以通过源码来说明的;我们可以看看他的解锁方法也就是unlock方法看他如何唤醒线程的就真相大白了;

好了说了这么多最后给大家总结一下
不管是synchronized还是使用ReentrantLock来做同步,并发情况下
所有拿不到锁的线程都会进入一个双向链表去阻塞
而进入这个队列当中阻塞的线程的状态就是blocking状态
至于什么是waiting状态呢?
同样我会通过synchronized和ReentrantLock两个技术点来说明

3.waitingg状态

首先我们假设这样一个场景jack是您们公司的一名程序员,他由于经常看笔者的博客;故而水平非常的高,经常能解决一些核心问题,所以逼格也很高;而各位读者就是程序员x,水平比较低,几乎没有逼格;假设你们老板在周末休息时间打电话叫所有程序员来加班,公司钥匙只有一把(进入到公司的人会把门锁了),所以能进到公司一定需要这把钥匙;这个时候jack来了,但是前面说过jack逼格很高他加班必须要你们老板给他安排一个女人,他才会啪啪啪(当然这里的啪啪啪是指敲键盘),不然他就会去休息,而你们是没有逼格的,进到公司就写代码;(有么有一点感同身受啊),基于这个场景我们来编程

这样显然不合理,因为jack的女人问题,搞得其他五个人无法工作,可能被fire,这像极了我们平时,老板叫加班不敢不去,如果因为jack去不成那基本要被人事约谈,所以不合理;不合理的地方在于jack调用了sleep去阻塞,sleep阻塞的线程是无法释放锁的;假设有一种API能够让线程阻塞,同时又把锁释放了那就最好,jack他牛逼他去休息等女人,不影响其他人受虐心甘情愿的加班。JDK当中对于synchronized关键提供了一个wait方法可以实现上述功能

把代码修改一下,把sleep改成wait

package com.shadow.test;

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.TimeUnit;

@Slf4j(topic = "s")
public class TestWait1 {

    static  Object object = new Object();//锁对象
    static boolean isWoman = false; // 是否有女人

    public static void main(String[] args) {
        new Thread(() -> {//jack
            synchronized (object){
                while (!isWoman){//判断是否有女人
                    log.debug("没有女人 我去等待老板安排 先休息,安排好之后叫醒我");
                    try {
                        
                        //jack线程进入阻塞,但是释放了锁
                        object.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                log.debug("开始工作啪啪啪啪");
            }
        }, "jack").start();

        for (int i = 0; i <5 ; i++) {
            new Thread(() -> {
                synchronized (object){
                    log.debug("那些默默无闻的程序员coding");
                }
            }, "程序员"+i).start();
        }
    }

}

再次运行,其他五个人可以工作了,而jack则在等待女人(JVM没有退出,因为jack线程阻塞了)

但是五个彩笔只能写CRUD关键高并发的核心代码还是得jack来啊,如果他休息项目基本要黄;故而老板没有办法只能满足他——找个女人来,找个桥本有菜来给jack(这就是大神和你的区别吧可能);难么找来之后怎么唤醒jack呢?jdk当中提供了notify/notifyall来唤醒因为wait方法而被阻塞的线程

把代码再次改一下,添加一个boos线程,来满足jack的条件让isWoman=true,然后调用notifyAll来唤醒jack,叫醒之后jack啪啪啪完之后全部线程结束,JVM退出

后续的看原文

相关文章

微信公众号

最新文章

更多