JVM 没有限定 Java 线程需要使用哪种线程模型来实现, JVM 只是封装了底层操作系统的差异,而不同的操作系统可能使用不同的线程模型,例如 Linux 和 windows 可能使用了一对一模型,solaris 和 unix 某些版本可能使用多对多模型。所以一谈到 Java 语言的多线程模型,需要针对具体 JVM 实现。
1 进程、线程
在现代操作系统中,线程是处理器调度和分配的基本单位,进程则作为资源(内存地址、文件 I/O 等)拥有的基本单位。线程是进程内部的一个执行单元。 每一个进程至少有一个主执行线程,它无需由用户去主动创建,是由系统自动创建的。用户根据需要在应用程序中创建其它线程,多个线程并发地运行于同一个进程中。说白了,所谓内核中的任务调度,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。所以,对于线程和进程,我们可以这么理解:
- 当进程只有一个线程时,可以认为进程就等于线程
- 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的
- 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的
2 用户态和内核态
由于进程的调度以及系统资源的分配是离不开操作系统的,所以学习谈线程设计之前,有必要先看下操作系统的体系结构,以 Unix/Linux 的体系架构为例。
因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。所以,为了减少有限资源的访问和使用冲突,Unix/Linux 的设计哲学之一就是:对不同的操作赋予不同的执行等级(有多大能力做多大的事),用户态(User Mode)和内核态(Kernel Mode)。
运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。很多程序开始时运行于用户态,但在执行的过程中有时候会需要切换到内核态执行,常见如以下三种:
1.系统调用:发一些操作需要在内核权限下才能执行,这就涉及到一个从用户态切换到内核态的过程。比如 printf(),调用的是 wirte()系统调用来输出字符串,等等。
2.不可知的异常事件:就会触发从当前用户态执行的进程转向内核态执行相关的异常事件。
3.外围设备的中断:CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换。
而在用户态和内核态之间的上下文切换,不可避免的会产生一定的开销,这也是线程设计中必须考虑到的点。
3 线程设计的三个难点
1、在 CPU 密集型任务、I/O 密集型任务以及充分利用多核 CPU 提升程序性能上找到一个平衡点。
2、尽可能支持规模更大的线程数量。
3、减少线程在用户态(User Mode)和内核态(Kernel Mode)中切换带来的开销。
4 三种不同的线程模型
4.1 基本概念
1.用户线程与内核级线程
线程的实现可以分为两类:用户级线程(User-LevelThread, ULT)和内核级线程(Kemel-LevelThread, KLT)。用户线程由用户代码支持,内核线程由操作系统内核支持。
2.并发与并行
并发:一个时间段内有很多的线程或进程在执行,但何时间点上都只有一个在执行,多个线程或进程争抢时间片轮流执行。
并行:一个时间段和时间点上都有多个线程或进程在执行。
3.多线程模型
多线程模型即用户级线程和内核级线程的不同连接方式,线程模型影响着并发规模及操作成本(开销)。
4.2 三种多线程模型:
1.使用用户线程实现(多对一模型 M:1)
广义上来讲,一个线程只要不是内核线程,都可以任务是用户线程(User Threa,UT)的一种。多个用户线程映射到一个内核线程,用户线程建立在用户空间的线程库上,用户线程的建立、同步、销毁和调度完全在用户态中完成,对内核透明。果程序实现得当,不需要切换内核态,因此操作可以是非常快且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
优点:
- 线程的上下文切换都发生在用户空间,避免了模态切换(mode switch),减少了性能的开销。
- 用户线程的创建不受内核资源的限制,可以支持更大规模的线程数量。
缺点:
所有的线程基于一个内核调度实体即内核线程,这意味着只有一个处理器可以被利用,浪费了其它处理器资源,不支持并行,在多处理器环境下这是不能够被接受的,如果线程因为 I/O 操作陷入了内核态,内核态线程阻塞等待 I/O 数据,则所有的线程都将会被阻塞。
增加了复杂度,所有的线程操作都需要用户程序自己处理,而且在用户空间要想自己实现 “阻塞的时候把线程映射到其他处理器上” 异常困难。
3)Java、Ruby等予以都曾经使用过用户线程,最终又都放弃了使用它。
2.使用内核线程实现(一对一模型 1:1)
使用内核线程实现的方式被称为1:1实现。内核线程(Kernel Levvel Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。其实程序一般不会直接使用内核线程,程序使用的是内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,轻量级进程也是属于用户线程。
所以这里统一称为用户线程。
每个用户线程都映射到一个内核线程,每个线程都成为一个独立的调度单元,由内核调度器独立调度,一个线程的阻塞不会影响到其他线程,从而保障整个进程继续工作。
优点:
- 每个线程都成为一个独立的调度单元,使用内核提供的线程调度功能及处理器映射,可以完成线程的切换,并将线程的任务映射到其他处理器上,充分利用多核处理器的优势,实现真正的并行。
缺点:
- 每创建一个用户级线程都需要创建一个内核级线程与其对应,因此需要消耗一定的内核资源,而内核资源是有限的,所以能创建的线程数量也是有限的。
- 模态切换频繁,各种线程操作,如创建、析构及同步,都需要进行系统调用,需要频繁的在用户态和内核态之间切换,开销大。
3.使用用户线程加轻量级进程混合实现(多对多模型 M:N)
内核线程和用户线程的数量比为 M : N,这种模型需要内核线程调度器和用户空间线程调度器相互操作,本质上是多个线程被映射到了多个内核线程。
综合了前面两种模型的优点:
用户线程的创建、切换、析构及同步依然发生在用户空间,能创建数量更多的线程,支持更大规模的并发。
大部分的线程上下文切换都发生在用户空间,减少了模态切换带来的开销。
可以使用内核提供的线程调度功能及处理器映射,充分利用多核处理器的优势,实现真正的并行,并降低了整个进程被完全阻塞的风险。
5 Java的线程模型
一句话总结:Java 的线程是映射到操作系统的原生线程之上的。
JVM 没有限定 Java 线程需要使用哪种线程模型来实现, JVM 只是封装了底层操作系统的差异,而不同的操作系统可能使用不同的线程模型,例如 Linux 和 windows 可能使用了一对一模型,solaris 和 unix 某些版本可能使用多对多模型。所以一谈到 Java 语言的多线程模型,需要针对具体 JVM 实现。
比如 Sun JDK 1.2开始,线程模型都是基于操作系统原生线程模型来实现,它的 Window 版和 Linux 版都是使用系统的 1:1 的线程模型实现的。
6 Java线程调度
线程调度是指系统为线程分配处理使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。
- 协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。
优点:实现简单,切换操作对线程自己是可知的,所以一般没有什么线程同步问题。
缺点:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。 - 抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。
优点:可以主动让出执行时间(例如Java的Thread::yield()
方法),并且线程的执行时间是系统可控的,也不会有一个线程导致整个系统阻塞的问题。
缺点:无法主动获取执行时间。
Java使用的就是抢占式线程调度,虽然这种方式的线程调度是系统自己的完成的,但是我们可以给操作系统一些建议,就是通过设置线程优先级来实现。Java语言一共设置了10个级别的线程优先级。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
不过由于各个系统的提供的优先级数量不一致,所以导致Java提供的10个级别的线程优先级并不见得能与各系统的优先级都一一对应。
6.1 线程状态
以下以linux平台为例
linux平台下,JVM采用1:1的线程模型,那Java中的线程状态与OS的线程状态是否也是一一对应的?
6.1.1 linux系统线程状态&生命周期
linux系统的线程状态及生命周期如上图,每种状态的详细解释不再一一赘述,这里简单介绍下RUNNABLE与RUNNING
RUNNABLE
线程处于可运行的状态,但还没有被系统调度器选中,即还没有分配到CPU时间片
RUNNING
线程处于运行状态,即线程分配到了时间片,正在执行机器指令
6.1.2 Java系统线程状态&生命周期
Java中的线程状态并没有使用系统线程状态一一对应的方式,而是提供了与之不同的6种状态
以下,linux系统线程状态会使用 斜体\ 加以区分
linux系统中的*RUNNABLE
与RUNNING
被Java合并成了RUNNABLE
一种状态,而linux系统中的BLOCKED
*被Java细化成了WAITING
、TIMED_WAITING
及BLOCKED
三种状态
Java中的线程状态与系统中的线程状态大体相似,但又略有不同,最明显的一点是,如果由于I/O阻塞会使Java线程进入BLOCKED
状态么?NO!I/O阻塞在系统层面会使线程进入*BLOCKED
*状态,但在Java里线程状态依然是RUNNABLE
!
系统中的*RUNNABLE
表示线程正在等待CPU资源,在在Java中被认为同样是在运行中,只是在排队等待而已,故Java中将系统的RUNNABLE
与RUNNING
*合并成了RUNNABLE
一种状态
而对于系统中I/O阻塞引起的*BLOCKED
*状态,在Java中被认为同样是在等待一种资源,故也认为是RUNNABLE
的一种情况
Java线程的状态在Thread.State
枚举中可以查看,其每种状态的释义写的非常清楚,这里不再一一解释
NEW
Thread state for a thread which has not yet started.
RUNNABLE
Thread state for a runnable thread. A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor.
BLOCKED
Thread state for a thread blocked waiting for a monitor lock. A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method or reenter a synchronized block/method after calling
Object.wait
.WAITING
Thread state for a waiting thread. A thread is in the waiting state due to calling one of the following methods:
Object.wait
with no timeoutThread.join
with no timeoutLockSupport.park
A thread in the waiting state is waiting for another thread to perform a particular action. For example, a thread that has called
Object.wait()
on an object is waiting for another thread to callObject.notify()
orObject.notifyAll()
on that object. A thread that has calledThread.join()
is waiting for a specified thread to terminate.TIMED_WAITING
Thread state for a waiting thread with a specified waiting time. A thread is in the timed waiting state due to calling one of the following methods with a specified positive waiting time:
Thread.sleep
Object.wait
with timeoutThread.join
with timeoutLockSupport.parkNanos
LockSupport.parkUntil
TERMINATED
Thread state for a terminated thread. The thread has completed execution.
总结所以java线程状态没有使用和操作系统的一一对应的方式
6.2 Java线程状态转换
Java语言定义了6种线程状态,在任意一个时间点钟,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间切换。
- 新建(New):创建后尚未启动的线程处于这种状态。
- 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处理此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
- 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显示唤醒。
以下方法会让线程陷入无限期等待状态:
1、没有设置Timeout参数的Object::wait()
方法;
2、没有设置Timeout参数的Thread::join()
方法;
3、LockSupport::park()
方法。 - 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。
以下方法会让线程进入限期等待状态:
1、Thread::sleep()
方法;
2、设置了Timeout参数的Object::wait()
方法;
3、设置了Timeout参数的Thread::join()
方法;
4、LockSupport::parkNanos()
方法;
5、LockSupport::parkUntil()
方法; - 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间 ,或者唤醒动作发生。在程序进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
这6种状态在遇到特定事件发生的时候将会互相转换,他们的转换关系如下图:
6.3 上下文切换
所谓的上下文切换是指操作系统变更执行任务的这一过程,它发生在任务调度后,涉及到两个动作——保存上文和恢复下文。
- 保存上文是指,在任务调度后需要变更执行任务时,保存CPU的各个寄存器到当前任务的栈空间中的过程。上文是保存在进程的栈空间中的
- 恢复下文是指,变更执行任务时,从目标任务的栈空间中把之前保存的CPU寄存器的值依次取出的过程。
保存上文的目的是方便有效的恢复下文,这点与处理器响应中断请求, 进入和退出服务函数时的操作有些类似。 但两者所讨论的范畴是不一样的,中断服务是处理器响应外部事件的机制,上下文切换是操作系统变更执行任务的机制。 两者一个在于底层的硬件,一个是上层软件的抽象。
6.3.1 上下文切换诱因
sleep、wait、yield、join、park、synchronized、lock
线程被分配的时间片用完、系统调用、进程状态转换(运行、就绪、阻塞)、系统资源不足、优先组调度、硬件中断等。
除上述以外java中可能导致的上下文切换:
1、多线程对锁资源的竞争会引起上下文切换,锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销就越大。(在多线程编程中,锁本身不是性能开销的根源,锁竞争才是性能开销的根源)
2、GC会导致上下文切换JVM垃圾回收(STW、线程暂停)
6.3.2 线程下文切换三种情况
第一种:前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样
第二种:前后两个线程属于同一个进程。此时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。可发现同进程内的线程切换,要比多进程间的切换消耗更少的资源,而这,也正是多线程代替多进程的一个优势
第三种:中断上下文线程切换,对同一个CPU来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。同样道理,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。
6.3.3 进程/线程上下文切换会用掉你多少CPU?
1 | https://zhuanlan.zhihu.com/p/79772089 |
6.3.4 有哪些减少上下文的技术用例?
数据库连接池(复用连接)、合理设置应用最大进程、线程数,直接内存访问DMA、零拷贝技术
6.3.5 监控工具
1 | ubuntu@aiit:~$ vmstat |
- r: Running or Runnable Task 是就绪队列的长度,也就是正在运行和等待CPU的进程数
- b: Blocked Task 处于不可中断睡眠状态的进程数
- cs: Context switch 是每秒上下文切换的次数
- in: Interrupt 则是每秒中断的次数
7、参考
1 | https://crazyfzw.github.io/2018/06/19/thred-model/ |
- 本文作者: 初心
- 本文链接: http://funzzz.fun/2021/03/01/java线程模型/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!