Skip to content

看看你的基础:Java线程状态之间如何转换?

面试被问:Java线程有哪些状态?状态之间如何转换?——遇到这种问题,”背“是背不完的,关键要理解。面试官可以一直追问,比如说为什么叫做RUNNABLE状态,而不是RUNNING? Thread.join后是什么状态……层出不穷。

为了解决这些问题,还是让我们从底层开始,去了解实现原理。去思考问题背后的本质,回到操作系统的角度看并发,学习必须掌握的底层理论,帮助大家从以一种更底层、更具体的视角审视并发编程。 将这类问题一网打尽。

这节课关联的主要面试题目有:

  • 进程和线程的区别?
  • 为什么要有线程?
  • 进程开销为什么比线程大?
  • Java线程是内核级线程还是用户级线程?
  • JAVA线程有哪些状态?如何转换?
  • 为什么要减少线程切换?

什么是进程?

每个Java程序执行起来, 是一个进程,进程(Process)是程序的执行副本。

Java是程序,和微信、QQ、Word等是类似的,被安装在操作系统中,执行起来就是进程。操作系统一开始是没有应用程序这个概念的。每次开机,程序员就加载一段程序进去,执行完就关机。那个时候没有应用程序的概念,因此也没有进程。后来一个操作系统通常会装多个应用程序,就有了进程。

进程是应用程序在内存中的执行副本。副本就是一个应用程序可以打开多个实例;比如多开QQ,这就是多个执行副本,也就是多个QQ进程。Java在一台服务器上执行多个服务,其实就是启动了多个Java的进程。

什么是线程?

线程(Thread)还被称为是轻量级的进程。在Linux设计中,现在线程还是被称为Light-Weighted-Process(LWP)。

应用越做越复杂,比如一个web服务,要接收请求、要请求数据库、要进行业务计算——这就需要并发执行任务。

并发(concurrent),是指在一段时间内多个任务看上去同时进行。所以并发并不要求任务真正同时被处理,只要在给定的一段时间,看上去任务在同时执行就是并发。所以在高并发场景,服务器需要在很小的时间内完成大量的并发工作,而并不是说每个用户的请求都同时被响应,这里面还可以使用队列。

并行(parallel),则不同,指任务被同时执行。通常并行需要用到多道设计——比如多个CPU就是多条执行指令的流水线。

并发代表任务在分时执行,或者叫做交错(interleaved)执行;并行意味着任务同时执行(simultaneously)。

下面用两张图,帮助大家区分并发和并行。

并发 interleaved

并行-simultaneously

线程是一种并发的模型,每次每个线程只执行一个小的时间片段,然后切换到另一个线程执行,这样,用户感觉到的就好像任务在同时执行。

进程和线程的模型

进程和线程

进程是一个线程的容器,可以为每个需要并发的任务分配一个线程,多个线程共享CPU的时间去轮流切换执行,这就构成了一个最基本的线程模型。

资源分配问题

进程和线程间有个资源分配的问题。

对于程序执行,最基本的是3种资源:

  • 计算资源(CPU)
  • 内存
  • 文件

为什么会有线程?

早期操作系统把这3个资源都分配给进程,计算资源给到进程,并由操作系统管理。应用开发者们对分时要求上升后,再把计算资源(也就是CPU)分配给进程就不是很方便了。因为进程的开发者还需要自己实现分时算法,将计算资源再次分配给独立的一个个线程。因此,操作系统开始从底层支持线程。

主线程

进程创建后,操作系统除了分配内存和文件资源外,还为进程创建一个主线程,用于执行程序。如果进程想要创建更多的线程,可以调用操作系统提供的API创建。

思考:线程创建需要什么?

创建线程需要提供一段程序,本质上是提供一段程序指令所在的内存地址,程序指令是编译器编译好,进程启动的时候加载到进程的内存中的。 内存直接分配到进程,文件也直接分配到进程,计算资源分配到线程。

线程内存权限

理论上进程拥有的所有线程可以看到进程的全部内存,也就是线程共享进程的地址空间;但是实际实现时,Java的程序能访问的内存空间是受限的,因为Java不支持直接操作内存地址,必须通过对象、对象成员等等。

内核级线程和用户级线程

操作系统的内核程序也可以看做是一个进程,它拥有最高权限,可以访问所有内存。为了保护内核的执行,操作系统将内存划分了内核空间和用户空间。

内核中执行的程序,执行在内核态。除内核外的其他应用,只能访问用户空间,执行在用户态。 用户空间、内核空间描述的是程序对内存的可见范围;用户态、内核态指的是指程序在哪个空间执行。

image-20201119002444659

内核是应用程序和硬件沟通的桥梁。应用程序不断通过内核提供的系统调用请求硬件资源,这个时候内核中也需要有线程去承接这些任务。内核线程由操作系统直接调度。

这样就诞生了两个关联的概念:内核级线程和用户级线程。内核级线程由内核调度,用户级线程由应用自己调度。操作系统提供了系统API,允许进程申请一个用户级线程,并且分配一个对应的内核级线程。

好了,一个关键的面试问题来了,Java的线程是执行在内核态还是执行在用户态? 大家本能的可能会想到,Java线程肯定是执行在用户空间。假如我们使用用户级线程作为Java的线程。这意味着操作系统不知道这些线程存在。 如图:

image-20201119020022576

内核可以看做一个完整的进程,内核中也有一个线程表。图中线程们是由用户空间的进程们管理的,也称作用户级线程。上图中:操作系统在调度的是JVM的主线程。所以主线程在内核中是登记在册的。 操作系统把执行时间片交给JVM主线程,再由JVM主线程去调度子线程,所以JVM必须实现调度算法。 内核调度的是JVM主线程,所以CPU时间片段实际上是分配给了JVM主线程。这样,对于一个Java应用的多个线程来说,CPU一次只能执行一个子线程——子线程之间无法并行(无法利用多核优势)。

