解析Thread类
- 概述
- 一、线程的生命周期
- 二、上下文切换
- 三、线程的创建
- 3.1 实现Runnable接口
- 3.2 实现 Callable 接口
- 3.3 继承 Thread 类
- 3.4 实现接口 VS 继承 Thread
- 四、Thread类详解
- 4.1 线程运行状态
- 4.1.1 start()方法
- 4.1.2 run 方法
- 4.1.3 sleep 方法
- 4.1.4 yield方法
- 4.1.5 join方法
- 4.1.6 interrupt 方法
- 4.2 线程的暂停与恢复
- 4.2.1 线程的暂停、恢复方法
- 4.2.2 线程常用操作
- 五、常见问题
- 5.1 一般线程和守护线程的区别?
- 5.2 Sleep 与wait 区别
- 5.3 多线程如何避免死锁
- 5.4 如何确保N 个线程可以访问N 个资源同时又不导致死锁?
概述
Java 中 Thread类 的各种操作与线程的生命周期密不可分,了解线程的生命周期有助于对Thread类中的各方法的理解。一般来说,线程从最初的创建到最终的消亡,要经历创建、就绪、运行、阻塞 和 消亡 五个状态。在线程的生命周期中,上下文切换通过存储和恢复CPU状态使得其能够从中断点恢复执行。结合 线程生命周期,本文最后详细介绍了 Thread 各常用 API。
特别地,在介绍会导致线程进入Waiting状态(包括Timed Waiting状态)的相关API时,笔者会特别关注两个问题:
- 客户端调用该API后,是否会释放锁(如果此时拥有锁的话);
- 客户端调用该API后,是否会交出CPU(一般情况下,线程进入Waiting状态(包括Timed Waiting状态)时都会交出CPU);
一、线程的生命周期
在Java虚拟机中,线程从最初的创建到最终的消亡,需要经历::创建(new)、就绪(runnable/start)、运行(running)、阻塞(blocked)、等待(waiting)、时间等待(time waiting) 和 消亡(dead/terminated),给定时间内,每个线程只能处于一种状态;
- 新建(New):创建后尚未启动;
- 可运行(Runnable):可能正在运行,也可能正在等待 CPU 时间片,包含了操作系统线程状态中的 Running 和 Ready;
- 阻塞(Blocked):等待获取一个排它锁,如果其线程释放了锁就会结束此状态;
- 无限期等待(Waiting):等待其它线程显式地唤醒,否则不会被分配 CPU 时间片;
- 限期等待(Timed Waiting):无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒;
- 死亡(Terminated):可以是线程结束任务之后自己结束,或者产生了异常而结束;
当创建完一个线程之后,不会立刻进入可运行状态,只有线程运行需要的运行条件满足之后才能进入可运行状态。在进入可运行状态之后,要等待CPU的执行时间,之后才可以真正的进入运行状态;
线程在运行状态过程中,可能有多个原因导致当前线程不继续运行下去,比如用户主动让线程睡眠(睡眠一定的时间之后再重新执行)、用户主动让线程等待,或者被同步块阻塞,此时就对应着多个状态:time waiting(睡眠或等待一定的时间)、waiting(等待被唤醒)、blocked(阻塞)。当由于突然中断或者子任务执行完毕,线程就会被消亡;
Java只定义了六种线程状态:分别是:New, Runnable, Waiting,Timed Waiting、Blocked 和 Terminated;
二、上下文切换
CPU在运行一个线程的过程中,转而去运行另外一个线程,这个叫做线程上下文切换(对于进程也是类似);
比如一个线程A正在读取一个文件的内容,读取到了一半,暂停一下去执行线程B,当再次回来执行线程A的时候,不希望线程A从头开始读文件;所以需要记录下文件的状态,就是记录程序计数器、CPU寄存器状态等数据。
上下文切换的本质就是存储和恢复CPU状态的过程,使得线程执行能够从中断点恢复执行,这就是程序计数器支持的;
三、线程的创建
有三种使用线程的方法:
- 实现 Runnable 接口;
- 实现 Callable 接口;
- 继承 Thread 类;
实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用;
3.1 实现Runnable接口
需要实现run()方法,通过Thread调用start()方法来启动线程;
实际上,start()方法的作用是通知 “线程规划器” 该线程已经准备就绪,以便让系统安排一个时间来调用其 run()方法,也就是使线程得到运行
public class MyRunnable implements Runnable {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}
3.2 实现 Callable 接口
与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装;
public class MyCallable implements Callable<Integer> {
public Integer call() {
return 123;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
3.3 继承 Thread 类
同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口;
当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法;
public class MyThread extends Thread {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}
3.4 实现接口 VS 继承 Thread
实现接口会更好一些,因为:
- Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
- 类可能只要求可执行就行,继承整个 Thread 类开销过大;
四、Thread类详解
Thread 类实现了 Runnable 接口,在 Thread 类中,有一些比较关键的属性,比如name是表示Thread的名字,可以通过Thread类的构造器中的参数来指定线程名字,priority表示线程的优先级(最大值为10,最小值为1,默认值为5),daemon表示线程是否是守护线程,target表示要执行的任务;
4.1 线程运行状态
4.1.1 start()方法
start() 用来启动一个线程,当调用该方法后,相应线程就会进入就绪状态,该线程中的run()方法会在某个时机被调用;
4.1.2 run 方法
run()方法是不需要用户来调用的。当通过start()方法启动一个线程之后,一旦线程获得了CPU执行时间,便进入run()方法体去执行具体的任务。创建线程时必须重写run()方法,来定义具体要执行的任务。
一般来说,有两种方式可以达到重写run()方法的效果:
- 直接重写:直接继承Thread类并重写run()方法;
- 间接重写:通过Thread构造函数传入Runnable对象 (注意,实际上重写的是 Runnable对象 的run() 方法)。
4.1.3 sleep 方法
方法 sleep() 的作用是在指定的毫秒数内让当前正在执行的线程(即 currentThread() 方法所返回的线程)睡眠,并交出 CPU 让其去执行其他的任务。当线程睡眠时间满后,不一定会立即得到执行,因为此时 CPU 可能正在执行其他的任务。所以说,**调用sleep方法相当于让线程进入阻塞状态。**该方法具有以下两个特征:
- 如果调用了sleep方法,必须捕获InterruptedException异常或者将该异常向上层抛出;
- sleep方法不会释放锁,也就是说如果当前线程持有对某个对象的锁,则即使调用sleep方法,其他线程也无法访问这个对象;
4.1.4 yield方法
调用 yield()方法会让当前线程交出CPU资源,让CPU去执行其他的线程。但是,yield()不能控制具体的交出CPU的时间。需要注意的是:
- yield()方法只能让 拥有相同优先级的线程 有获取 CPU 执行时间的机会;
- 调用yield()方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新得到 CPU 的执行;
- 同样不会释放锁;
4.1.5 join方法
假如在main线程中调用thread.join方法,则main线程会等待thread线程执行完毕或者等待一定的时间;
- join方法同样会会让线程交出CPU执行权限;
- join方法同样会让线程释放对一个对象持有的锁;
- 如果调用了join方法,必须捕获InterruptedException异常或者将该异常向上层抛出;
4.1.6 interrupt 方法
单独调用interrupt方法可以使得 处于阻塞状态的线程 抛出一个异常,也就是说,它可以用来中断一个正处于阻塞状态的线程;通过 interrupted()方法 和 isInterrupted()方法 可以停止正在运行的线程。
直接调用interrupt() 方法不能中断正在运行中的线程。但是,如果配合 isInterrupted()/interrupted() 能够中断正在运行的线程,因为调用interrupt()方法相当于将中断标志位置为true,那么可以通过调用isInterrupted()/interrupted()判断中断标志是否被置位来中断线程的执行。
一般情况下,不建议通过这种方式来中断线程,一般会在MyThread类中增加一个 volatile 属性 isStop 来标志是否结束 while 循环,然后再在 while 循环中判断 isStop 的值;
4.2 线程的暂停与恢复
4.2.1 线程的暂停、恢复方法
暂停线程意味着此线程还可以恢复运行,在 Java 中,我可以使用 suspend() 方法暂停线程;使用 resume() 方法恢复线程的执行,但是这两个方法已被废弃,因为它们具有固有的死锁倾向;
在使用 suspend 和 resume 方法时,如果使用不当,极易造成公共的同步对象的独占,使得其他线程无法得到公共同步对象锁,从而造成死锁;
4.2.2 线程常用操作
1、获得代码调用者信息
currentThread() 方法返回代码段正在被哪个线程调用的信息;
2、判断线程是否处于活动状态
方法 isAlive() 的功能是判断调用该方法的线程是否处于活动状态。其中,活动状态指的是线程已经 start (无论是否获得CPU资源并运行) 且尚未结束。
3、获取线程唯一标识
方法 getId() 的作用是取得线程唯一标识,由JVM自动给出;
4、getName和setName
用来得到或者设置线程名称,如果我们不手动设置线程名字,JVM会为该线程自动创建一个标识名,形式为: Thread-数字;
5、getPriority和setPriority
线程可以划分优先级,优先级较高的线程得到的CPU资源较多,也就是CPU优先执行优先级较高的线程。设置线程优先级有助于帮助 “线程规划器” 确定在下一次选择哪个线程来获得CPU资源;
6、守护线程(Daemon)
在 Java 中,线程可以分为两种类型,即用户线程和守护线程;
典型的守护线程就是垃圾回收线程,任何一个守护线程都是整个JVM中所有非守护线程的保姆,只要当前JVM实例中存在任何一个非守护线程没有结束,守护线程就在工作;只有当最后一个非守护线程结束时,守护线程才随着JVM一同结束工作;
五、常见问题
5.1 一般线程和守护线程的区别?
守护线程是指程序运行的时候在后台提供一种通用服务的线程,比如,垃圾回收线程,这种线程不是程序中不可或缺的一部分,只要任何非守护线程还在运行,程序就不会终止;
唯一的区别是判断虚拟机(JVM)何时离开,Daemon 是为其他线程提供服务,
如果全部的User Thread 已经撤离,Daemon 没有可服务的线程,JVM 撤离
5.2 Sleep 与wait 区别
- sleep 是线程类(Thread)的方法,导致此线程暂停执行指定时间,给执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复。调用sleep 不会释放对象锁sleep()使当前线程进入阻塞状态,在指定时间内不会执行。
- wait 是Object 类的方法,对此对象调用wait 方法导致本线程放弃对象锁,进入等待
此对象的等待锁定池,只有针对此对象发出notify 方法(或notifyAll)后本线程才进入对象锁定池准备获得对象锁进入运行状态。
区别:
- 这两个方法来自不同的类分别是Thread 和Object;
- sleep 方法没有释放锁,而wait 方法释放了锁,使得其他线程可以使用同
步控制块或者方法; - wait,notify 和notifyAll 只能在同步控制方法或者同步控制块里面使用,而sleep 可
以在任何地方使用(使用范围) - sleep 必须捕获异常,而wait,notify 和notifyAll 不需要捕获异常;
5.3 多线程如何避免死锁
死锁是指多个进程因竞争资源而造成的一种僵局(互相等待),若无外力作用,
这些进程都将无法向前推进。
死锁产生的4 个必要条件:
- 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间
内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等
待; - 不剥夺条件:只能由获得该资源的进程自己来释放(只能是主动释放);
- 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该
资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。 - 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同
时被链中下一个进程所请求。
5.4 如何确保N 个线程可以访问N 个资源同时又不导致死锁?
使用多线程的时候,一种非常简单的避免死锁的方式就是:指定获取锁的顺序,
并强制线程按照指定的顺序获取锁。
- 加锁顺序(线程按照一定的顺序加锁);
- 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,
并释放自己占有的锁); - 死锁检测;