线程的生命周期(二)

544 阅读6分钟

上一篇讲到操作系统的线程生命周期,以及状态之间的转换,这样我们对线程的生命周期有了大概的了解.今天,重点讲解操作系统和JVM线程状态之间的区别,并借助工具来回答开篇提到的问题:

注意: 由于网上更多使用内核线程这一术语来表示操作系统线程,为方便大家理解,后面的文章会统一使用内核线程来表示操作系统线程或OS线程

JVM线程状态

在java中,线程的状态包含以下六种:

  • NEW: 新建
  • RUNNABLE: 可运行
  • BLOCKED: 阻塞
  • WAIT: 等待
  • TIMED_WAIT: 限时等待
  • TERMINATED: 终止 我们再回顾一下内核的线程状态:New, Ready, Running, Sleeping, Terminated. 我们看到JVM和内核线程之间还是有一些差别,那具体什么差别呢?上图

主要有两个:

  • JVM的Runnable状态对应内核的Ready和Running状态
  • 内核的Sleeping状态对应JVM的BLOCKED,WAITING和TIMED_WAITING.

上图仅仅是两者状态之间的对应,那在实际的运行过程中,JVM线程跟内核线程是如何对应起来呢? 我们先看一段程序,下面这个程序使用一个单独线程去读取用户输入并打印

public class LifeCycleBlockIO {
  private static void showLifeCycle(){
    try(InputStream inputStream = System.in){
      int data=inputStream.read();
      System.out.println((char)data);
    }
    catch (IOException ioe){
      ioe.printStackTrace();
    }
  }

  public static void main(String[] args) {
    Thread thread = new Thread(()->showLifeCycle());
    ThreadUtil.printState(thread);
    thread.start();
    //主线程等待1s确保thread线程已经运行
    ThreadUtil.sleep(1000);
    //此时thread调用阻塞IO(read方法)等待用户的输入
    //但下面输入结果是RUNNABLE,而不是BLOCKED
    ThreadUtil.printState(thread);
  }
}

这段程序也正好说明了开篇提到的问题: 线程调用阻塞IO时,ThreadState返回RUNNABLE,而不是BLOCKED. 那到底是为什么呢?

这就涉及到一个概念: 线程映射模型.它就是为解决运行时用户线程和内核线程的映射而产生的.简单来说,为了安全和稳定,操作系统不允许用户线程直接访问内核空间.比如你想获得用户的键盘输入,需要先通过内核线程访问驱动程序. 然后内核线程通过线程映射模型再返回给用户线程.对于操作系统来说,JVM线程就是一种用户线程.关于线程映射模型的更多内容,有兴趣的话可以参考这两篇文章.

采用哪种方式由具体的虚拟机和操作系统来决定. 虽然具体的映射操作对于我们来说是透明的,但有时我们需要知道当前JVM线程最终映射到了哪个内核线程,如何确定呢? 很简单,使用jstack查看线程的dump信息.操作起来也很简单,我们以上面的程序为例:

  1. 首先运行上面的程序,运行起来后程序将等待用户的输入,先不要输入

注意: 为了示例程序的简洁,我编写了ThreadUtil.printState(thread)和ThreadUtil.sleep(1000)两个辅助方法,你可以使用原生的sleep和println来代替

  1. 打开命令窗口输入jps你会发现程序对应的进程ID.以我本地为例,进程ID是10348
C:\Users\xxx>jps
4388 Launcher
10348 LifeCycleBlockIO
14284 Jps
  1. 使用jstack查看该进程包含的线程信息
C:\Users\xxx>jstack 10348
2020-12-25 23:08:53
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.191-b12 mixed mode):

"Thread-0" #12 prio=5 os_prio=0 tid=0x00000000203ad800 nid=0x3ad4 runnable [0x0000000020c3e000]
   java.lang.Thread.State: RUNNABLE
        at java.io.FileInputStream.readBytes(Native Method)
        at java.io.FileInputStream.read(FileInputStream.java:255)
        at java.io.BufferedInputStream.fill(BufferedInputStream.java:246)
        at java.io.BufferedInputStream.read(BufferedInputStream.java:265)
        - locked <0x000000076b991910> (a java.io.BufferedInputStream)
        at com.neal.learning.chapter1.LifeCycleBlockIO.showLifeCycle(LifeCycleBlockIO.java:26)
        at com.neal.learning.chapter1.LifeCycleBlockIO.lambda$main$0(LifeCycleBlockIO.java:35)
        at com.neal.learning.chapter1.LifeCycleBlockIO$$Lambda$1/1324119927.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)
...

虽然你输出的具体内容跟我不一样,但是不影响理解,我们看一下这行:

"Thread-0" #12 prio=5 os_prio=0 tid=0x00000000203ad800 nid=0x3ad4 runnable [0x0000000020c3e000]
  • Thread-0: JVM线程名称
  • #12: JVM线程编号
  • prio=5: JVM线程优先级
  • os_prio=0: 内核线程优先级
  • tid=0x00000000203ad800: JVM线程ID
  • nid=0x3ad4: 内核线程ID
  • runnable: JVM线程状态
  • 0x0000000020c3e000: JVM线程栈入口地址

通过jstack工具我们可以看出: JVM线程对应的内核线程ID是0x3ad4,状态是runnable.这样我们就得到了JVM对应的内核线程.

那此时对应的内核线程状态是什么呢?我们可以通过内核线程的ID查看它对应的状态.因为我对powershell不太熟悉,所以这里我们借助强大的Process Explorer工具来查看.

  1. 首先将十六进制的0x3ad4转换为十进制,因为这个工具用的十进制来表示线程ID
PS C:\Users\xxx> [Convert]::ToString(0x3ad4,10)
15060
  1. 然后利用上面拿到的进程ID(10348)找到对应进程

  2. 选中该行,右键->Properties(属性),在打开的窗口中选中Threads(线程)标签,然后找到ID为15060的线程,会发现它的状态是WAIT

至此,我们可以解释为什么上面的JVM线程状态是RUNNABLE而不是BLOCKED了:

首先JVM线程调用阻塞方法,我们通过线程映射模型会找到对应的内核线程.因为内核线程等待驱动程序的返回(用户输入),为了不浪费CPU资源,JVM线程被置为就绪状态. 又因为JVM线程没有单独的就绪状态,它是被RUNNABLE来统一表示的,所以通过jstack发现它是RUNNABLE.

注意: 我是在windows平台上进行的试验,所以内核线程显示WAIT:UserRequest, 在Linux或其他平台显示的状态可能会不太一样,但是都应该表示该线程处于等待状态

另外, 上面加粗的文字并没有说明到底是谁将JVM线程置为就绪状态,JVM还是操作系统? 我在网上找了半天也没找到明确的说明,但感觉应该是JVM,因为JVM线程对于内核来说是透明的,也就是说操作系统并不知道JVM线程的存在,所以应该是JVM.

总结

首先我们对比了JVM和内核线程状态,然后讲解了线程映射模型,最后利用这两点我们解答了开篇提到的问题. 关于上面JVM和内核线程状态对比的那张图,我们只讲解了RUNNABLE和Ready,Running,剩下的并没有讲解.比如虽然都对应Sleeping状态,但具体什么操作会导致BLOCKED,什么操作导致WAITING? 这是我们下一篇的重点,通过对这些问题的分析,我们会更好的理解锁是如何工作的.充分理解这些原理对后面的学习会起到事半功倍的效果,甚至我们也可以写出自定义锁,就像java并发包里面的代码一样.