四、线程的生命周期

(一)线程的状态

线程的状态可以分为如下六种:

  1. 初始:新创建了一个线程,但是没有调用start()方法时,此时线程仅仅是一个普通的java对象,只有调用start()方法后才会与操作系统上的线程有所对应。
  2. 运行:运行可以分为两个状态
    1. 就绪:等待CPU调度执行
    2. 执行:被CPU调度执行
  1. 等待:进入该状态的线程需要被其他线程所通知是否继续或者停止
  2. 等待超时:该状态不等同于等待状态,进入该状态的线程可以自行继续执行,只需要等待超时时间过去
  3. 阻塞:该线程阻塞于锁,synchronized方法或者synchronized块
  4. 终止:该线程执行完毕

注:yield()方法会使当前线程让出CPU占有权,但是让出的时间不是固定的,也不会释放锁资源,执行该方法的线程会进入就绪状态。


(二)线程的优先级

真正决定线程优先级的是操作系统,不同的JVM或操作系统上线程规划会存在差异,有些操作系统甚至会忽略优先级。

在Java线程中,通过一个整型成员变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级是5,优先级高的线程分配时间片的数量要多于优先级低的线程。

设置线程优先级时,针对频繁阻塞(休眠或者I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的JVM以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。


(三)线程的调度

线程调度是指操作系统为线程分配CPU使用权的过程,主要分为两种:

1、协同式线程调度

2、抢占式线程调度

使用协同式线程调度的多线程系统,线程执行的时间由线程本身控制,线程把当前工作执行完成后才会通知其他线程。优点:避免线程上下文频繁切换;缺点:线程一旦出问题,则程序会一直阻塞。

使用抢占式线程调度的系统,每个线程执行的时间是由操作系统决定的。优点:线程出现问题程序不会阻塞;缺点:线程上下文切换频繁。


(四)线程与协程

为什么Java线程调度是抢占式调度?这需要我们了解Java中线程的实现模式。

我们已经知道线程其实是操作系统层面的实体,Java中的线程怎么和操作系统层面对应起来呢?

任何语言实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。

4.1 线程

4.1.1 内核线程实现

操作系统层面

使用内核线程实现的方式也被称为1: 1实现。 内核线程(Kernel-Level Thread, KLT) 就是直接由操作系统内核(Kernel, 下称内核) 支持的线程, 这种线程由内核来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进行调度, 并负责将线程的任务映射到各个处理器上。

由于内核线程的支持,每个线程都成为一个独立的调度单元,即使其中某一个在系统调用中被阻塞了,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已经由操作系统处理了。

局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、 析构及同步,都需要进行系统调用。而系统调用的代价相对较高, 需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个语言层面的线程都需要有一个内核线程的支持,因此要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的线程数量是有限的。

4.1.2 用户线程实现

不同语言实现,如JVM

严格意义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。 如果程序实现得当, 这种线程不需要切换到内核态, 因此操作可以是非常快速且低消耗的, 也能够支持规模更大的线程数量, 部分高性能数据库中的多线程就是由用户线程实现的。

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援, 所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难, 甚至有些是不可能实现的。 因为使用用户线程实现的程序通常都比较复杂,所以一般的应用程序都不倾向使用用户线程。Java语言曾经使用过用户线程,最终又放弃了。 但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang。

4.1.3 混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外, 还有一种将内核线程与用户线程一起使用的实现方式, 被称为N:M实现。

在这种混合实现下, 既存在用户线程, 也存在内核线程。用户线程还是完全建立在用户空间中, 因此用户线程的创建、 切换、 析构等操作依然廉价, 并且可以支持大规模的用户线程并发。同样又可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过内核线程来完成。

在这种混合模式中, 用户线程与轻量级进程(内核线程)的数量比是不定的,是N:M的关系。

4.1.4 Java线程的实现方式

Java线程在早期的Classic虚拟机上(JDK 1.2以前),是用户线程实现的, 但从JDK 1.3起, 主流商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1: 1的线程模型。

以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构, 所以HotSpot自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。

所以,这就是我们说Java线程调度是抢占式调度的原因。而且Java中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和Java中的一一对应,所以Java优先级并不是特别靠谱。

4.2 协程

可以理解为就是用户线程

进程中包含线程,其中一个内核线程可能对应多个用户线程(协程)

4.2.1 概念

IO密集型:一个方法需要频繁的处理磁盘文件或网络请求。

计算密集型:一个方法需要频繁从内存中读写数据并处理

4.2.2 协程解决什么问题

随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。比如,互联网服务架构在处理一次对外部业务请求的响应, 往往需要分布在不同机器上的大量服务共同协作来实现,,也就是我们常说的微服务,这种服务细分的架构在减少单个服务复杂度、 增加复用性的同时, 也不可避免地增加了服务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算, 这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求, 这样才不会出现请求由于某个服务被阻塞而出现等待。

Java目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1的内核线程模型是如今Java虚拟机线程实现的主流选择, 但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。 以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

另外我们常见的Java Web服务器,比如Tomcat的线程池的容量通常在几十个到两百之间, 当把数以百万计的请求往线程池里面灌时, 系统即使能处理得过来,但其中的切换损耗也是相当可观的。 这样的话,对Java语言来说,用户线程的重新引入成为了解决上述问题一个非常可行的方案。

其次,Go语言等支持用户线程等新型语言给Java带来了巨大的压力,也使得Java引入用户线程成为了一个绕不开的话题。

4.2.3 协程简介

为什么用户线程又被称为协程呢?我们知道,内核线程的切换开销是来自于保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉吗? 答案还是“不能”。 但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,则可以通过很多手段来缩减这些开销。

由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling) 的,所以它有了一个别名——“协程”(Coroutine) 完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。

协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。如果进行量化的话, 那么如果不显式设置,则在64位Linux上HotSpot的线程栈容量默认是1MB, 此外内核数据结构(Kernel Data Structures) 还会额外消耗16KB内存。与之相对的, 一个协程的栈通常在几百个字节到几KB之间, 所以Java虚拟机里线程池容量达到两百就已经不算小了, 而很多支持协程的应用中, 同时并存的协程数量可数以十万计。

协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程上也存在。

总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络io),不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)。

(五)守护线程

没有普通线程时,守护线程也就结束了。

Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。我们一般用不上,比如垃圾回收线程就是Daemon线程。

Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时Daemon线程中的finally块并不一定会执行。在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。

(六)线程间的通信和协调、协作

6.1 线程间的通信方式

6.1.1 管道方式

我们已经知道,进程间有好几种通信机制,其中包括了管道,其实Java的线程里也有类似的管道机制,用于线程之间的数据传输,而传输的媒介为内存。

例:有两个线程,一个线程负责接收键盘输入事件,另一个线程负责打印输入的内容

 public static void main(String[] args) throws IOException {
        //管道字符输出流
        PipedWriter pipedWriter = new PipedWriter();
        //管道字符输入流
        PipedReader pipedReader = new PipedReader();
        pipedWriter.connect(pipedReader);
        //接收数据线程
        new Thread(() ->{
            int data;
            try {
                while ((data = pipedReader.read()) != -1) {
                    System.out.println("接收到其他线程数据:" + (char)data);
                }
            }catch (Exception e){
                e.printStackTrace();
            }
        }).start();
        //发送数据线程
        new Thread(() ->{
            int data;
            try {
                while ((data = System.in.read()) !=-1) {
                    pipedWriter.write(data);
                }
            }catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }

6.2 线程间的协调方式

6.2.1 指定先后执行顺序

join()

把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B剩下的代码。

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值