前言
上章介绍了线程生命周期
的就绪
和运行
状态
这章讲下线程生命周期中最复杂的阻塞
状态
阻塞(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
的详细使用有很多注意点, 我们后续单独开一章来讲解.
阻塞状态分类
根据阻塞产生的原因不同,阻塞状态又可以分为三种:
- 等待阻塞
运行中的线程执行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() )
这里重点再强调下类方法和对象方法的区别,我们以sleep
和wait
为例
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的策略;