Java并发编程 | 一文搞懂Java线程的生命周期

521 阅读10分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第23天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

在Java并发编程中,我们知道可以使用多线程来提高任务执行效率,而这个线程也是操作系统中的概念;在前面说并发编程Bug源头的文章中有说到,为了提高CPU的使用率,操作系统创建了线程来分时复用CPU;所以理解线程的不同状态,对后续知识点学习非常重要。

在操作系统层面,线程也有"生老病死",专业的说法叫做生命周期,对于有生命周期的事物,我们一定要搞懂生命周期中各个节点的状态转换机制

正文

在说Java的线程生命周期之前,我们来看看通用的线程生命周期是什么样子的,因为Java的线程也是操作系统线程的一个封装。

通用的线程生命周期

在操作系统层面,通用的线程生命周期可以用下图5个状态来描述,分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态

image.png

根据上面的图,我们进行每个状态细说:

初始状态

指的是线程已经被创建,但是不允许分配CPU执行。千万要注意,这个状态属于编程语言特有的状态,是指编程语言层面被创建,在操作系统底层,真正的线程并没有被创建

至于原因我们思考一下,操作系统是利用线程来进行分时复用CPU的,通过多线程以及线程切换来实现,所以这里的线程就可以看成是一个需要给CPU执行的任务,它没有必要搞出一个初始状态来复杂化这个模型。

可运行状态

这种也可以称为是就绪状态,指的是线程可以分配CPU执行,在这种状态下,真正的操作系统线程已经被成功创建了,可以分配CPU执行。

这里注意一下,可运行状态和运行状态的区别就是,这个线程没有获取到CPU的执行权,至于什么是没有获取到CPU执行权,下面会细说。

运行状态

当有空闲CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态为运行状态

想一下,由于线程非常多,而CPU个数有限,所以需要多个线程运行在同一个CPU上,那是如何做到的呢 就是把线程任务分开成一段段的:

image.png

比如上图中2个线程在一个CPU上执行,绿色就表示线程在执行,虚线就表示让出CPU。这种情况从整体上来看,线程A和线程B一直都在执行,但是对于线程A和线程B来说其线程状态就会一直改变。

在上面状态图中,我们发现可运行状态和运行状态这俩者是互相切换的,而切换的条件就是这个线程获得了CPU执行权,所以在运行时,线程的状态会一直发生变化。

休眠状态

运行状态的线程如果调用一个阻塞的API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态会转到休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权,当等待事件出现了,线程会从休眠状态换到可运行状态。

这里的描述非常有意思,说是调用阻塞的API或者等待条件变量而切换到休眠状态,不如叫做线程设计了这个状态来存放某种情况。

那为什么要设计出这个状态呢,其实就是为了节省CPU资源。假如这个线程一直在while(true)死循环,它会一直在可运行状态和运行状态之间切换,会大量消耗CPU资源,所以这时就可以让这个线程进入休眠状态,在休眠状态的线程就安稳了,不会占用CPU资源,等合适的机会再去获取CPU。

所以可以看出休眠状态和可运行状态最大区别就是休眠状态完全没有CPU的使用权,即无法去争取使用CPU。

终止状态

线程执行完或者出现异常时会进入终止状态,终止状态的线程就不会切换到其他任何状态了,也意味着线程的生命周期要结束了。

上面几种状态,我们从底层CPU使用权获得的角度来说十分好理解,但是不同的编程语言会把这些状态简化或者细化,下面我们来看看Java的线程生命周期。

Java中线程的生命周期

在Java语言中,线程的状态被分为了6种,分别是:

  1. NEW,初始化状态
  2. RUNNABLE,可运行/运行状态
  3. BLOCKED,阻塞状态
  4. WAITING,无时限等待
  5. TIMED_WAITING,有时限等待
  6. TERMINATED,终止状态

看上去比通用模型多几个状态,其实非常简单,Java线程种的BLOCKED、WAITING、TIMED_WAITING就是前面所提及的休眠状态,而把可运行和运行状态又合并为一个RUNNABLE状态,所以Java线程的生命周期转换如下图:

image.png

这就非常有意思了,我们可以想一下Java设计者为什么这样设计

首先是把通用模型里的可运行和运行状态合并为了一个RUNNABLE状态,这是为了忽略操作系统利用线程分时复用这个操作,因为当一个线程在执行,我们从宏观层面来看,我们并不在乎这个线程在获取CPU执行权和让出CPU执行权反复横跳,因为切换非常快,我们只看到了这个线程在正常运行,所以我们就把这2个合并为一个状态。

