上一篇讲到操作系统的线程生命周期,以及状态之间的转换,这样我们对线程的生命周期有了大概的了解.今天,重点讲解操作系统和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线程就是一种用户线程.关于线程映射模型的更多内容,有兴趣的话可以参考这两篇文章.
- blog.csdn.net/21aspnet/ar…
- juejin.cn/post/690740… 映射关系分为三种:
- 一个JVM线程对应一个内核线程
- 多个JVM线程对应一个内核线程
- 多个JVM线程对应多个内核线程
采用哪种方式由具体的虚拟机和操作系统来决定. 虽然具体的映射操作对于我们来说是透明的,但有时我们需要知道当前JVM线程最终映射到了哪个内核线程,如何确定呢? 很简单,使用jstack查看线程的dump信息.操作起来也很简单,我们以上面的程序为例:
- 首先运行上面的程序,运行起来后程序将等待用户的输入,先不要输入
注意: 为了示例程序的简洁,我编写了ThreadUtil.printState(thread)和ThreadUtil.sleep(1000)两个辅助方法,你可以使用原生的sleep和println来代替
- 打开命令窗口输入jps你会发现程序对应的进程ID.以我本地为例,进程ID是10348
C:\Users\xxx>jps
4388 Launcher
10348 LifeCycleBlockIO
14284 Jps
- 使用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工具来查看.
- 首先将十六进制的0x3ad4转换为十进制,因为这个工具用的十进制来表示线程ID
PS C:\Users\xxx> [Convert]::ToString(0x3ad4,10)
15060
-
然后利用上面拿到的进程ID(10348)找到对应进程
-
选中该行,右键->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并发包里面的代码一样.