从0开始深入理解并发编程(一)

为什么开发中需要并发编程?

  • 加快响应用户的时间

  我们在做程序开发的时候,特别是我们做互联网项目,网页的响应时间若提升 1s,如果流量大的话,就能增加不少转换量。做过高性能 web 前端调优的都知道,要将静态资源地址用两三个子域名去加载,为什么?因为每多一个子域名,浏览器在加载你的页面的时候就会多开几个线程去加载你的页面资源,提升网站的响应速度。

  • 使我们的代码模块化、异步化、简单化
  例如我们实现电商系统,下订单和给用户发送短信、邮件就可以进行拆分, 将给用户发送短信、 邮件这两个步骤独立为单独的模块, 并交给其他线程去执行。 这样既增加了异步的操作, 提升了系统性能, 又使程序模块化,清晰化和简单化。
  • 充分利用 CPU 的资源
目前市面上没有 CPU 的内核不是多核的,例如下面这台机器

多核下如果还是使用单线程的技术做,无法充分利用 CPU 的多核特点。如果设计一个多线程的程序的话,那它就可以同时在多个 CPU 的多 个核的多个线程上跑,可以充分地利用CPU,减少 CPU 的空闲时间,发挥它的运算能力,提高并发量。

1. 基础概念

1.1. 进程和线程

进程

  我们常听说的是应用程序, 也就是 app ,由指令和数据组成。当我们不运行一个具体的应用程序时,这些应用程序就是放在磁盘(也包括 U 盘、远程网络存储等等)上的一些二进制的代码。 一但我们运行这些应用程序,指令要运行, 数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备,从这种角度来说, 进程就是用来加载指令、 管理内存、管理 IO的。
  当一个程序被运行, 从磁盘加载这个程序的代码至内存, 这时就开启了一个进程, 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程 (例如记事本、画图、 浏览器 等),也有的程序只能启动一个实例进程(例如网易云 音乐、 360 安全卫士等)。很显然,程序是死的、静态的,进程是活的、动态的。进程可以分为系统进程和用户进程,凡是用于完成操作系统的各种功能的进程就是系统进程,它们就是处于运行状态下的操作系统本身,用户进程就是所有由我们启动的进程。