那为什么又把休眠状态分为了3种呢,我们先来看看这几种状态的切换,最后再简单思考一下。

RUNNABLE与BLOCKED的转换

只有一种场景会触发这个转换,也就是线程等待synchronized的隐式锁的时候,被synchronized修饰的方法同一时刻只允许一个线程执行,其他线程只能等待,这些等待的线程就会从RUNNABLE状态转换到BLOCKED状态,当等待的线程获得synchronized隐式锁时,又会从BLOCKED转换为RUNNABLE状态

这个非常好理解,即多个线程同时去调用一个synchronized修饰的方法,只有一个线程能进入临界区,其他线程就会等待,即不会消耗CPU资源。

这里展开思考一下,synchronized的加锁和解锁是隐式的,即当线程T1进入临界区执行完后,其他等待的处于休眠状态的TN线程就去获取锁,谁获取到了进入RUNNABLE状态,没有获取到的线程依旧是BLOCKED状态。

但是调用阻塞式API时,是否会转到BLOCKED状态呢

这里就有一个疑惑,这个阻塞式API是如何实现的,从某种程度上synchronized修饰的方法也是阻塞式API,因为它也会等,所以如果使用Lock或者其他技术实现了阻塞式API,比如有个方法叫做 getUserInfo() 这个阻塞API,调用这个阻塞API时,在操作系统层面,线程会进入休眠状态,不会获取CPU执行权

但是在JVM看来,它还是RUNNABLE状态,因为从宏观角度来说,等待CPU使用权和等待I/O是没有区别的,这个线程还是在执行,都是在等待资源,所以都归纳到RUNNABLE状态。

所以调用阻塞式API时,底层线程依旧会进入等待状态,只是JVM层还是定义在RUNNABLE状态,这个等后面说明阻塞式API细节时细说。

RUNNABLE与WAITING转换

总体来说一共有3种场景会触发这个转换:

  1. 获得synchronized隐式锁的线程,调用无参数的Object.wait()方法,会转入WAITTING状态。
  2. 调用无参数的Thread.join()方法,其join()方法是一种线程同步方法。例如有一个线程A,当主线程调用A.join()时,执行这条语句的主线程会等待线程A执行完,而等待的这个过程其状态就会从RUNNABLE变成WAITING,当线程A执行完成,则主线程从WAITING转换为RUNNABLE状态。
  3. 调用LockSupport.park()方法,这个LockSupport对象是啥呢 在Java并发包中的锁,都是基于它实现的,调用其park()方法,当前线程会阻塞,线程状态会由RUNNABLE状态变成WAITING,调用LockSupport.unpark可唤醒目标线程,目标线程又会从WAITING转换到RUNNABLE状态。

这个部分理解如果熟悉管程的话就容易理解,因为synchronized中的wait()方法就是针对一个条件变量进行等待的,即一个条件变量有一个线程等待队列。

RUNNABLE与TIMED_WAITING转换

既然和WAITING多了一个TIMED,那也可以猜的出和上面的区别就是多了个时间参数,所以有5种场景会触发这个转换:

  1. 调用带超时参数的Thread.sleep(long millis)方法。
  2. 获得synchronized隐式锁的线程,调用带超时参数的Object.wait(long timeout)方法。
  3. 调用带超时参数的Thread.join(long millis)方法。
  4. 嗲用带超时参数的LockSupport.parkNanos()方法。
  5. 调用带超时参数的LockSupport.parkUntil()方法。

NEW与RUNNABLE转换

Java刚创建出来的Thread对象的状态就是NEW状态,而创建Thread对象有2种方法:一种是继承Thread类,重写run方法;一种是实现Runnable接口,重写run方法,并且将Runnable对象作为创建Thread对象的参数。

而从NEW转换到RUNNABLE只需要调用Thread的start()方法即可

RUNNABLE与TERMINATED转换

线程执行完run()方法后,会自动转到TERMINATED状态,如果执行run()方法的时候抛出异常,也会导致线程终止。那假如要强制中断run()方法的执行呢,在Java种有2个方法,一个是stop()方法,不过已经过时了,不推荐使用;另一个就是interrupt()方法。

stop()和interrupt()的区别

stop()方法真的会杀死线程,不给线程喘息的机会,如果线程持有ReentrantLock锁,被stop()的线程不会去释放锁,那其他线程就再也获取不到锁了,这是很危险的操作,所以不建议使用

而interrupt()方法就好多了,它仅仅是通知线程,线程有机会执行一些后续操作,然后再转换状态。

总结

搞明白Java线程的生命周期非常关键,对解决一些并发的BUG很有用,当出现问题时可以dump出日志来查看线程状态等。