美文网首页Java-多线程就该这么学并发
03. 就该这么学并发 - 线程的阻塞

03. 就该这么学并发 - 线程的阻塞

作者: 码哥说 | 来源:发表于2020-06-15 15:44 被阅读0次

    前言

    上章介绍了线程生命周期就绪运行状态

    这章讲下线程生命周期中最复杂的阻塞状态

    阻塞(Blocked)

    在开始之前

    我们先科普几个概念

    阻塞,挂起,睡眠 区分

    阻塞

    在线程执行时,所需要的资源不能立马得到,则线程被“阻塞”,直到满足条件则会继续执行
    

    阻塞是一种“被动”的状态

    挂起

    线程执行时,因为“主观”需要,需要暂停执行当前的线程,此时需要“挂起”当前线程.
    

    挂起是“主动”的动作行为,因为是“主动”的,所以“挂起”的线程需要“唤醒”才能继续执行

    睡眠

    线程执行时,因为“主观”需要,需要等待执行一段时间后再继续执行,
    此时需要让当前线程睡眠一段时间
    

    睡眠和挂起一样,也是“主动”的动作行为,不过和挂起不一样的是,它规定了时间!

    我们举个形象的例子来说明

    假设你是个主人,雇佣了一个佣人
    
    挂起: 你主动对阿姨说 “你先去休息,有需要我再喊你”
    
    睡眠: 你主动对阿姨说 “你去睡两个小时,然后继续干活”
    
    阻塞: 阿姨自己没在干活,因为干活的工具不见了,等有了工具,她会自觉继续干活
    

    明白了以上概念,我们继续了解,线程什么情况会阻塞?

    线程阻塞原因

    对于线程来讲,当发生如下情况时,线程将会进入阻塞状态:

    • 线程调用一个阻塞式I/O方法,在该方法返回之前,该线程被阻塞

    • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有

    • 线程调用sleep(): sleep()不会释放对象锁资源,指定的时间一过,线程自动重新进入就绪状态

    • 线程调用wait(): wait()会释放持有的对象锁,需要notify( )或notifyAll()唤醒

    • 线程调用suspend()挂起(已废弃,不推荐使用): resume()(已废弃,不推荐使用)可以唤醒,使线程重新进入就绪状态

    • 线程调用Join()方法: 如线程A中调用了线程B的Join()方法,直到线程B线程执行完毕后,线程A才会被自动唤醒,进入就绪状态

    需要说明的是

    • 在阻塞状态的线程只能进入就绪状态,无法直接进入运行状态

    • 就绪和运行状态之间的转换通常不受程序控制,而是由系统线程调度所决定

      • 当处于就绪状态的线程获得CPU时间片时,该线程进入运行状态;

      • 当处于运行状态的线程失去CPU时间片时,该线程进入就绪状态;

      • 但有一个方法例外yield( ):
        使得线程放弃当前分得的CPU的时间片,
        但是不使线程阻塞,即线程仍然处于就绪状态
        随时可能再次分得CPU的时间片进入执行状态
        调用yield()方法可以让运行状态的线程转入就绪状态

    • sleep()/suspend()/rusume()/yield()均为Thread类的方法,
      wait()/notify()/notifyAll()为Object类的方法

    对象锁 和 监视器

    细心的朋友可能注意到以上提到了个名词“监视器”,和“对象锁”,他们是个啥?

    在JVM的规范中,有这么一些话:

    “在JVM中,每个对象和类在逻辑上都是和一个监视器相关联的”  
    
    “为了实现监视器的排他性监视能力,JVM为每一个对象和类都关联一个锁”   
    
    “锁住了一个对象,就是获得对象相关联的监视器”   
    

    引用一个流传很广的例子来解释

    可以将监视器比作一个建筑,
    它有一个很特别的房间,
    房间里有一些数据,而且在同一时间只能被一个线程占据,
    一个线程从进入这个房间到它离开前,它可以独占地访问房间中的全部数据.
    
    进入这个建筑叫做"进入监视器"
    进入建筑中的那个特别的房间叫做"获得监视器"
    占据房间叫做"持有监视器"
    离开房间叫做"释放监视器"
    离开建筑叫做"退出监视器"
    
    而一个锁就像一种任何时候只允许一个线程拥有的特权,
    一个线程可以允许多次对同一对象上锁,
    对于每一个对象来说,java虚拟机维护一个计数器,记录对象被加了多少次锁,
    没被锁的对象的计数器是0, 
    线程每加锁一次,计数器就加1,
    每释放一次,计数器就减1,
    当计数器跳到0的时候,锁就被完全释放了
    

    Java中使用同步监视器的代码很简单,使用关键字 “synchronized”即可

    synchronized (obj){            
        //需要同步的代码
        //obj是同步监视器
    }
    public synchronized void foo(){
        //需同步的代码        
        //当前对象this是同步监视器
    }
    

    关于synchronized的详细使用有很多注意点, 我们后续单独开一章来讲解.

    阻塞状态分类

    阻塞分类.png

    根据阻塞产生的原因不同,阻塞状态又可以分为三种:

    • 等待阻塞
    运行中的线程执行wait()方法,该线程会释放占用的所有资源对象,
    JVM会把该线程放入该对象的“等待队列”中,进入这个状态后,是不能自动唤醒的,
    必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒,
    唤醒后进入“阻塞(同步队列)”
    
    • 同步阻塞
    就绪状态的线程,被分配了CPU时间片,
    准备执行时发现需要的资源对象被synchroniza(同步)(资源对象被其它线程锁住占用了),
    获取不到锁标记,该线程将会立即进入锁池状态,等待获取锁标记,
    这时的锁池里,也许已经有了其他线程在等待获取锁标记,
    这时它们处于队列状态,既先到先得
    一旦线程获得锁标记后,就转入就绪状态,继续等待CPU时间片
    
    • 其他阻塞
    运行的线程调用了自身的sleep()方法或其他线程的join()方法,
    或者发出了I/O请求时,JVM会把该线程置为阻塞状态.
    当sleep()状态超时、join()等待线程终止或者超时、
    或者I/O处理完毕时,线程重新转入就绪状态,等待CPU分配时间片执行
    

    Thread类相关方法介绍

    看完以上阻塞的介绍,可能很多朋友对Thread类的一些方法产生了疑问,下面我们来实际探究下这些方法的使用,相关的注意点我就直接写在注释中方便阅读

    public class Thread{
        // 线程的启动
        public void start(); 
        // 线程体,线程需要做的事
        public void run(); 
        // 已废弃,停止线程
        public void stop(); 
        // 已废弃,挂起线程,(不释放对象锁)
        public void suspend(); 
        // 已废弃,唤醒挂起的线程
        public void resume(); 
        // 在指定的毫秒数内让当前正在执行的线程休眠(不释放对象锁)
        public static void sleep(long millis); 
        // 同上,增加了纳秒参数(不释放对象锁)
        public static void sleep(long millis,int nanos); 
        //线程让步(不释放对象锁)
        public static void yield();
        // 测试线程是否处于活动状态
        public boolean isAlive(); 
        // 中断线程
        public void interrupt(); 
        // 测试线程是否已经中断
        public boolean isInterrupted(); 
        // 测试当前线程是否已经中断
        public static boolean interrupted(); 
        // 等待该线程终止
        public void join() throws InterruptedException; 
        // 等待该线程终止的时间最长为 millis 毫秒
        public void join(long millis) throws InterruptedException; 
        // 等待该线程终止的时间最长为 millis 毫秒 + nanos 纳秒
        public void join(long millis,int nanos) throws InterruptedException; 
    }
    

    我们可以看出,Thread类很多方法因为线程安全问题已经被弃用了,比如我们讲的suspend()/resume(), 因为它会产生死锁

    现在

    挂起是JVM的系统行为,我们无需干涉

    suspend()/resume()产生死锁的原因

    suspend()的线程持有某个对象锁,而resume()它的线程又正好需要使用此锁的时候,死锁就产生了

    举个例子:

    有两个线程A和B,以及一个公共资源O
    
    A执行时需要O对象,所以A拿到了O锁住,防止操作时O再被别的线程拿走,之后suspend挂起,
    
    B呢,负责在适当的时候resume唤醒A,但是B执行时也需要拿到O对象
    
    此时,死锁产生了
    
    A拿着O挂起,因为resume的实现机制,所以挂起时O不会被释放,
    
    只有A被resume唤醒继续执行完毕才能释放O,
    
    B本来负责唤醒A,但是B又拿不到O,
    
    所以,A和B永远都在等待,执行不了
    

    说到Thread类的suspend和/resume,顺带也提下Object类的wait/notify这对组合,wait/notify属于对象方法,意味着所有对象都会有这两个方法.

    wait/notify

    这两个方法同样是等待/通知,但使用它们的前提是已经获得了锁,且在wait(等待)期间会释放锁

    线程要调用wait(),必须先获得该对象的锁,在调用wait()之后,当前线程释放该对象锁并进入休眠,只有以下几种情况下会被唤醒

    - 其他线程调用了该对象的notify()(随机唤醒等待队列的一个线程)
    或notifyAll()(随机等待队列的所有线程); 
    
    - 当前线程被中断; 
    
    - 调用wait(3000)时指定的时间(3s)已到.
    

    类方法和对象方法区别( sleep() & wait() )

    这里重点再强调下类方法和对象方法的区别,我们以sleepwait为例

    sleep方法是Thread类静态方法,直接使用Thread.sleep()就可以调用
    
    最好不要用Thread的实例对象调用它,因为它睡眠的始终是当前正在运行的线程
    
    它只对正在运行状态的线程对象有效
    
    使用sleep方法时,一定要用try catch处理InterruptedException异常
    

    我们举个例子

    //定义一个线程类
    public class ImpRunnableThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 3; i++) {
                System.out.println("线程: " +  Thread.currentThread().getName() + "第" + i + "次执行!");
            }
        }
    }
    
    //测试
    class Test {
        public void main(String[] args){
        Thread t = new Thread(new ImpRunnableThread());
         t.start();
         //很多人会以为睡眠的是t线程,但其实是main线程
         t.sleep(5000);
         for (int i = 0; i < 3; i++) {
              System.out.println("线程: " +    Thread.currentThread().getName() + "第" + i + "次执行!");
        }
    }
    

    先不说直接 “对象.sleep()” 这种使用方式本就不对,

    再者 t.sleep(5000)很多人会以为是让t线程睡眠5s,

    但其实睡眠的是main线程!

    我们执行代码,就可以看出,t线程会先执行完毕,5s后主线程才会输出!

    那么问题来了,如何让t线程睡眠呢??

    很简单,我们在ImpRunnableThread类的run()方法中写sleep()即可!

    public class ImpRunnableThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 3; i++) {
                //sleep()一定要try catch
                try {
                    if (i == 2) {
                        //t线程睡眠5s
                        Thread.sleep(5000);
                    }
                    System.out.println("线程: " + Thread.currentThread().getName() + "第" + i + "次执行!");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }}}
    }
    

    说完类方法,我们再说对象方法

    对象锁: 即针对一个“实例对象”的锁,java中,所有的对象都可以“锁住”,这里举个简单的例子

    //实例一个Object对象
    Object lock = new Object();
    //使用synchronized将lock对象锁住
    synchronized(lock){
        //锁保护的代码块
    }
    

    在Object对象中有三个方法wait()、notify()、notifyAll()

    • wait()
    wait()方法可以使调用该方法的线程释放共享资源的锁,
    然后从运行状态退出,进入阻塞(等待队列),直到再次被唤醒(进入阻塞(同步队列)).
    
    这里需要注意,形如wait(3000)这样的带参构造,
    无需其它线程notify()或notifyAll()唤醒,到了时间会自动唤醒,
    看似和sleep(3000)一样,但其实是不同的
    wait(3000)调用时会释放对象锁,3s过后,进入阻塞(同步队列),
    竞争到对象锁后进入就绪状态,而后cpu调度执行.
    所以,实际等待时间比3s会长!!
    
    而sleep(3000),不会释放对象锁,
    3s过后,直接进入就绪状态,等待cpu调度执行.
    
    • notify()
    notify()方法可以随机唤醒阻塞(等待队列)中等待同一共享资源的一个线
    程,并使得该线程退出等待状态,进入阻塞(同步队列)
    
    • notifyAll()
    notifyAll()和notify()类似,不过它唤醒了阻塞(等待队列)中等待
    同一共享资源的所有线程
    

    最后,如果wait()方法和notify()/notifyAll()方法不在同步方法/同步代码块中被调用,那么虚拟机会抛出

    java.lang.IllegalMonitorStateException
    

    接下来我们来看看具体任何使用

    定义一个等待线程WaitThread

    class WaitThread extends Thread {
        private Object lock;
    
        public WaitThread(Object lock) {
            this.lock = lock;
        }
    
        @Override
        public void run() {
            try {
                synchronized (lock) {
                    System.out.println(
                            "start---" + Thread.currentThread().getName() + "---wait time = " + System.currentTimeMillis());
                    lock.wait();
                    System.out.println(
                            "end---" + Thread.currentThread().getName() + "---wait time = " + System.currentTimeMillis());
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    

    定义一个唤醒线程NotifyThread

    class NotifyThread extends Thread {
        private Object lock;
    
        public NotifyThread(Object lock) {
            this.lock = lock;
        }
    
        @Override
        public void run() {
            synchronized (lock) {
                System.out.println(
                        "start---" + Thread.currentThread().getName() + "---wait time = " + System.currentTimeMillis());
                lock.notify();
                System.out.println(
                        "end---" + Thread.currentThread().getName() + "---wait time = " + System.currentTimeMillis());
            }
        }
    }
    

    测试代码

    public class Test {
        public static void main(String[] args) throws Exception {
            Object lock = new Object();
            WaitThread w1 = new WaitThread(lock);
            w1.setName("等待线程");
            w1.start(); 
            //main线程睡眠3s,便于我们看到效果
            Thread.sleep(3000);
            NotifyThread n1 = new NotifyThread(lock);
            n1.setName("唤醒线程");
            n1.start();
        }
    }
    

    结果

    start---等待线程---wait time = 1589425525994
    start---唤醒线程---wait time = 1589425529001
    end---唤醒线程---wait time = 1589425529001
    end---等待线程---wait time = 1589425529001
    

    结果可以看出,等待线程被唤醒线程唤醒后才继续输出.
    需要注意的是,如果等待线程设置的是wait(3000),则无需唤醒线程唤醒,它自己在3s后会继续执行.

    等待队列 & 同步队

    前面一直提到两个概念,等待队列(等待池),同步队列(锁池),这两者是不一样的.具体如下:

    同步队列(锁池)

    假设线程A已经拥有了某个对象(注意:不是类)的锁,
    而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),
    由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,
    但是该对象的锁目前正被线程A拥有,
    所以这些线程就进入了该对象的同步队列(锁池)中,
    这些线程状态为Blocked.
    

    等待队列(等待池)

    假设一个线程A调用了某个对象的wait()方法,
    线程A就会释放该对象的锁
    (因为wait()方法必须出现在synchronized中,
    这样自然在执行wait()方法之前线程A就已经拥有了该对象的锁),
    同时 线程A就进入到了该对象的等待队列(等待池)中,
    此时线程A状态为Waiting.
    如果另外的一个线程调用了相同对象的notifyAll()方法,
    那么处于该对象的等待池中的线程
    就会全部进入该对象的同步队列(锁池)中,准备争夺锁的拥有权.
    如果另外的一个线程调用了相同对象的notify()方法,
    那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的同步队列(锁池)
    

    notify()notifyAll()唤起的线程是有规律

    - 如果是通过notify来唤起的线程,那 先进入wait的线程会先被唤起来;
    
    - 如果是通过nootifyAll唤起的线程,默认情况是 最后进入的会先被唤起来,即LIFO的策略;
    

    欢迎关注我

    技术公众号 “CTO技术”

    相关文章

      网友评论

        本文标题:03. 就该这么学并发 - 线程的阻塞

        本文链接:https://www.haomeiwen.com/subject/herzmhtx.html