如正在运行的360程序就是一个进程
站在操作系统的角度, 进程是系统进行资源分配的最小单位。
线程
  一台计算机中肯定会运行很多的程序, 而CPU 又是有限的, 怎么让有限的CPU 运行这么多程序呢? 就需要一种机制在程序之间进行协调,也就是所谓 CPU 调度。 线程则是 CPU 调度的最小单位。
  线程必须依赖于进程而存在,线程是进程中的一个实体,是 CPU 调度和分派的基本单位,它是比进程更小的、能独立运行的基本单位。进程是能独立存活的,但线程必须依赖进程而活,启动线程是通过在进程内部来启动一个个线程。线程自己基本上不拥有系统资源,只拥有在运行中必不可少的资源(如程序计数器 、一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。 一个进程可以拥有多个线程, 一个线程必须有一个父进程。线程, 有时也被称为轻量级进程 (Lightweight Process ,LWP),早期 Linux 的线程实现几乎就是复用的进程,后来才独立出自己的API。
Java中的线程无处不在,Java 中不管任何程序都必须启动一个 main 函数的主线程;Java Web 开发里面的定时任务、定时器、JSP 和 Servlet、异步消息处理机制,远程访问接口 RM 等, 任何一个监听事件,onclick 的触发事件等都离不开线程和并发的知识。
大厂面试题:进程间的通讯

同一台计算机的进程通信称为 IPC(Inter-process communication),不同计算机之间的进程通信被称为 R(mote)PC,需要通过网络,并遵守共同的协议,比如大家熟悉的 Dubbo 就是一个 RPC 框架, 而 Http 协议也经常用在 RPC 上, 比如 SpringCloud 微服务。

大厂常见的面试题就是,进程间通信有几种方式?

1. 管道, 分为匿名管道(pipe)及命名管道(named pipe):匿名管道可用于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外, 它还允许无亲缘关系进程间的通信。

2. 信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式, 用于通知进程有某事件发生, 一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的。

3. 消息队列(message queue):消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点, 具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。

4. 共享内存(shared memory):可以说这是最有用的进程间通信方式。它 使得多个进程可以访问同一块内存空间, 不同进程可以及时看到对方进程中对共 享内存中数据得更新。这种方式需要依靠某种同步操作, 如互斥锁和信号量等。

5. 信号量(semaphore):主要作为进程之间及同一种进程的不同线程之间得同步和互斥手段。

6. 套接字(socket):这是一种更为一般的进程间通信机制, 它可用于网络中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用 Unix domain socket(比如同一机器中 MySQL 中的控制台 mysql shell 和 MySQL 服务程序的连接),这种方式不需要经过网络协议栈, 不需要打包拆包、计算校验、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。

1.2. CPU 核心数和线程数的关系

  目前主流 CPU 都是多核的, 线程是 CPU 调度的最小单位。同一时刻, 一个 CPU 核心只能运行一个线程, CPU 内核和同时运行的线程数是 1:1 的关系,也就是说 6 核 CPU同时可以执行 6个线程的代码。但Intel 引入超线程技术后,产生了逻辑处理器的概念,即将一个物理核心视为两个逻辑核心, 使核心数与线程数形成 1:2 的关系。 在上面的 Windows 任务管理器截图就能看出来,内核数是 6 而逻辑处理器数是 12。
  在 Java 中提供了 Runtime.getRuntime().availableProcessors(),可以让我们获取当前的 CPU 核心数, 注意这个核心数指的是逻辑处理器数。 获得当前的 CPU 核心数在并发编程中很重要,并发编程下的性能优化往往和 CPU 核心数密切相关。

1.3. 什么是上下文切换(Context switch)

上下文

  首先,需要讲清楚什么是上下文?CPU上下文其实是一些环境。每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,这就涉及到 CPU 寄存器和程序计数器(PC):

  • CPU 寄存器是 CPU 内置的容量小、但速度极快的内存,它通过提供对常用值的快速访问来加快计算机程序的执行;
  • 程序计数器会存储 CPU 正在执行的指令位置,或者即将执行的指令位置。

这两个是 CPU 运行任何任务前都必须依赖的环境,因此叫做CPU 上下文。

上下文切换

  既然操作系统要在多个进程(线程) 之间进行调度, 而每个进程(线程)在使用 CPU时总是要使用 CPU 中的资源,比如 CPU 寄存器和程序计数器。这就意味着,操作系统要保证进程(线程)在调度前后的正常执行,所以, 操作系统中就有上下文切换的概念。那么,什么是上下文切换呢?其实就是在操作系统把一个进程(线程)调度进CPU或从CPU上调度出去的时候需要把进程(线程)的一些相关数据单独保存或者重新载入的一个过程,称之为上下文切换。

下面是一个上下文切换时需要遵循的步骤:

  1. 将前一个 CPU 的上下文(也就是 CPU 寄存器和程序计数器里边的内容)保存起来;
  2. 然后加载新任务的上下文到寄存器和程序计数器;
  3. 最后跳转到程序计数器所指的新位置,运行新任务;

说明:被保存起来的上下文会存储到系统内核中,等待任务重新调度执行时再次加载进来。

CPU 的上下文切换分三种:进程上下文切换、线程上下文切换、中断上下文切换

引发上下文切换的原因一般包括: 线程、进程切换、系统调用等等。上下文切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、 缓存中的来回拷贝。就 CPU 时间而言, 一次上下文切换大概需要 5000~20000 个时钟周期, 相对一个简单指令几个乃至十几个左右的执行时钟周期, 可以看出这个成本的巨大。

补充:物质在1秒内完成周期性变化的次数叫做频率。物理中频率的单位是赫兹(Hz),简称赫,以符号“Hz”表示。常用的频率单位有千赫(KHz)、兆赫(MHz)、吉赫(GHz)等。Hz是一个频率的单位,物体在一秒钟之内振动一次,它的频率就是1Hz。K代表千,KHz即千赫兹;M代表兆,MHz即兆赫兹;还有一个G代表吉,GHz即千兆赫兹。在物理上:千兆赫兹,简写为“GHz”,是交流电或电磁波频率的一个单位,等于十亿赫兹(1,000,000,000 Hz)。KHz,相当于1,000Hz或0.000001GHz;MHz,相当于1,000,000Hz或0.001GHz。在计算机上:GHz即十亿赫兹(10^9 Hz)。GHz是CPU的处理频率,换言之,即CPU的处理速度。

时钟周期:一个时钟脉冲所需要的时间。在计算机组成原理中又叫T周期或节拍脉冲,是CPU和其他单片机的基本时间单位。它可以表示为时钟晶振频率(1秒钟的时钟脉冲数)的倒数(也就是1s/时钟脉冲数,比如1/12MHz),对CPU来说,在一个时钟周期内,CPU仅完成一个最基本的动作。时钟脉冲是计算机的基本工作脉冲,控制着计算机的工作节奏。时钟频率越高,时钟周期就越短,工作速度也就越快。时钟周期在CPU的描述里也叫节拍,即将一个机器周期划分成若干个相等的时间段,每一段仅完成一个基本操作,用一个电平信号宽度对应。举例:(个人理解)工作频率为100MHZ的芯片的时钟周期为10ns(1/100MHZ),那么1s(10的9次方ns)内的时钟周期个数为10的8次方,所以每个时钟周期的时间长为10的9次方ns/10的8次方个时钟周期,结果为10ns。 综上,时钟周期越小越好,这代表着周时间段内处理完成的指令会更多,也就意味着性能越高。

系统调用

Linux 按照特权等级,把进程的运行空间分为内核空间和用户空间:

  • 内核空间:具有最高权限,可以访问所有资源;
  • 用户空间:只能访问受限资源,不能直接访问内存等硬件设备,必须借助系统调用。

进程可以在用户空间运行(叫作:进程用户态),也可以在内核空间运行(叫作:进程内核态)。从用户态到内核态需要系统调用完成。对于一个进程来说,一般是运行在用户态的,但是当需要访问内存、磁盘等硬件设备的时候需要陷入到内核态中,也就是要从用户态到内核态的转变,而这种转变需要通过系统调用来实现。例如一个打开文件的操作,需要调用open()打开文件,read()读取文件内容,write()将文件内容输出到控制台,最后close()关闭文件,这就是系统调用。

系统调用过程中也会发生 CPU 上下文切换。CPU 寄存器会先保存用户态的状态,然后加载内核态相关内容,系统调用结束之后,CPU 寄存器要恢复原来保存的用户态,继续运行进程。所以,一次系统调用,发生两次 CPU 上下文切换。

但是我们一般说系统调用是特权模式切换而不是上下文切换,因为这里不涉及虚拟内存等进程用户态的资源,也不会切换进程,是属于进程之内的上下文切换。与通常所说的进程上下文切换不同:进程上下文切换是指,从一个进程切换到另一个进程,而系统调用过程中一直是同一个进程在运行。

进程上下文切换

  进程是由内核管理和调度的,进程的切换只能发生在内核态。 因此,进程的上下文不但包括虚拟内存、栈、全局变量等用户空间资源,还包括内核堆栈、寄存器等内核空间状态。所以,进程的上下文切换比系统调用多一个步骤:保存当前进程的内核状态和 CPU 寄存器之前,先把该进程的虚拟内存、栈等保存起来;加载下一个进程的内核态后,还需要刷新进程的虚拟内存和用户栈。保存上下文和恢复上下文需要内核在 CPU 上运行才能完成。

Linux 通过 TLB (Translation Lookaside Buffer) 管理虚拟内存到物理内存的映射关系。当虚拟内存更新后,TLB 也需要刷新,内存的访问速度会因此变慢。尤其在多处理器系统上,缓存被多个处理器共享,刷新缓存不仅影响当前处理器的进程,还会影响共享缓存的其他处理器进程。

Linux 会为每个 CPU 都维护一个就绪队列,将活跃进程(正在运行和正在等待 CPU 的进程)按照优先级和等待 CPU 时间来排序,然后选择最需要 CPU 的进程,也就是优先级最高和等待 CPU 时间最长的进程来运行。

进程切换时需要切换上下文,进程切换的场景有:

  • 进程时间片耗尽;
  • 系统资源不足(如内存不足);
  • 进程通过睡眠函数 sleep 把自己挂起来;
  • 当有优先级更高的进程运行时,为了去运行高优先级进程,当前进程会被挂起;
  • 发生硬中断,CPU 上的进程会被挂起,然后去执行内核中的中断服务进程;

线程上下文切换

线程是调度的基本单位,而进程则是资源拥有的基本单位。

内核中的任务调度实际是在调度线程,进程只是给线程提供虚拟内存、全局变量等资源。线程上下文切换时,共享相同的虚拟内存和全局变量等资源不需要修改。而线程自己的私有数据,如栈和寄存器等,上下文切换时需要保存。

线程切换分两种情况:

  • 前后两个线程属于不同进程,所以这时候的线程上下文切换和进程上下文切换是一致的;
  • 前后两个线程属于同一个进程(速度更快,消耗更少资源),因为虚拟内存是共享的,所以在切换的时候,虚拟内存这些资源保持不动,只有切换线程的私有数据、寄存器等不共享的资源

中断上下文切换

  为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,然后调用中断处理程序,响应设备事件。简单来说,就是计算机停下当前的事情,去处理其他的事情,然后在回来继续执行之前的任务。例如,我们在调用print函数的时候,其实汇编的底层会帮我们调用一条 int 0x80的指令,便是调用0x80号中断。当然,中断要先将当前进程的状态保存下来,等中断结束后,进程仍然可以从原来的状态恢复运行。

跟进程上下文不同,中断上下文切换不涉及进程的用户态。所以,即便中断程序打断了正在处于用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源只需要保存和恢复这个进程的内核态中的资源包括CPU寄存器、内核堆栈等。中断上下文,只包括内核态中断服务程序执行所必需的状态,也就是 CPU 寄存器、内核堆栈、硬件中断参数等。

对同一个 CPU 来说,中断处理比进程拥有更高的优先级所以中断上下文切换不会与进程上下文切换同时发生。并且,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便可以尽快完成。当中断上下文切换次数比较多的时候,会耗费大量的CPU。

怎么查看系统上下文

  上面已经介绍到CPU上下文切换分为进程上下文切换、线程上下文切换、中断上下文切换,那么过多的上下文切换会把CPU的时间消耗在寄存器、内核栈以及虚拟内存等数据的保存和恢复上,缩短进程真正运行的时间,成为系统性能大幅下降的一个因素。所以,我们可以使用vmstat这个工具来查询系统的上下文切换情况,vmstat是一个常用的系统性能分析工具,可以用来分析CPU上下文切换和中断的次数。

1.4. 并行和并发

  并发和并行从宏观上来讲都是同时处理多路请求的概念。但并发和并行又有区别,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生

举个例子,例如有条名为G7的高速公路上面并排有 6条车道,那么最大的并行车辆就是 6 辆,此条高速公路 G7 同时并排行走的车辆小于等于 6 辆的时候,车辆就可以并行行驶。 CPU 也是这个原理,一个CPU 就相当于一条名为G7的高速公路 ,而核心数或者线程数就相当于这条高速并排可以通行的车道;而多个CPU 就相当于有多条高速公路,每条高速公路并排有多个车道。

当谈到并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少? 离开了单位时间其实是没有意义的。综合来说:

并发 Concurrent:指应用能够交替执行不同的任务, 比如单 CPU 核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在以你几乎不可能察觉到的速度不断去切换这两个任务, 以达到"同时执行效果"。事实上并不是这样,只是计算机的速度太快,我们无法察觉到而已。

并行 Parallel:指应用能够同时执行不同的任务。

两者区别:一个是交替执行,一个是同时执行,如下图所示。

2. 认识 Java 里的线程

2.1. Java 程序天生就是多线程的

  一个 Java 程序从 main()方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与,但实际上 Java 程序天生就是多线程程序, 执行 main() 方法的是一个名称为 main 的线程。
而一个 Java 程序的运行就算是没有用户自己开启的线程,实际也有很多 JVM 自行启动的线程一般来说有:
[6] Monitor Ctrl-Break --监控 Ctrl-Break 中断信号的
[5] Attach Listener --内存 dump,线程 dump,类信息统计, 获取系统属性等
[4] Signal Dispatcher --分发处理发送给 JVM 信号的线程
[3] Finalizer  --调用对象 finalize 方法的线程
[2] Reference Handler --清除 Reference 的线程
[1] main --main 线程, 用户程序入口

尽管这些线程根据不同的 JDK 版本会有差异, 但是依然证明了 Java 程序天生就是多线程的。

2.2 线程的启动与中止

上面看到的线程都是 JVM 启动的系统线程,我们学习并发编程希望的是自己能操控线程,所以,接下来先来看看如何启动线程。
启动
启动线程的方式有:
1. A extends Thread,通过调用start()方法启动线性
2. B implements Runnable,然后交给 Thread 运行
参考代码如下:
/**
 *类说明:新启线程的方式
 */
public class UserThread {
	/*扩展自Thread类*/
	private static class UseThread extends Thread{
		@Override
		public void run() {
			System.out.println("继承Thread类");
		}
	}

	
	/*实现Runnable接口*/
	private static class UseRunnable implements Runnable{

		@Override
		public void run() {
			System.out.println("实现Runnable接口");

		}
	}
	
	public static void main(String[] args) 
			throws InterruptedException, ExecutionException {
		UseThread useThread = new UseThread();
		useThread.start();

		UseRunnable useRunnable = new UseRunnable();
		new Thread(useRunnable).start();
		System.out.println("main end");

		
	}
}

2.3.Thread 和 Runnable 的区别

  Thread 才是 Java 里对线程的唯一抽象, Runnable 只是对任务(业务逻辑) 的抽象。 Thread 可以接受任意一个 Runnable 的实例并执行。

2.4. Callable 、Future 和 FutureTask

前提说明:Runnable 是一个接口, 在它里面只声明了一个 run()方法。由于Thread 和 Runnable中run()方法的返回值都是void类型,所以在执行完任务之后无法返回任何结果。如果希望在线程执行完之后,拿到返回值,这个时候就需要用到另外一个接口Callable 。
  Callable 位于 java.util.concurrent 包下, 它也是一个接口, 在它里面也只声明了一个方法,只不过这个方法叫做 call() ,这是一个泛型接口, call()函数返回的类型就是传递进来的 V 类型。

这里有一个问题,线程的一大作用就是异步执行,虽然Callable中的call方法会返回任务的执行结果但由于线程是异步执行,主调函数什么时候才可以拿到异步执行的结果,或者说怎么样才能拿到异步执行的结果呢?

为了解决这个问题,JDK又为我们提供了一个Future接口。Future 就是对于具体的 Runnable 或者 Callable 任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过 get 方法获取执行结果, 该方法会阻塞直到任务返回结果。

因为 Future 只是一个接口,所以是无法直接用来创建对象使用的,因此就有了下面的
FutureTask。

FutureTask 类实现了 RunnableFuture 接口,RunnableFuture 继承了 Runnable 接口和 Future 接口,而 FutureTask 实现了 RunnableFuture 接口。所以它既可以作为 Runnable 被线程执行,又可以作为Future 得到 Callable 的返回值。

因此我们通过一个线程运行 Callable ,但是 Thread 不支持构造方法中传递 Callable 的实例,所以
我们需要通过 FutureTask 把一个 Callable 包装成 Runnable , 然后再通过这个 FutureTask 拿到 Callable运行后的返回值。 要 new 一个 FutureTask 的实例,有两种方法

	/*实现Callable接口,允许有返回值*/
	private static class UseCallable implements Callable<Integer>{
		private int sum;
		@Override
		public Integer call() throws Exception {
			System.out.println("Callable子线程开始计算!");
//			Thread.sleep(1000);
	        for(int i=0 ;i<5000;i++){
	            sum=sum+i;
				System.out.println("sum="+sum);
	        }
	        System.out.println("Callable子线程计算结束!结果为: "+sum);
	        return sum;
		}
	}
	
	public static void main(String[] args)
			throws InterruptedException, ExecutionException {

		UseCallable useCallable = new UseCallable();
		//包装
		FutureTask<Integer> futureTask = new FutureTask<>(useCallable);
		Random r = new Random();
		new Thread(futureTask).start();

	    System.out.println("Get UseCallable result = "+futureTask.get());

	}

}

