Java并发编程——解析Thread类


概述

本文借鉴了Java 并发:Thread 类深度解析这篇博主的文章,他的专栏写的很好,推荐!

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

Runable状态

二、上下文切换

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 区别

  1. sleep 是线程类(Thread)的方法,导致此线程暂停执行指定时间,给执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复。调用sleep 不会释放对象锁sleep()使当前线程进入阻塞状态,在指定时间内不会执行。
  2. wait 是Object 类的方法,对此对象调用wait 方法导致本线程放弃对象锁,进入等待
    此对象的等待锁定池,只有针对此对象发出notify 方法(或notifyAll)后本线程才进入对象锁定池准备获得对象锁进入运行状态。

区别:

  • 这两个方法来自不同的类分别是Thread 和Object;
  • sleep 方法没有释放锁,而wait 方法释放了锁,使得其他线程可以使用同
    步控制块或者方法;
  • wait,notify 和notifyAll 只能在同步控制方法或者同步控制块里面使用,而sleep 可
    以在任何地方使用(使用范围)
  • sleep 必须捕获异常,而wait,notify 和notifyAll 不需要捕获异常;

5.3 多线程如何避免死锁

死锁是指多个进程因竞争资源而造成的一种僵局(互相等待),若无外力作用,
这些进程都将无法向前推进。

死锁产生的4 个必要条件:

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间
    内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等
    待;
  • 不剥夺条件:只能由获得该资源的进程自己来释放(只能是主动释放);
  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该
    资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同
    时被链中下一个进程所请求。

5.4 如何确保N 个线程可以访问N 个资源同时又不导致死锁?

使用多线程的时候,一种非常简单的避免死锁的方式就是:指定获取锁的顺序,
并强制线程按照指定的顺序获取锁。

  • 加锁顺序(线程按照一定的顺序加锁);
  • 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,
    并释放自己占有的锁);
  • 死锁检测;
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页