最近有点懒散,没什么比较有深度的产出。刚好想重新研读一下JUC
线程池的源码实现,在此之前先深入了解一下Java
中的线程实现,包括线程的生命周期、状态切换以及线程的上下文切换等等。编写本文的时候,使用的JDK
版本是11。
在「JDK1.2之后」,Java线程模型已经确定了基于操作系统原生线程模型实现。因此,目前或者今后的JDK版本中,操作系统支持怎么样的线程模型,在很大程度上决定了Java虚拟机的线程如何映射,这一点在不同的平台上没有办法达成一致,虚拟机规范中也未限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对于Java程序来说,这些差异是透明的。
对应Oracle Sun JDK
或者说Oracle Sun JVM
而言,它的Windows版本和Linux版本都是使用「一对一的线程模型」实现的(如下图所示)。
也就是一条Java
线程就映射到一条轻量级进程(「Light Weight Process」)中,而一条轻量级线程又映射到一条内核线程(「Kernel-Level Thread」)。我们平时所说的线程,往往就是指轻量级进程(或者通俗来说我们平时新建的java.lang.Thread
就是轻量级进程实例的一个"句柄",因为一个java.lang.Thread
实例会对应JVM
里面的一个JavaThread
实例,而JVM
里面的JavaThread
就应该理解为轻量级进程)。前面推算这个线程映射关系,可以知道,我们在应用程序中创建或者操作的java.lang.Thread
实例最终会映射到系统的内核线程,如果我们恶意或者实验性无限创建java.lang.Thread
实例,最终会影响系统的正常运行甚至导致系统崩溃(可以在Windows
开发环境中做实验,确保内存足够的情况下使用死循环创建和运行java.lang.Thread
实例)。
线程调度方式包括两种,协同式线程调度和抢占式线程调度。
Java
线程最终会映射为系统内核原生线程,所以Java
线程调度最终取决于系操作系统,而目前主流的操作系统内核线程调度基本都是使用抢占式线程调度。也就是可以死记硬背一下:「Java线程是使用抢占式线程调度方式进行线程调度的」。
很多操作系统都提供线程优先级的概念,但是由于平台特性的问题,Java中的线程优先级和不同平台中系统线程优先级并不匹配,所以Java线程优先级可以仅仅理解为“「建议优先级」”,通俗来说就是java.lang.Thread#setPriority(int newPriority)
并不一定生效,「有可能Java线程的优先级会被系统自行改变」。
Java
线程的状态可以从java.lang.Thread
的内部枚举类java.lang.Thread$State
得知:
这些状态的描述总结成图如下:
当Java线程实例调用了Thread#start()
之后,就会进入RUNNABLE
状态。RUNNABLE
状态可以认为包含两个子状态:READY
和RUNNING
。
READY
:该状态的线程可以被线程调度器进行调度使之更变为RUNNING
状态。RUNNING
:该状态表示线程正在运行,线程对象的run()
方法中的代码所对应的的指令正在被CPU执行。当Java线程实例Thread#yield()
方法被调用时或者由于线程调度器的调度,线程实例的状态有可能由RUNNING
转变为READY
,但是从线程状态Thread#getState()
获取到的状态依然是RUNNABLE
。例如:
WAITING
是「无限期的等待状态」,这种状态下的线程不会被分配CPU执行时间。当一个线程执行了某些方法之后就会进入无限期等待状态,直到被显式唤醒,被唤醒后,线程状态由WAITING
更变为RUNNABLE
然后继续执行。
其中Thread#join()
方法相对比较特殊,它会阻塞线程实例直到线程实例执行完毕,可以观察它的源码如下:
可见Thread#join()
是在线程实例存活的时候总是调用Object#wait()
方法,也就是必须在线程执行完毕isAlive()
为false(意味着线程生命周期已经终结)的时候才会解除阻塞。
基于WAITING
状态举个例子:
「API注释」:
线程正在等待一个监视器锁,只有获取监视器锁之后才能进入synchronized
代码块或者synchronized
方法,在此等待获取锁的过程线程都处于阻塞状态。
synchronized
代码块或者synchronized
方法后(此时已经释放监视器锁)调用Object#wait()
方法之后进行阻塞,当接收其他线程T调用该锁对象Object#notify()/notifyAll()
,但是线程T尚未退出它所在的synchronized
代码块或者synchronized
方法,那么线程X依然处于阻塞状态(注意API注释中的「reenter」,理解它场景2就豁然开朗)。更加详细的描述可以参考笔者之前写过的一篇文章:深入理解Object提供的阻塞和唤醒API
针对上面的场景1举个简单的例子:
针对上面的场景2举个简单的例子:
场景2中:
Object#notify()
后睡眠2000毫秒再退出同步代码块,释放监视器锁。Object#wait()
,此时它已经释放了监视器锁,所以线程2成功进入同步块,线程1处于API注释中所述的reenter a synchronized block/method
的状态。reenter
状态并且打印其线程状态,刚好就是BLOCKED
状态。这三点看起来有点绕,多看几次多思考一下应该就能理解。
「API注释」:
TERMINATED
状态表示线程已经终结。一个线程实例只能被启动一次,准确来说,只会调用一次Thread#run()
方法,Thread#run()
方法执行结束之后,线程状态就会更变为TERMINATED
,意味着线程的生命周期已经结束。
举个简单的例子:
多线程环境中,当一个线程的状态由RUNNABLE
转换为非RUNNABLE
(BLOCKED
、WAITING
或者TIMED_WAITING
)时,相应线程的上下文信息(也就是常说的Context
,包括CPU
的寄存器和程序计数器在某一时间点的内容等等)需要被保存,以便线程稍后恢复为RUNNABLE
状态时能够在之前的执行进度的基础上继续执行。而一个线程的状态由非RUNNABLE
状态进入RUNNABLE
状态时可能涉及恢复之前保存的线程上下文信息并且在此基础上继续执行。这里的对「线程的上下文信息进行保存和恢复的过程」就称为上下文切换(Context Switch
)。
线程的上下文切换会带来额外的性能开销,这包括保存和恢复线程上下文信息的开销、对线程进行调度的CPU
时间开销以及CPU
缓存内容失效的开销(线程所执行的代码从CPU
缓存中访问其所需要的变量值要比从主内存(RAM
)中访问响应的变量值要快得多,但是「线程上下文切换会导致相关线程所访问的CPU缓存内容失效,一般是CPU的L1 Cache
和L2 Cache
」,使得相关线程稍后被重新调度到运行时其不得不再次访问主内存中的变量以重新创建CPU
缓存内容)。
在Linux
系统中,可以通过vmstat
命令来查看全局的上下文切换的次数,例如:
参考资料中提到Windows
系统下可以通过自带的工具perfmon
(其实也就是任务管理器)来监视线程的上下文切换,实际上笔者并没有从任务管理器发现有任何办法查看上下文切换,通过搜索之后发现了一个工具:Process Explorer。运行Process Explorer
同时运行一个Java
程序并且查看其状态:
如果项目在生产环境中运行,不可能频繁调用Thread#getState()
方法去监测线程的状态变化。JDK本身提供了一些监控线程状态的工具,还有一些开源的轻量级工具如阿里的Arthas,这里简单介绍一下。
jvisualvm
是JDK自带的堆、线程等待JVM指标监控工具,适合使用于开发和测试环境。它位于JAVA_HOME/bin
目录之下。
理解Java线程状态的切换和一些监控手段,更有利于日常开发多线程程序,对于生产环境出现问题,通过监控线程的栈信息能够快速定位到问题的根本原因(通常来说,目前比较主流的MVC
应用(准确来说应该是Servlet
容器如Tomcat
)都是通过一个线程处理一个单独的请求,当请求出现阻塞的时候,导出对应处理请求的线程基本可以定位到阻塞的精准位置,如果使用消息队列例如RabbitMQ
,消费者线程出现阻塞也可以利用相似的思路解决)。