面试题:新启线程有几种方式?

这个问题的答案可能有很多种,有 2 种, 3 种, 4 种等等,这里认为的一种比较好的回答是:按照 Java 源码中 Thread 上的注释

官方说法是在 Java 中有两种方式创建一个线程用以执行, 一种是派生自 Thread 类,另一种是实现 Runnable 接口。

  当然本质上 Java 中实现线程只有一种方式, 都是通过 new Thread()创建线程 对象,调用Thread#start 启动线程。至于基于 callable 接口的方式,因为最终是要把实现了 Callable 接口的对象 通过 FutureTask 包装成 Runnable,再交给 Thread 去执行,所以这个其实可以和 实现 Runnable 接口看成同一类。而线程池的方式, 本质上是池化技术, 是资源的复用, 和新启线程没什么关系。所以,比较赞同官方的说法,有两种方式创建一个线程用以执行。

2.5. 中止

2.5.1. 线程自然终止
  • 写在run方法中的代码全部执行完成,也就是run()执行完成了
  • 写在run()中的代码抛出了一个未处理的异常导致线程提前结束。
2.5.2. 使用stop

  使用stop方法强行终止。线程在执行的时候会占用资源,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会, 因此会导致程序可能工作在不确定状态下。例如:线程正在往磁盘上写一个文件,这个文件大小是2M,当文件写到1M大小时,执行了stop()方法突然中止了,此时文件没有写完,也没有加上文件结束符之类的标识,这就会导致这个文件会成为一个只有开始标识符没有结束符的不完整文件,操作系统无法判断该文件到底有没有结束,那么这个文件可能就损坏了。

  暂停、恢复和停止操作对应在线程 Thread 的 API 就是 suspend() 、resume() 和 stop(),但是这些API 是过期的,也就是不建议使用的。不建议使用的原因主要有:以 suspend()方法为例,在调用后(使用suspend方法挂起线程,操作系统会把该线程调度出当前CPU的核心,不再给它分配CPU的执行时间),导致线程暂停的同时,并不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。正因为 suspend()、 resume()和 stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方法。

