从0开始深入理解并发、线程与等待通知机制
基础概念
进程和线程
进程
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
进程可以视为程序的一个实例。大部分程序可以同时运行多个实例进程,也有的程序只能启动一个实例线程。
显然,程序是死的、静态的,进程是活的、动态的。站在操作系统的角度,进程是程序运行资源分配(以内存为主)的最小单位。
进程可以分为系统进程和用户进程。
系统进程
凡是用于完成操作系统的各种功能的进程就是系统进程,它们就是处于运行状态下的操作系统本身。
用户进程
用户进程就是所有由你启动的进程。
线程
通过CPU调度的机制在程序之间进行协调,可以让有限的CPU运行很多的程序。线程则是CPU调度的最小单位。
线程必须依赖于进程而存在,线程是进程中的一个实体,是CPU调度和分派的基本单位,它是比进程更小的、能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。一个进程可以拥有多个线程,一个线程必须有一个父进程。线程,有时也被称为轻量级进程(Lightweight Process,LWP),早期Linux的线程实现几乎就是复用的进程,后来才独立出自己的API。
Java线程的无处不在
Java中不管任何程序都必须启动一个main函数的主线程; Java Web开发里面的定时任务、定时器、JSP和 Servlet、异步消息处理机制,远程访问接口RM等,任何一个监听事件,onclick的触发事件等都离不开线程和并发的知识。
CPU核心数和线程数的关系
CPU内核和同时运行的线程数是1:1的关系。8核CPU同时可以执行8个线程的代码。
Intel拥有超线程技术,产生了逻辑处理器的概念,使核心数与线程数形成1:2的关系。
在Java中提供了Runtime.getRuntime().availableProcessors(),可以让我们获取当前的CPU核心数,注意这个核心数指的是逻辑处理器数。
获得当前的CPU核心数在并发编程中很重要,并发编程下的性能优化往往和CPU核心数密切相关。
上下文切换(Context switch)
既然操作系统要在多个进程(线程)之间进行调度,而每个线程在使用CPU时总是要使用CPU中的资源,比如CPU寄存器和程序计数器。这就意味着,操作系统要保证线程在调度前后的正常执行,所以,操作系统中就有上下文切换的概念,它是指CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。
上下文是CPU寄存器和程序计数器在任何时间点的内容。
寄存器是CPU内部的一小部分非常快的内存(相对于CPU内部的缓存和CPU外部较慢的RAM主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。
程序计数器是一种专门的寄存器,它指示CPU在其指令序列中的位置,并保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。
上下文切换可以更详细地描述为内核(即操作系统的核心)对CPU上的进程(包括线程)执行以下活动:
- 暂停一个进程的处理,并将该进程的CPU状态(即上下文)存储在内存中的某个地方 。
- 从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它。
- 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。
从数据来说,以程序员的角度来看, 是方法调用过程中的各种局部的变量与资源。 以线程的角度来看,是方法的调用栈中存储的各类信息。
引发上下文切换的原因一般包括:线程、进程切换、系统调用等等。上下文切换通常是计算密集型的,因为涉及一系列数据在各种寄存器,缓存中的来回拷贝。就CPU时间而言,一次上下文切换大概需要5000 ~ 20000个时钟周期,相对一个简单指令几个乃至十几个左右的执行时钟周期,可以看出这个成本的巨大。
并行和并发
并行(Parallel)
指应用能够同时执行不同的任务。
例:吃饭的时候可以边吃饭边打电话,这两件事情可以同时执行
并发(Concurrent)
指应用能够交替执行不同的任务,比如单CPU核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务,已达到同时执行效果,其实并不是的,只是计算机的速度太快,我们无法察觉到而已。
当谈论并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少?离开
了单位时间其实是没有意义的。
并发并行两者区别:一个是交替执行,一个是同时执行,如下图所示。
进程间的通信
同一台计算机的进程通信称为 IPC(Inter-process communication),不同计算机之间的进程通信被称为R(mote)PC,需要通过网络,并遵守共同的协议,比如大家熟悉的Dubbo就是一个RPC框架,而Http协议也经常用在RPC上,比如SpringCloud微服务。
进程间通信的几种方式:
- 管道:
- 匿名管道(pipe):
匿名管道可用于具有亲缘关系的父子进程间的通信。 - 命名管道(named pipe):
命名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。
- 信号(signal):
信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的。 - 消息队列(message queue):
消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。 - 共享内存(shared memory):
可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。 - 信号量(semaphore):
主要作为进程之间及同一种进程的不同线程之间得同步和互斥手段。 - 套接字(socket):
这是一种更为一般得进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用Unix domain socket(比如同一机器中MySQL中的控制台mysql shell和MySQL服务程序的连接),这种方式不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。
认识Java里的线程
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程序天生就是多线程的。
线程的启动与中止
启动
启动线程的方式有:
-
X extends Thread;,然后 X.start
-
X implements Runnable;然后交给 Thread 运行
Thread 和 Runnable 的区别:
Thread 才是 Java 里对线程的唯一抽象,Runnable 只是对任务(业务逻辑)的抽象。Thread 可以接受任意一个 Runnable 的实例并执行。
Callable、Future 和 FutureTask
Runnable 是一个接口,在它里面只声明了一个 run()方法,由于 run()方法返回值为 void 类型,所以在执行完任务之后无法返回任何结果。
Callable 位于 java.util.concurrent 包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做 call(),这是一个泛型接口,call()函数返回的类型就是传递进来的 V 类型。
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的实例,有两种方法:
提问:Java新启线程有几种方式?
中止
线程自然终止
代码执行完毕,或者抛出一个未处理的异常导致线程提前结束。
stop
暂停、恢复和停止操作对应在线程Thread的API就是suspend()、resume()和stop()。
但是这些API是过期的,也就是不建议使用的。不建议使用的原因主要有:以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下。正因为suspend()、resume()和stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方法。
中断
安全的中止则是其他线程通过调用某个线程A的 interrupt() 方法对其进行中断操作,中断好比其他线程对该线程打了个招呼,“A,你要中断了”,不代表线程A会立即停止自己的工作,同样的A线程完全可以不理会这种中断请求。线程通过检查自身的中断标志位是否被置为true来进行响应。
线程通过方法 isInterrupted() 来进行判断是否被中断,也可以调用静态方法 Thread.interrupted() 来进行判断当前线程是否被中断,不过Thread.interrupted()会同时将中断标识位改写为false。
如果一个线程处于了阻塞状态(如线程调用了thread.sleep、thread.join、thread.wait等),则在线程在检查中断标示时如果发现中断标示为true,则会在这些阻塞方法调用处抛出InterruptedException异常,并且在抛出异常后会立即将线程的中断标示位清除,即重新设置为false。
不建议自定义一个取消标志位来中止线程的运行。 因为run方法里有阻塞调用时会无法很快检测到取消标志,线程必须从阻塞调用返回后,才会检查这个取消标志。这种情况下,使用中断会更好,因为:
- 一般的阻塞方法,如sleep等本身就支持中断的检查。
- 检查中断位的状态和检查取消标志位没什么区别,用中断位的状态还可以避免声明取消标志位,减少资源的消耗。
注意:处于死锁状态的线程无法被中断
run() 和 start()
大白话:run是对象内方法,实现业务逻辑,start是最终调用了native方法。
Thread类是Java里对线程概念的抽象,可以这样理解:我们通过new Thread()其实只是new出一个Thread的实例,还没有操作系统中真正的线程挂起钩来。只有执行了start()方法后,才实现了真正意义上的启动线程。
从Thread的源码可以看到,Thread的start方法中调用了start0()方法,而start0()是个native方法,这就说明Thread#start一定和操作系统是密切相关的。
start()方法让一个线程进入就绪队列等待分配cpu,分到cpu后才调用实现的run()方法,start()方法不能重复调用,如果重复调用会抛出异常。
而run方法是业务逻辑实现的地方,本质上和任意一个类的任意一个成员方法并没有任何区别,可以重复执行,也可以被单独调用。
线程的状态/生命周期
Java 中线程的状态分为 6 种:
- 初始(NEW):新创建了一个线程对象,但还没有调用 start()方法。
- 运行(RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得 CPU 时间片后变为运行中状态(running)。
- 阻塞(BLOCKED):表示线程阻塞于锁。
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
- 超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时间后自行返回。
- 终止(TERMINATED):表示该线程已经执行完毕。
线程的其他相关方法
yield()
使当前线程让出CPU占有权,但让出的时间是不可设定的。也不会释放锁资源。同时执行yield()的线程有可能在进入到就绪状态后会被操作系统再次选中马上又被执行。
wait()
参考章节 等待/通知机制方法
notify()
参考章节 等待/通知机制方法
notifyAll()
参考章节 等待/通知机制方法
线程的优先级
在 Java 线程构建的时候可以通过 setPriority(int)方法来修改优先级。
通过一个整型成员变量 priority 来控制优先级,优先级的范围从1~10。默认优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。
Java更改线程优先级的方法:
Java更改线程优先级:
Thread thread = new Thread();
thread.setPriority(1);
设置线程优先级时,针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较高优先级,而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的 JVM 以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。
线程的调度
线程调度是指系统为线程分配CPU使用权的过程。
有以下两者方式:
-
协同式线程调度(Cooperative Threads-Scheduling)
线程执行的时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。使用协同式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系统进行线程切换,所以没有线程同步的问题,但是坏处也很明显,如果一个线程出了问题,则程序就会一直阻塞。 -
抢占式线程调度(Preemptive Threads-Scheduling)
每个线程执行的时间以及是否切换都由系统决定。在这种情况下,线程的执行时间不可控,所以不会有「一个线程导致整个进程阻塞」的问题出现。
Java线程调度就是抢占式调度。
在Java中,Thread.yield()可以让出CPU执行时间,但是对于获取执行时间,线程本身是没有办法的。对于获取CPU执行时间,线程唯一可以使用的手段是设置线程优先级,Java设置了10个级别的程序优先级,当两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
线程和协程
线程
任何语言实现线程主要有三种方式:
-
使用内核线程实现(1:1实现)
使用内核线程实现的方式也被称为1:1实现。 内核线程(Kernel-Level Thread, KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。由于内核线程的支持,每个线程都成为一个独立的调度单元,即使其中某一个在系统调用中被阻塞了,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已经由操作系统处理了。
局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个语言层面的线程都需要有一个内核线程的支持,因此要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的线程数量是有限的。
-
使用用户线程实现(1:N实现)
严格意义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。 如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。 因为使用用户线程实现的程序通常都比较复杂,所以一般的应用程序都不倾向使用用户线程。Java语言曾经使用过用户线程,最终又放弃了。 但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang。
-
使用用户线程加轻量级进程混合实现(N:M实现)
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。 在这种混合实现下,既存在用户线程,也存在内核线程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
同样又可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过内核线程来完成。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系。
Java线程的实现
Java线程调度是抢占式调度的原因:
Java线程在早期的Classic虚拟机上(JDK 1.2以前),是用户线程实现的,但从JDK 1.3起,主流商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。
以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。
Java中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和Java中的一一对应,所以Java优先级并不是特别靠谱。
协程
简介
由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名——“协程”(Coroutine)完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。
协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。如果进行量化的话,那么如果不显式设置,则在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ɑː®](Loom项目的Leader就是Quasar的作者Ron Pressler), Quasar的实现原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖Java虚拟机的现场保护虽然能够工作,但影响性能。
Quasar实战
引入maven依赖:
<dependency>
<groupId>co.paralleluniverse</groupId>
<artifactId>quasar-core</artifactId>
<version>0.7.9</version>
</dependency>
在具体的业务场景上,我们模拟调用某个远程的服务,假设远程服务处理耗时需要1S,使用休眠1S来代替。为了比较,用多线程和协程分别调用这个服务10000次,来看看两者所需的耗时。
Quasar的:
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 "aa";
}
}.start());
count.await();
stopWatch.stop();
System.out.println("结束了: " + stopWatch.prettyPrint());
}
线程的:
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();
}
从代码层面来看,两者的代码高度相似,忽略两者的公共部分,代码不同的地方也就2、3行。
其中的Fiber就是Quasar为我们提供的协程相关的类,可以类比为Java中的Thread类。
其他的CountDownLatch(闭锁,线程的某种协调工具类)、Executors.newCachedThreadPool(线程池)是并发编程后面的课程将要学习的知识。StopWatch是Spring的一个工具类,一个简单的秒表工具,可以计时指定代码段的运行时间以及汇总这个运行时间。
在执行Quasar的代码前,还需要配置VM参数(Quasar的实现原理是字节码注入,所以,在运行应用前,需要配置好quasar-core的java agent地址)
-javaagent:D:\libS\javaLib\co\paralleluniverse\quasar-core\0.7.9\quasar-core-0.7.9.jar
执行耗时结果:
Quasar的:
线程的:
未指定线程池大小:
指定线程池大小:
可以看到性能的提升还是非常明显的。而且上面多线程编程时,并没有指定线程池的大小,在实际开发中是绝不允许的。一般我们会设置一个固定大小的线程池,因为线程资源是宝贵,线程多了费内存还会带来线程切换的开销。上面的场景在设置200个固定大小线程池时(Executors.newFixedThreadPool(200)),在本机的测试结果达到了50多秒,几乎是数量级的增加。
由这个结果也可以看到协程在需要处理大量IO的情况下非常具有优势,基于固定的几个线程调度,可以轻松实现百万级的协程处理,而且内存消耗非常平稳。
JDK19的虚拟线程
后续补入。
守护线程
Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。我们一般用不上,比如垃圾回收线程就是Daemon线程。
Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时Daemon线程中的finally块并不一定会执行。在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。