因为有无法利用多核优势这样一个明显的缺点,因此JDK1.1之后,就不再使用这种方式。

我们再看看假如使用内核级线程设计。我们可以利用操作系统的能力,用m个内核级线程来执行n个JVM线程的程序。实际上Linux和Windows上,m:n是1:1。img 在这个模型中,Java自己维护一个线程表,同时通过操作系统API创造内核级线程。操作系统帮助Java把用户级线程和内核级线程对应起来。对JVM来说,主线程和各个子线程是平行的,公平竞争CPU资源,可以利用多核优势。

线程的实际调度者是内核。对JVM来说,JVM意识不到这里有一个映射关系。比如创建了1000个Java线程,从Java程序员的视角,这里创建了1000个内核线程,由操作系统调度,可以利用所有CPU。对操作系统而言,操作系统可以选择只维护固定数量的内核线程,或者为每个Java线程创建1:1的内核级线程,来支持执行。

线程的状态

线程是一个执行单位,更轻量级。创建线程,只需要确定要执行什么程序。

线程最基本的状态有3个:

  1. 运行态(Running)
  2. 就绪态(Ready)
  3. 休眠态(Sleeping)。我们通常说的阻塞态(Blocking)是休眠态的一种情况。

新创建的线程会处于就绪态,也就是在排队执行。轮到自己执行后,切换到执行态。

image-20201119020625709

在线程的执行过程中,如果线程请求了外部资源,比如磁盘。这个时候需要等待磁盘数据,可以考虑用编程的手段将线程切换到休眠态,这样可以节省CPU资源。休眠态的线程需要中断唤起,进入就绪态,继续排队。

线程无法从休眠态不排队,就执行,因此不可以从阻塞态切换到执行态。线程排队过程中没有任务执行,也就不可能从就绪态切换到休眠态。

JVM线程状态

JVM的线程有7中状态:

  • 新建(NEW):JVM自己的概念,Java构造Thread类实例的过程。
  • 运行(RUNAABLE):对应上面的执行态和就绪态。 对应两个状态,所以叫做RUNNABLE。
  • 时钟等待(TIME_WATING)
  • 阻塞(BLOCKED)
  • 信号等待(WAITING)
  • 终止(TERMINATED)

具体的转换过程如图所示:

image-20201119021430083

JAVA是一个面向对象语言,线程对象需要初始化,所以增加了一个新建状态(NEW)。Java语言需要手动调用Thread的start方法,才能从NEW切换到RUNNABLE。然后,运行、就绪、休眠三态之间的关系没有变化。只不过,休眠对应了3种Java的状态,分别是阻塞(BLOCKED)、信号等待(WAITING)和时钟等待(TIME_WAITING)。

触发上面任何一种,线程都会休眠。只不过上面三种对应的场景不同:BLOCKED对应I/O等待场景,比如读取磁盘、等待锁的释放。WAITING对应信号等待的场景,是在等待其他线程的通知。TIME_WATING对应时钟等待的场景。

这里需要大家灵活掌握,不可能背下所有的函数。大家可以思考一道面试题,Thread.join是哪种模式呢? Thread.join目的是休眠当前线程,直到所有子线程完成,那么应该属于信号等待,等待其他线程的通知。再比如说读取socket文件属于哪种呢?这种属于I/O等待,应该属于BLOCKED。 最后,当一个线程没有程序要执行的时候(执行完成),会到终止态(TERMINATED)。

线程的切换

线程不拥有资源(内存、文件),因此线程切换的时候,需要完成两件事情:

  1. 保存当前线程的状态(CPU寄存器的值)
  2. 重新执行调度程序(操作系统),重新选择其他线程执行

面试可能会问,既然线程不拥有资源,那么ThreadLocal怎么理解? ThreadLocal是线程本地对象,是从语言层面实现的。相当于通过哈希表,把线程作为Key,存储了数据。

线程切换的行为我们也叫作Context Switch。主要是切换CPU的Context(上下文)。其实就是要把当前CPU所有寄存器的值,保存到内存中。等线程再次执行的时候,从内存中恢复CPU中所有寄存器的值。 下图是线程切换过程示例。

image-20201119021817103

线程切换有2种场景:线程主动交出(Thread的yield方法),I/O Block等——主动触发中断。线程响应中断,被迫切换——被动触发中断。

无论如何,线程切换一定要触发中断。中断触发后,当前执行的线程就中断了。控制权回到操作系统手中。操作系统执行一小段程序保存当前寄存器的状态,然后执行调度算法选择下一个线程。下一步,操作系统在执行一段程序,为下一个要执行的线程恢复寄存器的值。最后新线程开始执行。

所以,线程切换,是一件成本不低的事情。操作系统响应中断,保存上百寄存器的值,执行调度程序,再恢复上百寄存器的值。

因此我们需要减少线程切换。

总结

这节课我们学习了进程和多线程的基本概念。这块是并发编程中和操作系统相关的知识。如果面试遇到底层问题,通常就来自于这个部分。对操作系统而言,JVM是一种应用。对Java程序来说,JVM是真实的机器。JVM作为一个进程的身份向操作系统申请资源,这里体现进程是操作系统分配资源的最小单位。JVM再以进程的身份向操作系统申请大量的线程,供使用者使用。这些线程映射到了操作系统内核级线程中,这种设计帮助操作系统直接调度Java线程。所以JVM不控制线程调度,JVM维护线程状态。

下一节课,我们将学习4.3 JVM底层:CAS和原子操作。

文章来源于自己总结和网络转载,内容如有任何问题,请大佬斧正!联系我