2.5.3. 通过中断

 java程序中如何安全的中止一个线程?

  通过中断操作。安全的中止,则是其他线程通过调用某个线程 A 的 interrupt()方法对其进行中断操作,中断好比其他线程对该线程打了个招呼,“A,你要中断了”,不代表线程 A 会立即停止自己的工作,同样的,A线程完全可以不理会这种中断请求。为什么这么说呢?因为interrupt()对目标线程发起中断,它的作用其实只是设置目标线程的中断标识位为true,并不会真正中断我们的目标线程。我们的目标线程,是需要在自己线程的业务代码中的合适的位置,去检查这个中断标识位,通过检查自身的中断标识位是否被设置为 true 来进行相应的操作,进而优雅的中止目标线程。

线程通过方法 isInterrupted()来进行判断是否被中断,也可以调用静态方法 Thread.interrupted()来进行判断当前线程是否被中断,不过 Thread.interrupted() 会同时将中断标识位改写为 false。

而像Thread.sleep()、Thread.join()、wait() 等这些阻塞类的方法都会抛InterruptedException异常。如果目标线程处于了阻塞状态(在目标调用了这些阻塞方法)一但在外部通过interrupt()中断目标线程的时候,会在这些阻塞方法调用处会抛出InterruptedException异常,这个时候即使线程被阻塞,也可以很快的从阻塞状态被唤醒,来处理中断事宜。并且在抛出异常的同时会将线程的中断标识位改为false。

补充说明:JDK有一些方法会去检查中断状态,如果设置为true时,就会自动抛出InterruptedException。比如,阻塞方法wait/sleep/join等。

不建议自定义一个标识位来中止线程的运行。因为当run 方法里有类似Thread.sleep()这种阻塞类型的方法调用时,可以认为当前线程被操作系统挂起,不给他分配任何的CPU运行时间了。这时,即使将自定义的标识位做了修改(如由false改为true),当前线程也无法很快检测到标识位的变化,因为线程被阻塞了,不会继续往下运行了,它完全不知道自定义的标识位已由false改为true。要中止线程的运行,线程必须从阻塞调用返回后, 才会检查这个取消标识。通过这种自定义标识位来中止线程运行,它的响应会非常迟钝。这种情况下,使用中断会更好,因为:

1. 一般的阻塞方法,如 sleep() 等本身就支持中断的检查,可以快速的响应中断标识位的变化;

2. 检查中断位的状态和检查取消标志位没什么区别, 用中断位的状态还可以避免声明取消标志位,减少资源的消耗;

注意:处于死锁状态的线程无法被中断

/**
 *类说明:如何安全中断线程
 */
public class ThreadDemo {
	
	private static class UseThread extends Thread{
		/**
		 * 用于演示不建议自定义一个标识位来中止线程的运行
		 */
		private boolean cancel;

		public UseThread(String name) {super(name);}

		public void setCancel(boolean cancel) {
			this.cancel = cancel;
		}

