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

前言

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

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

阻塞(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的策略;

请关注我的订阅号

订阅号.png

你可能感兴趣的:(03. 就该这么学并发 - 线程的阻塞)