应用程序,即 app,由指令和数据组成。但当一个具体的 app 未运行时,这些应用程序就是放在磁盘(包括U盘、远程网络存储等)上的一些二进制的代码。一旦运行这些应用程序,则指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备,从这种角度来说,进程就是用来加载指令、管理内存、管理 IO 的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
进程即可视为程序的一个实例。大部分程序可以同时运行多个实例进程(如记事本、画图、浏览器等),部分程序只能启动一个实例进程(如网易云音乐、360 安全卫士等)。说明程序是死的、静态的,而进程是活的、动态的。
进程可以分为系统进程和用户进程。凡是用于完成操作系统的各种功能的进程即为系统进程,其处于运行状态下的操作系统本身,用户进程就是所有由用户来启动的进程。
在操作系统的角度,进程是程序运行资源分配(以内存为主)的最小单位。
一个机器中会运行很多程序,但 CPU 是有限的,让有限的 CPU 运行这么多的程序,需要一种机制在程序之间进行协调,即 CPU 调度。线程则是 CPU 调度的最小单位。
线程必须依赖于进程而存在,线程是进程中的一个实体,是 CPU 调度和分派的基本单位,比进程更小、能独立运行。线程自身基本上不拥有系统资源,,只拥有在运行中必不可少的资源(如程序计数器、一组寄存器、栈),但却可与同属一个进程的其他的线程共享进程所拥有的全部资源。一个进程可以拥有多个线程(C 语言中,进程可以没有线程,例如 Nginx;Java 中,进程必须包含线程),一个线程必须有一个父进程。
线程在 Linux 中有时也被称为轻量级进程(Lightweight Process,LWP),因为早期 Linux 的线程实现几乎就是复用的进程,后来才独立出自己的 API。
同一台计算机的进程通信称为 IPC(Inter-process communication),不同计算机之间的进程通信被称为R(mote)PC,需要通过网络,并遵守共同的协议,例如 Dubbo 就是一个 RPC 框架,而 Http 协议也经常用在 RPC 上,例如 SpringCloud 微服务。
分为匿名管道(pipe)及命名管道(named pipe):匿名管道可用于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外,还允许无亲缘关系进程间的通信。
信号是在软件层面上对中断机制的一种模拟,是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上是一致的。
是消息的链接表,克服了上述两种通信方式中信号量有限的缺点。对消息队列有写权限的进程可以按照一定的规则向消息队列添加新信息;对消息队列有读权限的进程则可以从消息队列中读取信息。
此为最有用的进程间通信方式。使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。此方式需要依靠某种同步操作,如互斥锁和信号量等。
主要作为进程之间及同一种进程的不同线程之间的同步和互斥手段。
是一种更为一般的进程间通信机制,可用于网络中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用 Unix domain socket(如同一机器中 MySQL 中的控制台 mysql shell 和 MySQL 服务程序的连接),此方式不需要经过网络协议栈,不需要打包拆包、计算校验和维护序号和应答等,比纯粹基于网络的进程间通信效率更高。
目前主流 CPU 都是多核的,线程是 CPU 调度的最小单位。同一时刻,一个 CPU 核心只能运行一个线程,即 CPU 内核和同时运行的线程数是 1:1 的关系,例如 8 核 CPU 同时可以执行 8 个线程的代码。但 Intel 引入超线程技术后,产生了逻辑处理器的概念,使核心数与线程数形成 1:2 的关系。
在 Java 中提供了 Runtime.getRuntime().availableProcessors(),可以获取当前的 CPU 核心数,此核心数为逻辑处理器数:
获得当前的 CPU 核心数在并发编程中很重要,并发编程下的性能优化往往和 CPU 核心数密切相关。
操作系统要在多个进程(线程)之间进行调度,而每个线程在使用 CPU 时总是要使用 CPU 中的资源,例如 CPU 寄存器和程序计数器。因此操作系统要保证线程在调度前后的正常执行,便有了上下文切换的概念,即 CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。
从数据来说:
引发上下文切换的原因一般包括:
线程、进程切换、系统调用等。上下文切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、 缓存中的来回拷贝。就 CPU 时间而言,一次上下文切换大概需要 5000~20000 个时钟周期,相对一个简单指令仅需几个至十几个左右的执行时钟周期来说,可看出上下文切换会消耗巨大成本。
现实生活中的例子:假设有条高速公路并排有 8 条车道,那么其最大的并行车辆就是 8 辆。此条高速公路同时并排行走的车辆只要小于等于 8 辆,车辆便可并行运行。CPU 也是这个原理,一个 CPU 相当于一条高速公路,核心数或者线程数就相当于并排可以通行的车道;多个 CPU 就相当于有并排多条高速公路,而每条高速公路又并排有多个车道。
当谈论并发的时候一定要加个单位时间,即单位时间内并发量是多少?离开了单位时间谈并发没有什么意义。
总结:
一个 Java 程序从 main 方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与,但实际上 Java 程序天生就是多线程程序,因为执行 main 方法的是一个名称为 main 的线程。
而一个 Java 程序的运行就算没有用户自己开启的线程,实际也有很多 JVM 自行启动的线程:
/**
* 类说明:只有一个 main 方法的程序
*/
public class OnlyMain {
public static void main(String[] args) {
//Java 虚拟机线程系统的管理接口
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
//不需要获取同步的monitor和synchronizer信息,仅仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
//遍历线程信息,仅打印线程ID和线程名称信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "]" + threadInfo.getThreadName());
}
}
}
运行结果:
[6]Monitor Ctrl-Break
[5]Attach Listener
[4]Signal Dispatcher
[3]Finalizer
[2]Reference Handler
[1]main
[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.start
//创建线程对象
Thread t1 = new Thread() {
public void run() {
//要执行的任务
}
};
//启动线程
t1.start();
new Thread 运行
Runnable runnable = new Runnable() {
public void run() {
//要执行的任务
}
};
//创建线程对象
Thread t2 = new Thread(runnable);
//启动线程
t2.start();
Thread 才是 Java 里对线程的唯一抽象,Runnable 只是对任务(业务逻辑)的抽象。Thread 可以接受任意一个 Runnable 的实例并执行。
Runnable 是一个接口,里面只声明了一个 run 方法,由于 run 方法返回值为 void 类型,所以在执行完任务之后无法返回任何结果。
Callable 位于 java.util.concurrent 包下,也是一个接口,里面也只声明了一个方法: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 运行后的返回值。
创建 FutureTask 的实例,有两种方法:
暂停、恢复和停止操作对应在线程 Thread 的 API:suspend()、resume() 和 stop()。
但是这些 API 是过期的,不建议使用。
安全的中止指其他线程通过调用某个线程 A 的 interrupt() 对其进行中断操作, 中断好比其他线程对该线程打了个招呼:“A,你要中断了”,但不代表线程 A 会立即停止自己的工作,同样 A 线程也可不理会这种中断请求。线程通过检查自身的中断标志位是否被置为 true 来进行响应。
线程通过方法 isInterrupted() 来进行判断是否被中断,也可调用静态方法 Thread.interrupted() 来进行判断当前线程是否被中断,但 Thread.interrupted() 会同时将中断标识位改写为 false。
若一个线程处于阻塞状态(如线程调用了 thread.sleep()、thread.join()、thread.wait() 等),在线程在检查中断标示时若发现中断标示为 true,则会在这些阻塞方法调用处抛出 InterruptedException 异常,并在抛出异常后会立即将线程的中断标示位清除,即重新设置为 false。
不建议自定义一个取消标志位来中止线程的运行。因为 run 方法中有阻塞,调用时会无法快速检测到取消标志,线程必须从阻塞调用返回后,才会检查此取消标志。
中断的用法:
注意:处于死锁状态的线程无法被中断
Thread 类是 Java 里对线程概念的抽象:
新创建一个线程对象,但还未调用 start 方法。
Java 线程中将就绪(ready)和运行中(running)两种状态统称为“运行”。
线程对象创建后,其他线程(如 main 线程)调用了该对象的 start 方法。
该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权,此时处于就绪状态(ready)。
就绪状态的线程在获得 CPU 时间片后变为运行中状态(running)。
线程阻塞于锁。
被 synchronized 内置锁机制所阻塞时才会进入阻塞状态,其他锁只会进入等待或超时等待状态,例如可重入锁(ReentrantLock)、显式锁、读写锁等,底层调用的是 LockSupport 的 park 相关的方法。
进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
该状态不同于 WAITING,在指定的时间后自行返回。
表示该线程已经执行完毕。
熟知线程状态有助于实际项目中的调优,例如使用 JVM 指令查看线程栈状态,可看出是否有长期处于等待、超时等待或阻塞的线程,进而分析其原因,找出解决方法:
yield 方法:使当前线程让出 CPU 占有权,但让出的时间是不可设定的,并且不会释放锁资源。同时执行 yield() 的线程有可能在进入到就绪状态后会被操作系统再次选中马上又被执行。
如 ConcurrentHashMap#initTable:
因为 ConcurrentHashMap 中可能被多个线程同时初始化 table,但此时只允许一个线程进行初始化操作,其他的线程需要被阻塞或等待,但初始化操作实际上很快,这里 Doug Lea 大师为了避免阻塞或者等待这些操作引发上下文切换等开销,就让其他不执行初始化操作的线程执行 yield 方法,以让出 CPU 占用权,让执行初始化操作的线程可以更快地执行完成。
Java 线程中,通过一个整型成员变量 priority 可控制优先级,优先级的范围从 1~10,在线程构建的时候可通过 setPriority(int) 来修改优先级,默认优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。
设置线程优先级时,针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较高优先级,而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的 JVM 及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。
线程调度是指系统为线程分配CPU使用权的过程:
使用协同式线程调度的多线程系统,线程执行的时间由线程本身来控制,线程将自身工作执行完之后,需主动通知系统切换到另外一个线程上:
使用抢占式线程调度的多线程系统,每个线程执行的时间及是否切换都由系统决定。因此线程的执行时间不可控,不会出现因为一个线程而导致整个进程阻塞的问题。
Java 线程调度为抢占式调度。
在 Java 中,Thread.yield() 可以让出 CPU 执行时间,但线程本身无法获取执行时间,线程唯一可以使用的手段是设置线程优先级,Java设置了 10 个级别的程序优先级,当两个线程同时处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。
任何语言实现线程主要有三种方式:
使用内核线程实现的方式也被称为 1:1 实现。内核线程(Kernel-Level Thread,KLT)直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
由于内核线程的支持,每个线程都成为一个独立的调度单元,即使其中某一个在系统调用中被阻塞,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已由操作系统处理。
局限性:
严格意义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自行处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”等此类问题解决起来将会异常困难,甚至有些是无法解决。因为使用用户线程实现的程序通常都比较复杂,所以一般的应用程序都不倾向使用用户线程。Java 语言曾经使用过用户线程,最终又放弃了。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,例如 Golang。
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为 N:M 实现。在这种混合实现下,既存在用户线程,也存在内核线程。
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
同样又可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过内核线程来完成。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是 N:M 的关系。
Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前),是用户线程实现的,但从 JDK 1.3 起,主流商用 JVM 的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1:1 的线程模型。
以 HotSpot 为例,它的每一个Java 线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以 HotSpot 自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。
所以,这就是我们说Java线程调度是抢占式调度的原因。而且Java中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和Java中的一一对应,所以Java优先级并不是特别靠谱。
随着互联网行业的发展,目前内核线程实现在很多场景并不适宜,例如互联网服务架构在处理一次对外部业务请求的响应,往往需要分布在不同机器上的大量服务共同协作来实现,即微服务。这种服务细分的架构在减少单个服务复杂度、增加复用性的同时,也增加了服务的数量,缩短留给每个服务的响应时间。
其要求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待。
Java 目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1 的内核线程模型是如今 JVM 线程实现的主流选择,但这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本忽略不计,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销,会造成严重的浪费。
此外,Java Web 服务器,例如 Tomcat 的线程池的容量通常在几十个到两百之间,当把数以百万计的请求往线程池里面灌时,系统即使能处理得过来,但其中的切换损耗也是相当可观的。因此对 Java 语言来说,用户线程的重新引入成为了解决此类问题的一个非常可行的方案。另外,Go 语言等支持用户线程等新型语言给 Java 带来了巨大的压力,也使得 Java 引入用户线程成为了一个绕不开的话题。
用户线程也称为协程,内核线程的切换开销是来自于保护和恢复现场的成本,若改为采用用户线程,这部分开销仍然无法避免。不过若将保护、恢复现场及调度的工作从操作系统交到程序员手上,则可以通过很多手段来缩减这些开销。
由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling),所以才有此别名为“协程”(Coroutine)。其完整地做调用栈的保护、恢复工作,所以现今也被称为“有栈协程”(Stackfull Coroutine)。
协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。若进行量化,且不显式设置,则在 64 位 Linux 上,HotSpot 的线程栈容量默认为 1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗 16KB 内存。相比之下一个协程的栈通常在几百个字节到几 KB 之间,以 JVM 里线程池容量达到两百就已经不算小了,而很多支持协程的应用中,同时并存的协程数量可数以十万计。
协程也有局限性,需要在应用层面实现的内容(调用栈、调度器这些)特别多,同时因为协程基本上是协同式调度,所以协同式调度的缺点同样存在于协程上。
总结:协程机制适用于被阻塞的,且需要大量并发的场景(网络 IO),不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)。
在 JVM 的实现上,以 HotSpot 为例,协程的实现会有些额外的限制,Java 调用栈跟本地调用栈是做在一起的。如果在协程中调用了本地方法,还能否正常切换协程而不影响整个线程?另外,如果协程中遇传统的线程同步措施会怎样?譬如Kotlin提供的协程实现,一旦遭遇 synchronize 关键字,那挂起来的仍将是整个线程。
所以 Java 开发组就 Java 中协程的实现也做了很多努力,OpenJDK 在 2018 年创建了 Loom 项目,这是 Java 的官方解决方案,并用了“纤程(Fiber)”这个名字。
Loom 项目背后的意图是重新提供对用户线程的支持,但这些新功能不是为了取代当前基于操作系统的线程实现,而是会有两个并发编程模型在 JVM 中并存,可以在程序中同时使用。新模型有意地保持了与目前线程模型相似的 API 设计,它们甚至可以拥有一个共同的基类,这样现有的代码就不需要为了使用纤程而进行过多改动,甚至不需要知道背后采用了哪个并发编程模型。
根据 Loom 团队在 2018 年公布的他们对 Jetty 基于纤程改造后的测试结果,同样在 5000QPS 的压力下,以容量为 400 的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行对比,前者的请求响应延迟在 10000 至 20000 毫秒之间,而后者的延迟普遍在 200 毫秒以下,
目前 Java 中比较出名的协程库是 Quasar[ˈkweɪzɑː®](Loom 项目的领导就是 Quasar 的作者 Ron Pressler),Quasar 的实现原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖 JVM 的现场保护虽然能够工作,但影响性能。
单独使用一个项目,引入Maven依赖:
<dependency>
<groupId>co.paralleluniversegroupId>
<artifactId>quasar-coreartifactId>
<version>0.7.9version>
dependency>
在具体的业务场景上,模拟调用某个远程的服务,假设远程服务处理耗时需要 1S,使用休眠 1S来代替。为了比较,用多线程和协程分别调用这个服务 10000 次,来比较两者所需的耗时。
Quasar 的:
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());
线程的:
CountDownLatch count = new CountDownLatch(10000);
StopWatch stopWatch = new StopWatch();
stopWatch.start();
ExecutorService executorService = Executors.newCachedThreadPool();
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 类。
StopWatch 是 Spring 的一个工具类,一个简单的秒表工具,可以计时指定代码段的运行时间以及汇总这个运行时间。
代码的业务意义:调用远程服务10000次,每次耗时1S,统计总耗时。
在执行 Quasar 的代码前,还需要配置 VM 参数(Quasar 的实现原理是字节码注入,所以在运行应用前,需要配置 quasar-core 的 java agent 地址)
-javaagent:D:\Maven\repository\co\paralleluniverse\quasar-core\0.7.9\quasar-core-0.7.9.jar
运行前确认下方的 jar 路径是否为自己本机的 Maven 仓库路径
执行结果:
可看出性能有很明显地提升。且上面多线程编程时,并没有指定线程池的大小,在实际开发中是绝不允许的。一般设置一个固定大小的线程池,因为线程资源是宝贵的,线程多了费内存还会带来线程切换的开销。
上面的场景设置 200 个固定大小线程池时(Executors.newFixedThreadPool(200)),测试结果达到了 50 多秒:
协程在需要处理大量 IO 的情况下非常具有优势,基于固定的几个线程调度,可以轻松实现百万级的协程处理,而且内存消耗非常平稳
简介:
2022年9月22日,JDK 19(非 LTS 版本)正式发布,引入了协程,并称为轻量级虚拟线程。但此特性目前还是预览版,无法引入生产环境。
使用:
javac --release 19 --enable-preview XXX.java
java --enable-preview XXX
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
Thread.ofPlatform();
//或者
Thread.startVirtualThread(Runnable);
大多数时候仅有一个线程工作并没多大用处,通常是伴随很多线程一起工作,且这些线程间进行通信,或者配合着完成某项工作,因此离不开线程间的通信和协调、协作。
进程间有好几种通信机制,其中包括了管道,其实 Java 的线程里也有类似的管道机制,用于线程之间的数据传输,而传输的媒介为内存。
场景应用:
通过 Java 应用生成文件,然后需要将文件上传到云端:
通用方案:
先将文件写入到本地磁盘,再从文件磁盘读出来上传到云盘。
若通过 Java 中的管道输入输出流即可一步到位,避免写入磁盘这一步。
Java 中的管道输入/输出流主要的具体实现:
1 和 2 面向字节,3 和 4 面向字符。
答:用 Thread#join 方法即可,在 T3 中调用 T2.join(),在 T2 中调用 T1.join()。
将指定的线程加入到当前线程,即可让两个交替执行的线程合并为顺序执行。
例如在线程 B 中调用了线程 A 的 Join 方法,就会直到线程 A 执行完毕后,才会继续执行线程 B 剩下的代码。
线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码一步一步地执行,直到终止。但每个运行中的线程,若仅仅是孤立地运行,则没有多少价值,若多个线程能够相互配合完成工作,包括数据之间的共享、协同处理事情,则会带来巨大的价值。
Java 支持多个线程同时访问一个对象或者对象的成员变量,关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,用于确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,保证线程对变量访问的可见性和排它性,也称为内置锁机制。
类锁:
类的对象实例可以有很多个,所以当对同一个变量操作时,用来做锁的对象必须是同一个,否则加锁毫无作用:
类锁只是一个概念上的东西,并不真实存在,其锁的是每个类的对应的 class 对象,但是每个类只有一个 class 对象,所以每个类只有一个类锁。
同理,当对同一个变量操作时,若类锁和对象(非 class 对象)锁混用则同样无效。
public class TestIntegerSyn {
public static void main(String[] args) throws InterruptedException {
Worker worker = new Worker(1);
for (int i = 0; i < 5; i++) {
new Thread(worker).start();
}
}
private static class Worker implements Runnable {
private Integer i;
public Worker(Integer i) {
this.i = i;
}
@Override
public void run() {
synchronized (i) {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + "--@" + System.identityHashCode(i));
i++;
System.out.println(thread.getName() + "-------[i=" + i + "]-@" + System.identityHashCode(i));
SleepTools.ms(3000);
System.out.println(thread.getName() + "-------[i=" + i + "]--@" + System.identityHashCode(i));
}
}
}
}
执行结果:
结果可看出 i 的取值会出现乱序或者重复取值的现象。
原因分析:虽然对 i 进行加锁:
但是通过反编译这个类的 class 文件后,可以看到 i++ 实际是:
再进入 Integer 类的源码查看 valueOf 方法:
本质上是返回了一个新的 Integer 对象,即每个线程实际加锁的是不同的 Integer 对象。
volatile 保证不同线程对同个变量进行操作时的可见性,即一个线程修改了某个变量的值,则此新值对其他线程立即可见。
public class VolatileCase {
private static boolean ready;
private static int number;
private static class PrintThread extends Thread{
@Override
public void run() {
System.out.println("PrintThread is running.......");
while(!ready){
};//无限循环
System.out.println("number = "+number);
}
}
public static void main(String[] args) throws InterruptedException {
new PrintThread().start();
TimeUnit.SECONDS.sleep(1);
number = 27;
ready = true;
System.out.println("main is ended!");
}
}
运行结果:
不加 volatile 时,子线程无法感知主线程修改了 ready 的值,从而不会退出循环;加了 volatile 后,子线程即可感知主线程修改了 ready 的值,迅速退出循环:
运行结果:
volatile 不是锁,无法保证原子性,即不能保证数据在多个线程下同时写时的线程安全,最适用的场景:一个线程写,多个线程读。
其他可跳出循环的情况:
线程之间相互配合,完成某项工作。
例如:一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,而最终执行又是另一个线程。
前者为生产者,后者为消费者,这种模式隔离了“做什么”(what)和“怎么做”(How),简单的办法是让消费者线程不断地循环检查变量是否符合预期,在 while 循环中设置不满足的条件,若条件满足则退出 while 循环,从而完成消费者的工作。
存在问题如下:
等待/通知机制则可以很好地避免,此机制是指一个线程 A 调用了对象 O 的 wait 方法进入等待状态,而另一个线程 B 调用了对象 O 的 notify 或者 notifyAll 方法,线程 A 收到通知后从对象 O 的 wait 方法返回,进而执行后续操作。
上述两个线程通过对象 O 来完成交互,而对象上的 wait() 和 notify/notifyAll() 的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
通知任意一个在对象上等待的线程,使其从 wait 方法返回,而返回的前提是该线程获取到了对象的锁,没有获得锁的线程重新进入 WAITING 状态。
通知所有等待在该对象上的线程。
调用该方法的线程进入 WAITING 状态,只有等待另外线程的通知或被中断才会返回。调用 wait 方法后会释放对象的锁。
超时等待一段时间,其参数时间单位为毫秒,即等待长达 n 毫秒,若没有通知就超时返回。
对于超时时间更细粒度的控制,可以达到纳秒。
在调用 wait()、notify() 系列方法之前,线程必须要获得该对象的对象级别锁,即只能在同步方法或同步块中调用 wait 方法、notify 系列方法.
进入 wait 方法后,当前线程释放锁,在从 wait 返回前,线程与其他线程竞争重新获得锁, 执行 notify 系列方法的线程退出调用了 notifyAll 的 synchronized 代码块之后,便会去竞争。若其中一个线程获得了该对象锁,即会继续往下执行,在其退出 synchronized 代码块,释放锁后,其他的已经被唤醒的线程将会继续竞争获取该锁,一直进行下去,直到所有被唤醒的线程都执行完毕。
尽可能用 notifyAll(),谨慎使用 notify(),因为 notify() 只会唤醒任意一个线程,无法确保被唤醒线程为需要唤醒的线程。
假设等待时间段是 T,则可推断出在当前时间 now + T 之后便超时:
/**
* 类说明:连接池的实现
*/
public class DBPool {
/*容器,存放连接*/
private static LinkedList<Connection> pool = new LinkedList<>();
/*限制了池的大小=20*/
public DBPool(int initialSize) {
if (initialSize > 0) {
for (int i = 0; i < initialSize; i++) {
pool.addLast(SqlConnectImpl.fetchConnection());
}
}
}
/*释放连接,通知其他的等待连接的线程*/
public void releaseConnection(Connection connection) {
if (connection != null) {
synchronized (pool) {
pool.addLast(connection);
//通知其他等待连接的线程
pool.notifyAll();
}
}
}
/*获取*/
// 在mills内无法获取到连接,将会返回null 1S
public Connection fetchConnection(long mills)
throws InterruptedException {
synchronized (pool) {
//永不超时
if (mills <= 0) {
while (pool.isEmpty()) {
pool.wait();
}
return pool.removeFirst();
} else {
/*超时时刻*/
long future = System.currentTimeMillis() + mills;
/*等待时长*/
long remaining = mills;
while (pool.isEmpty() && remaining > 0) {
pool.wait(remaining);
/*唤醒一次,重新计算等待时长*/
remaining = future - System.currentTimeMillis();
}
Connection connection = null;
if (!pool.isEmpty()) {
connection = pool.removeFirst();
}
return connection;
}
}
}
}
public class SqlConnectImpl implements Connection{
/*拿一个数据库连接*/
public static final Connection fetchConnection(){
return new SqlConnectImpl();
}
......
}
客户端获取连接的过程被设定为等待超时的模式,即 1000 毫秒内若无法获取到可用连接,将会返回给客户端一个null。
设定连接池的大小为 10 个,再通过调节客户端的线程数来模拟无法获取连接的场景。
其通过构造函数初始化连接的最大上限,通过一个双向队列来维护连接,调用方需要先调用 fetchConnection(long) 来指定在多少毫秒内超时获取连接,当连接使用完成后,需要调用 releaseConnection(Connection) 将连接放回线程池。
yield() 、sleep() 是线程的方法,在 Thread 类中;wait()、notify() 是对象的方法,在 Object 类中。
主要是因为 Java API 强制要求这样做,若不这么做,代码则会抛出 IllegalMonitorStateException。
其真实原因是:此问题并不是只在 Java 语言中会出现,而是会在所有的多线程环境下出现。
若有两个线程,一个消费者线程,一个生产者线程。生产者线程的任务可以简化成将 count 加一,而后唤醒消费者;消费者则是将 count 减一,而后在减到 0 的时候陷入睡眠:
生产者伪代码:
count+1;
notify();
消费者伪代码:
while(count<=0)
wait()
count--
但是会产生问题:
生产者是两个步骤:
消费者也是两个步骤:
若初始时 count 赋值为 0,此时消费者检查 count 的值,发现 count 小于等于 0 的条件成立;然而此时发生了上下文切换,生产者进来了,经过操作,将两个步骤都执行完了,即最后发出了通知,准备唤醒一个线程。此时消费者刚准备睡眠,但还未入睡,所以此通知就会被丢掉。而紧接着,消费者就进入睡眠。由此便引发了 lost wake-up problem(丢失唤醒问题)。
问题的根源在于,消费者在检查 count 到调用 wait() 之间,count 就可能被改掉了,此为一种常见的竞态条件,因此让消费者和生产者竞争一把锁,竞争到的才能够修改 count 的值。
处于等待状态的线程可能会收到错误警报和伪唤醒,若不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。因此,当一个等待线程醒来时,不能认为其原来的等待状态仍然是有效的,在 notify() 调用之后和等待线程醒来之前的这段时间其可能被改变。因此在循环中使用 wait 方法效果更好。
JDK 1.5 引入了 Future,可简单理解为运算结果的占位符,其提供了两个方法来获取运算结果:
调用该方法线程将会无限期等待运算结果。
调用该方法线程将仅在指定时间 timeout 内等待结果,若等待超时便会抛出 TimeoutException。
Future 可以使用 Runnable 或 Callable 实例来完成提交的任务,但存在几个问题:
JDK1.8 新加入的一个实现类 CompletableFuture,很好的解决了这些问题。其实现了 Future、CompletionStage 两个接口。实现了Future 接口,意味着可以像以前一样通过阻塞或者轮询的方式获得结果。
除了直接 new 出一个 CompletableFuture 的实例,还可以通过工厂方法创建 CompletableFuture 的实例。
Asynsc 表示异步,而 supplyAsync 与 runAsync 不同,前者异步返回一个结果,后者是 void。第二个函数的第二个参数 Executor 表示程序员创建的线程池,否则采用默认的 ForkJoinPool.commonPool() 作为其线程池。
public T get()
public T get(long timeout, TimeUnit unit)
public T getNow(T valueIfAbsent)
public T join()
getNow 有点特殊,若结果已计算完则返回结果或者抛出异常,否则返回给定的 valueIfAbsent 值。
join 返回计算的结果或抛出一个 unchecked 异常(CompletionException),其和 get 对抛出的异常的处理有些细微的区别。
public static CompletableFuture allOf(CompletableFuture... cfs)
public static CompletableFuture anyOf(CompletableFuture... cfs)
allOf 方法是当所有的 CompletableFuture 都执行完后执行计算。
anyOf 方法是当任意一个 CompletableFuture 执行完后就会执行计算,计算的结果相同。
CompletionStage 是一个接口,从命名上便可得知是一个完成的阶段,其代表了一个特定的计算的阶段,可以同步或者异步的被完成。可简单看成一个计算流水线上的一个单元,并最终会产生一个最终结果,这意味着几个 CompletionStage 可以串联起来,一个完成的阶段可以触发下一阶段的执行,接着触发下一次,再接着触发下一次……
总结 CompletableFuture 几个关键点:
CompletableFuture里大约有五十种方法,但是可以进行归类,
关键入参是函数式接口 Function。其入参为上一个阶段计算后的结果,返回值是经过转化后结果。
关键入参是函数式接口 Consumer。其入参是上一个阶段计算后的结果, 没有返回值。
对上一步的计算结果不关心,执行下一个操作,入参是一个 Runnable 的实例,表示上一步完成后执行的操作。
需要上一步的处理返回值,并且 other 代表的 CompletionStage 有返回值之后,利用这两个返回值,进行转换后返回指定类型的值。
两个 CompletionStage 是并行执行的,无先后依赖顺序,other 并不会等待先前的 CompletableFuture 执行完毕后再执行。
对于 Compose 可以连接两个 CompletableFuture,其内部处理逻辑是当第一个 CompletableFuture 处理没有完成时会合并成一个 CompletableFuture,若处理完成,第二个 future 会紧接上一个 CompletableFuture 进行处理。
第一个 CompletableFuture 的处理结果是第二个 future 需要的输入参数。
需要上一步的处理返回值,并且 other 代表的 CompletionStage 有返回值之后,利用这两个返回值进行消费。
不关心这两个 CompletionStage 的结果,只关心这两个 CompletionStage 都执行完毕,之后再进行操作(Runnable)。
两个 CompletionStage,谁计算得快,就用那个 CompletionStage 的结果进行下一步的转化操作。现实开发场景中,总会碰到有两种渠道完成同一个事情,所以就可以调用这个方法,找一个最快的结果进行处理。
两个 CompletionStage,谁计算得快,就用那个 CompletionStage 的结果进行下一步的消费操作。
两个 CompletionStage,任何一个完成了都会执行下一步的操作(Runnable)。
当运行时出现了异常,可以通过 exceptionally 进行补偿。
action 执行完毕后,其结果返回原始的 CompletableFuture 的计算结果或者返回异常。所以不会对结果产生任何的作用。