		@Override
		public void run() {
			String threadName = Thread.currentThread().getName();
			System.out.println(threadName + "的" + " interrrupt flag ="+isInterrupted());
			while(!isInterrupted()){
//			while(!Thread.interrupted()){
//			while(true){
				System.out.println(threadName+" is running,内部的中断标识位为:" + isInterrupted());

			}
			//System.out.println(threadName+" interrrupt flag ="+isInterrupted());
		}
	}

	public static void main(String[] args) throws InterruptedException {
		UseThread endThread = new UseThread("endThread");
		endThread.start();
		Thread.sleep(20);
		//interrupt()并不会真正的结束目标线程的运行,只是设置目标线程的中断标识位为true
		endThread.interrupt();
	}

}

/**
 *类说明:阻塞方法中抛出InterruptedException异常的同时会将线程的中断标识位改为false.
 * 如果需要继续中断,需要手动再中断一次,
 */
public class InterrputExceptionDemo {

	private static class UseThread extends Thread {

		public UseThread(String name) {
			super(name);
		}

		@Override
		public void run() {
			while (!isInterrupted()) {
				try {
					Thread.sleep(1000);
					System.out.println("sleep方法之后打印...");
				} catch (InterruptedException e) {
					System.out.println(Thread.currentThread().getName() + " 抛出了中断异常,中断标识为 " + isInterrupted());
//					interrupt();
					e.printStackTrace();
				}
				System.out.println(Thread.currentThread().getName() + " 正在运行中...");
			}
			//while 循环结束后才会打印本句
			System.out.println(Thread.currentThread().getName() + " 中断标识为 " + isInterrupted());
		}
	}

	public static void main(String[] args) throws InterruptedException {
		Thread endThread = new UseThread("useThread");
		endThread.start();
		Thread.sleep(500);
		endThread.interrupt();
	}

}

面试题:如何停止一个正在运行的线程?

1. 当run方法完成后,线程终止。

2. 使用stop方法强行终止,但是不推荐这个方法,stop和suspend及resume一样都是过期作废的方法。

3. 使用interrupt方法中断线程。

2.6. 深入理解run()和start()

  Thread 类是Java 里对线程概念的抽象,可以这样理解:我们通过 new Thread() 创建出一
个Thread 的实例时,只是在JVM堆上,有一个Thread类型的对象而已,还没有和操作系统中真正的线程联系起来。 只有执行了 start()方法后(在start()内通过调用start0()),才是真正把Thread的对象和操作系统中具体的线程一对一的联系起来,才实现了真正意义上的启动线程。

  从 Thread 的源码可以看到, Thread 的 start 方法中调用了 start0()方法,而 start0()是个 native 方法, 这就说明 Thread#start 一定和操作系统是密切相关的。

  start()方法让一个线程进入就绪队列等待分配 cpu,分到 cpu 后才调用实现 的 run()方法,start()方法不能重复调用,如果重复调用会抛出异常(注意, 此处 可能有面试题:多次调用一个线程的 start 方法会怎么样? )。

  而 run 方法是业务逻辑实现的地方, 本质上和任意一个类的任意一个成员方法并没有任何区别,
可以重复执行,也可以被单独调用。之所以单独定义一个run()方法,是为了让线程知道,在它启动了以后,去哪里执行对应的业务代码。
面试题:多次调用一个线程的 start 方法会怎么样?
  我们通过 new Thread() 创建出一个Thread 的实例时,只是在JVM堆上,有一个Thread类型的对象而已,还没有和操作系统中真正的线程联系起来, 只有执行了 start()方法后(在start()内通过调用start0()),才是真正把Thread的对象和操作系统中具体的线程一对一的联系起来。一个Thread的对象实例只能和一个线程映射,不能和两个线程对应,所以start()方法不允许执行两次,如果重复调用会抛出异常。

3. 深入学习 Java 的线程

3.1. 线程的状态/生命周期

Java 中线程的状态分为 6 种:
1. 初始(NEW):新创建了一个线程对象,但还没有调用 start()方法。
2. 运行(RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运
行”。
线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。 该状态的线程位于可
运行线程池中, 等待被线程调度选中, 获取 CPU 的使用权, 此时处于就绪状态(ready),这个时候是可以运行,只是还没有被分配到CPU的时间片。就绪状态的线程在获得 CPU 时间片后变为运行中 状态(running)。
3. 阻塞(BLOCKED):表示线程阻塞于锁。
4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作 (通知或中断)。
5. 超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时间后自行返回。
6. 终止(TERMINATED):表示该线程已经执行完毕。

这6种状态定义在Thread类的State枚举中,可查看源码进行一一对应。

线程的状态图     

状态之间的变迁如下图所示

  掌握这些状态,可以让我们在进行 Java 程序调优时可以提供很大的帮助。例如,我们可能会经常遇到这样的问题java程序卡顿、假死,这就意味着当前应用中相应的线程没有正常工作了,要查看线程为什么没有正常工作,就需要通过JDK为我们提供的工具去查看当前应用的线程处于什么状态。

jstack,打开当前正在运行的java程序的线程栈,会把当前java程序中的每个线程当前处于什么样的状态显示出来。

状态详细说明

1. 初始状态(NEW)

实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。

2.1. 就绪状态(RUNNABLE之READY)

就绪状态只是说你有资格运行,调度程序没有挑选到你,你就永远是就绪状态。调用线程的start()方法,此线程进入就绪状态。
当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。锁池里的线程拿到对象锁后,进入就绪状态。

2.2. 运行中状态(RUNNABLE之RUNNING)

线程调度程序从可运行池中选择一个线程作为当前线程时,线程所处的状态。这也是线程进入运行状态的唯一的一种方式。

3. 阻塞状态(BLOCKED)

阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。

4. 等待(WAITING)

处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

5. 超时等待(TIMED_WAITING)

处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。

6. 终止状态(TERMINATED)

当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是它已经不是一个单独执行的线程。线程一但终止了,就不能复生。在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

3.2. 其他的线程相关方法

yield()方法:使当前线程让出 CPU 占有权, 但让出的时间是不可设定的,也不会释放锁资源。同时,执行 yield()的线程,会进入就绪状态,但有可能在进入到就绪状态后会被操作系统再次选中马上又被执行。例如:某个线程当前正在运行着代码,处于RUNNING状态,当执行yield()后,线程会把当前CPU的占用权让出来,交给其它的线程去使用。

ConcurrentHashMap#initTable 方法中就使用了这个方法。这是因为 ConcurrentHashMap 可能被多个线程同时初始化 table,其实这个时候只允许一个线程进行初始化操作, 而其他的线程就需要被阻塞或等待, 但是初始化操作其实很快。 这里为了避免阻塞或者等待这些操作引发的上下文切换等等开销, 就让其他不执行初始化操作的线程干脆执行 yield() 方法,以让出 CPU 执行权,让执行初始化操作的线程可以更快的执行完成。

说明:当new出一个ConcurrentHashMap实例时,对于ConcurrentHashMap而言,它并没有真正给ConcurrentHashMap底层的数据结构:如数组,分配空间。只有在执行put()方法,往里插入第一个元素时,会调用initTable(), ConcurrentHashMap才真正开始对这个数组分配空间,进行初始化操作。那么在多线程并发环境下,同时执行put()方法的线程可能有很多个,但是数组(Node<K,V>[] tab)在ConcurrentHashMap里面是只有一个的,对于数组初始化只需要一个线程来做就可以了。
ConcurrentHashMap是为了应对并发下面多个线程同时操作HashMap而产生的,是线程安全的。

3.3 线程的优先级

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

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

3.4 线程的调度

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

  • 协同式线程调度(Cooperati ve Threads-Scheduling)
  • 抢占式线程调度(Preempti ve Threads-Scheduling)

  使用协同式线程调度的多线程系统, 线程执行的时间由线程本身来控制, 线程把自己的工作执行完之后, 要主动通知系统切换到另外一个线程上。使用协同式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系统进行线程切换, 所以没有线程同步的问题。 但是坏处也很明显:例如,一个线程执行周期很长,一直占着CPU使用权,导致其它线程在很长时间内,得不到运行。或者说一个线程在CPU上运行的时候,是不能保证它运行过程中的每个时刻都是正常的,有时候可能会出现问题,导致它无法执行让出CPU的代码,则程序就会一直阻塞。这些都是协同式线程的劣势。

  使用抢占式线程调度的多线程系统, 每个线程执行的时间以及是否切换都由系统决定。操作系统负责对每个线程使用CPU的时间做一个管理,由OS强制的在多个线程之间,进行CPU时间的调度,在这种情况下, 线程的执行时间不可控, 所以不会有「一个线程导致 整个进程阻塞」的问题出现。

  在 Java 中, Thread.yield()可以让出 CPU 执行时间,但是对于获取执行时间, 线程本身是没有办法的。对于获取 CPU 执行时间,线程唯一可以使用的手段是设置线程优先级, Java 设置了 10 个级别的程序优先级,当两个线程同时处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。

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

  我们已经知道线程其实是操作系统层面的实体, Java 中的线程怎么和操作系统层面对应起来呢?任何语言实现线程主要有三种方式:使用内核线程实现(1:1 实现),使用用户线程实现(1:N 实现),使用用户线程加轻量级进程混合实现(N:M 实现)。

3.5 线程的3种实现方式-内核级线程, 用户级线程和混合型线程

  在传统的操作系统中,拥有资源和独立调度的基本单位都是进程。在引入线程的操作系统中,线程是独立调度的基本单位,进程是资源拥有的基本单位。在同一进程中,线程的切换不会引起进程切换。在不同进程中进行线程切换,如从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换。

根据操作系统内核是否对线程可感知,可以把线程分为内核线程用户线程

名称描述
用户级线程(User-LevelThread, ULT)由应用程序所支持的线程实现, 内核意识不到用户级线程的实现
内核级线程(Kemel-LevelThread, KLT)内核级线程又称为内核支持的线程

  有些情况下,也把内核级线程叫做轻量级进程(LWP),但这个是一个不准确的描述,其实LWP的术语是借自于SVR4/MP和Solaris 2.x系统中,有些系统将LWP称为虚拟处理器,将之称为轻量级进程的原因可能是,在内核线程的支持下,LWP是独立的调度单元,就像普通的进程一样。所以,LWP的最大特点还是每个LWP都有一个内核线程支持。

内核级线程实现

  使用内核线程实现的方式也被称为 1:1 实现(一个语言层面的线程实例对应一个操作系统层面的线程实体)。内核线程(Kernel-Level Thread , KLT) 就是直接由操作系统内核(Kernel , 下称内核) 支持的线程,这种线程由内核来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进行调度, 并负责将线程的任务映射到各个处理器上。线程其实是操作系统层面的实体。既然操作系统已经为我们提供了线程,那我们就将语言层面线程的实现和操作系统层面线程的实现做1:1映射,Java语言层面new Thread(),执行start()方法后,在操作系统里面就开启一个新线程。有关线程调度等一系列事情,语言层面都不用去考虑,操作系统会去调度,只需专心将语言层面用户的代码编译好交给操作系统执行即可。

在内核级线程中,内核线程建立和销毁都是由操作系统负责,通过系统调用完成的。在内核的支持下运行,无论是用户进程的线程,或者是系统进程的线程,他们的创建、撤销、切换都是依靠内核实现的。由于内核线程的支持, 每个线程都成为一个独立的调度单元, 即使其中某一个在系统调用中被阻塞了, 也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已经由操作系统处理了。

线程管理的所有工作由内核完成,应用程序没有进行线程管理的代码,只有一个到内核级线程的编程接口。 内核为进程及其内部的每个线程维护上下文信息,调度也是在内核基于线程架构的基础上完成。

内核线程驻留在内核空间,它们是内核对象。有了内核线程,每个用户线程被映射或绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。一旦用户线程终止,两个线程都将离开系统。这被称作”一对一”线程映射,

1)线程的创建、撤销和切换等,都需要内核直接实现,即内核了解每一个作为可调度实体的线程;

2)这些线程可以在全系统内进行资源的竞争;

3)内核空间内为每一个内核支持线程设置了一个线程控制块(TCB),内核根据该控制块,感知线程的存在,并进行控制;

如图所示,即内核级线程的实现方式,每个用户线程都直接与一个内核线程相关联

操作系统调度器管理、调度并分派这些线程。运行时库为每个用户级线程请求一个内核级线程。

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

用户线程实现

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

  用户级线程仅存在于用户空间中,此类线程的创建、撤销、线程之间的同步与通信功能,都无须利用系统调用来实现。用户进程利用线程库来控制用户线程。由于线程在进程内切换的规则远比进程调度和切换的规则简单,不需要用户态/核心态切换,所以切换速度快。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少,为了在操作系统中加入线程支持,采用了在用户空间增加运行库来实现线程,这些运行库被称为“线程包”,用户线程是不能被操作系统所感知的。用户线程多见于一些历史悠久的操作系统,例如Unix操作系统

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

下图说明了用户级线程的实现方式

如同在图中看到的那样,库调度器从进程的多个线程中选择一个线程,然后该线程和该进程允许的一个内核线程关联起来。内核线程将被操作系统调度器指派到处理器内核。用户级线程是一种”多对一”的线程映射。

混合实现

  线程除了依赖内核线程实现和完全由用户程序自己实现之外, 还有一种将内核线程与用户线程一起使用的实现方式, 被称为 N:M 实现。    在这种混合实现下, 既存在用户线程, 也存在内核线程。用户线程还是完全建立在用户空间中, 因此用户线程的创建、 切换、 析构等操作依然廉价, 并且可以支持大规模的用户线程并发。同样又可以使用内核提供的线程调度功能及处理器映射, 并且用户线程的系统调用要通过内核线程来完成。在这种混合模式中, 用户线程与轻量级进程的数量比是不定的,是 N:M 的关系。

下图说明了用户级与内核级的组合实现方式

Java 线程的实现

  Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前),是用户线程实现的, 但从 JDK 1.3 起, 主流商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1: 1 的线程模型。
  以 HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构, 所以 HotSpot 自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。所以,这就是我们说 Java 线程调度是抢占式调度的原因。而且 Java 中的线程优先级是通过映射到操作系统的原生线程上实现的, 所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,所以 Java 优先级并不是特别靠谱。

3.6. 协程

出现的原因
  随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。 比如, 互联网服务架构在处理一次对外部业务请求到响应, 往往需要分布在不同机器上的大量服务共同协作来实现, 也就是我们常说的微服务, 这种服务细分的架构在减少单个服务复杂度、 增加复用性的同时, 也不可避免地增加了服务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算, 这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求, 这样才不会出现请求由于某个服务被阻塞而出现等待。
  Java 目前的并发编程机制就与上述架构趋势产生了一些矛盾, 1:1的内核线程模型是如今Java 虚拟机线程实现的主流选择, 但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂, 系统能容纳的线程数量也很有限。 以前处理一个请求可以允许花费很长时间在单体应用中, 具有这种线程切换的成本也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多的前提下, 用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销, 这就会造成 严重的浪费。
  我们常见的 Java Web 服务器,比如 Tomcat 的线程池的容量通常在几十个到两百之间, 当数以百万计的请求到线程池里面时, 系统即使能处理得 过来,但其中的切换损耗也是相当可观的。 这样的话,对 Java 语言来说,用户线程的重新引入成为了解决上述问题一个非常可行的方案。 其次, Go 语言等支持用户线程等新型语言给 Java 带来了巨大的压力, 也使得 Java 引入用户线程成为了一个绕不开的话题。
协程简介
  为什么用户线程又被称为协程呢?我们知道, 内核线程的切换开销是来自于保护和恢复线程的成 本, 那如果改为采用用户线程, 这部分开销就能够省略掉吗? 答案还是“不能”。 但是, 一但把保护、恢复线程及调度的工作从操作系统交到程序员手上, 则可以通过很多手段来缩减这些开销。由于最初多数的用户线程是被设计成协同式调度(Cooperati ve Scheduling) 的,所以它有了一个别名—— “协程”(Corouti ne) 完整地做调用栈的保护、 恢复工作,所以今天也被称为“有栈协程” (Stackfull Corouti ne)。 协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。如 果进行量化的话, 那么如果不显式设置,则在 64 位 Linux 上 HotSpot 的线程栈容量默认是 1MB ,此外内核数据结构(Kernel Data Structures) 还会额外消耗 16KB 内存。与之相对的,一个协程的栈通常在几百个字节到几 KB 之间, 所以 Java 虚拟机里线程池容量达到两百就已经不算小了, 而很多支持协程的应用中, 同时并存的协程数量可数以十万计。
  协程当然也有它的上也存在。局限, 需要在应用层面实现的内容(调用栈、 调度器这些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程
  总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io), 不适合大量计算的场 景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)。
纤程-Java 中的协程
  在 JVM 的实现上,以 HotSpot 为例, 协程的实现会有些额外的限制, Java 调用栈跟本地调用栈是做在一起的。 如果在协程中调用了本地方法, 还能否正常切换协程而不影响整个线程? 另外, 如果协程中遇传统的线程同步措施会怎样? 譬如 Kotlin 提供的协程实现, 一但遭遇 synchronize 关键字, 那挂起来的仍将是整个线程。

  所以 Java 开发组就 Java 中协程的实现也做了很多努力, OpenJDK 在 2018 年 创建了 Loom 项目,这是 Java 的官方解决方案, 并用了“纤程(Fiber)”这个 名字。

  Loom 项目背后的意图是重新提供对用户线程的支持, 但这些新功能不是为了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在 Java 虚拟机中并存, 可以在程序中同时使用。 新模型有意地保持了与目前线程模型相似 的 API 设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型。

  根据 Loom 团队在 2018 年公布的他们对 Jetty 基于纤程改造后的测试结果, 同样在 5000QPS 的压力下, 以容量为 400 的线程池的传统模式和每个请求配以 一个纤程的新并发处理模式进行对比, 前者的请求响应延迟在 10000 至 20000 毫秒之间, 而后者的延迟普遍在 200 毫秒以下。

  目前 Java 中比较出名的协程库是 Quasar[ˈkweɪzɑː(r)](Loom 项目的 Leader 就是 Quasar 的作者 Ron Pressler), Quasar 的实现原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖 Java 虚拟机的线程保护虽然能够工作,但影响性能。

接下来通过模拟一个业务场景来,来比较两种实现

  在具体的业务场景上, 我们模拟调用某个远程的服务, 假设远程服务处理耗时需要 1S,使用休眠 1S 来代替。为了比较, 用多线程和协程分别处理10000 个调用这个服务的任务,来看看两者所需的耗时。

Quasar 实现:

本实战的代码是单独的项目 quasar。Quasar 的使用其实并不复杂,首先引入 Maven 依赖

  在执行 Quasar 的代码前,还需要配置 VM 参数(Quasar 的实现原理是字节码注入,所以,在运行应用前,需要配置好 quasar-core 的 java agent 地址,-javaagent:D:\Maven\repository\co\paralleluniverse\quasar-core\0.7.9\quasar- core-0.7.9.jar,自己运行前记得修改为本机的 Maven 仓库路径。

public class FiberExample {

    public static void main(String[] args) throws Exception{
        CountDownLatch count  = new CountDownLatch(10000);
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        IntStream.range(0,10000).forEach(i-> new Fiber() {
            @Override
            protected String run() throws SuspendExecution, InterruptedException {
                //Quasar中Thread和Fiber都被称为Strand,Fiber不能调用Thread.sleep休眠
                Strand.sleep(1000 );
                count.countDown();
                return  "";
            }
        }.start());
        count.await();
        stopWatch.stop();
        System.out.println("结束了: " + stopWatch.prettyPrint());
    }

}

  其中的 Fiber 就是 Quasar 为我们提供的协程相关的类,可以类比为 Java 中 的 Thread 类。其他的 CountDownLatch(闭锁,线程的某种协调工具类)、Executors.newCachedThreadPool(线程池) 。StopWatch 是 Spring 的一个工具类, 一个简单的秒表工具, 可以计时指定代码段的运行时间以及汇总这个运行时间。

线程实现:

public class ThreadExample {

    public static void main(String[] args) throws Exception{
        CountDownLatch count  = new CountDownLatch(10000);
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        //ExecutorService executorService = Executors.newCachedThreadPool();
        ExecutorService executorService = Executors.newFixedThreadPool(200);
        //往线程池中提交一万个任务
        IntStream.range(0,10000).forEach(i-> executorService.submit(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException ex) { }
            count.countDown();
        }));
        count.await();
        stopWatch.stop();
        System.out.println("结束了: " + stopWatch.prettyPrint());
        executorService.shutdownNow();
    }
}

上面的代码很容易理解,这些代码的业务含义: 调用远程服务10000 次,每次耗时 1S,然后统计总耗时。

  可以看到性能的提升还是非常明显的。而且上面多线程编程时, newCachedThreadPool()并没有指定线程池的大小, 在实际开发中是绝不允许的。一般我们会设置一个固定大小的线程池, 因为线程资源是宝贵, 线程多了费内存还会带来线程切换的开销。上面的场景在设置 200 个固定大小线程池时(Executors.newFixedThreadPool(200)),在本机的测试结果达到了 50 多秒,几乎是数量级的增加。

由这个结果也可以看到协程在需要处理大量 IO 的情况下非常具有优势, 基于固定的几个线程调度, 可以轻松实现百万级的协程处理, 而且内存消耗非常平稳。

JDK19 的虚拟线程

  2022 年 9 月 22 日,JDK19(非 LTS 版本) 正式发布,引入了协程,并称为轻量级虚拟线程。但是这个特性目前还是预览版, 还不能引入生成环境。这里只讲述基本用法和原理

要使用的话,需要通过使用 javac --release 19 --enable-preview XXX.java 编译程序,并使用 java --enable-preview XXX 运行该程序。

在具体使用上和原来的 Thread API 差别不大:java.lang.Thread.Builder,可以创建和启动虚拟线程,例如:Thread thread = Thread.ofVirtual().name("thread-1").unstarted(runnable);

更新的JDK文档中也把原来的Thread称为Platform Thread,可以更明确的与Virtual Thread 区分开来。这里的Platform Thread直译为“平台线程”,其实就是虚拟线程出现之前老生常谈的线程,而Thread.ofPlatform() 则是创建传统线程的实现 或者  Thread.startVirtualThread(Runnable)。线程池也支持了虚拟线程,可以通过Executors.newVirtualThreadPerTaskExecutor()提供了虚拟线程。

  在具体实现上, 虚拟线程当然是基于用户线程模式实现的,JDK 的调度程序不直接将虚拟线程分配给处理器, 而是将虚拟线程分配给实际线程,是一个 M: N 调度,具体的调度程序由已有的 ForkJoinPool 提供支持。

  但是虚拟线程不是协同调度的, JDK 的虚拟线程调度程序通过将虚拟线程挂载到平台线程上来分配要在平台线程上执行的虚拟线程。在运行一些代码之后, 虚拟线程可以从其载体卸载。此时平台线程是空闲的, 因此调度程序可以在其上挂载不同的虚拟线程,从而使其再次成为载体。

  通常,当虚拟线程阻塞 I/O 或 JDK 中的其他阻塞操作(如 BlockingQueue.take ())时,它将卸载。当阻塞操作准备完成时(例如,在套接字上已经接收到字节) , 它将虚拟线程提交回调度程序, 调度程序将在运营商上挂载虚拟线程以恢复执行。 虚拟线程的挂载和卸载频繁且透明,并且不会阻塞任何 OS 线程。

3.7. 守护线程

   Daemon(守护) 线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。 这意味着,当一个 Java 虚拟机中,目前正在运行的线程全部都是守护线程,不存在非 Daemon 线程的时候, Java 虚拟机就会停止工作,将会退出。而如果这个JVM中,只要有一个非守护线程工作的时候,JVM就不会退出。
  守护线程的作用就是做支持,支持非守护线程的工作,当非守护线程都已经停止工作了,也就没有支持的对象了,所以Java 虚拟机也将会退出。如果没有守护线程(这里指的是这些做辅助工作的线程也是非守护线程),也就意味着程序可能永远无法自己终止了,因为这些做辅助工作的线程总是存在JVM中,JVM永远无法停止。
  可以通过调用 Thread.setDaemon(true)将线程设置 为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是 Daemon 线程。 Daemon 线程被用作完成支持性工作, 但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行。在构建 Daemon 线程时, 不能依靠 finally 块中 的内容来确保执行关闭或清理资源的逻辑。
  
如下图框中部分为守护线程


如下,主线程退出后当前应用程序不会停止,因为useThread的这个线程还在一直执行。
改成守护线程后(useThread.setDaemon(true)),主线程结束后,创建的UserThread线程也跟着结束了,整个应用程序也就停止了。

/**
 *类说明:守护线程的使用
 */
public class DaemonThread {
	private static class UseThread extends Thread{
		@Override
		public void run() {
			try {
				while (true) {
					System.out.println(Thread.currentThread().getName() 
							+ "...");
				}
			} finally {
				//守护线程中finally不一定起作用
				System.out.println("finally执行...");
			}
		}
	}
	
	public static void main(String[] args) 
			throws InterruptedException, ExecutionException {
		UseThread useThread = new UseThread();
//		useThread.setDaemon(true);
		useThread.start();
		Thread.sleep(1000);
		System.out.println("主线程结束...");
	}
}

  • 27
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值