线程和协程
线程是操作系统层面的实体,那么Java中的线程怎么和操作系统的线程对应起来?
任何语言实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。Java就是第三种混合实现。
-
内核线程实现(1:1实现)
优点:各种线程操作如创建、析构及同步,全部交由操作系统来完成,对用户来说只要调用系统的API即可,实现简单;每个线程是独立的调度单元,阻塞也不会影响其他线程。
缺点:系统调用代价高昂,需要用户态(User Mode)和内核态(Kernel Mode)的切换;每个用户线程都意味着有一个操作系统线程与其对应,操作系统的线程资源有限。
-
用户线程实现(1:N实现)
优点:用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要系统内核支援,不需要切换到内核态, 因此操作可以是非常快速且低消耗的, 也能够支持规模更大的线程数量。
缺点:没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题。
一般的应用程序都不倾向使用用户线程,但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang。Java有了语言性能的竞争压力,于是JDK19开始有了虚拟线程先行版本,等后续有了长期版本可能会投入使用
-
混合实现(N:M实现)
一种将内核线程与用户线程一起使用的实现方式, 被称为N:M实现。
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
同样又可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过内核线程来完成。
Java线程的实现
Java线程在早期的Classic虚拟机上(JDK 1.2以前),是用户线程实现的, 但从JDK 1.3起, 主流商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。
协程
出现的原因
主流的内核线程模型,需要映射到操作系统上的线程,其天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限, 很多场景已经不适宜了。
另外常见的Java Web服务器,比如Tomcat的线程池的容量通常在几十个到两百之间,当把数以百万计的请求往线程池里面灌时,系统即使能处理得过来,但其中的切换损耗也是相当可观的。
随着微服务的兴起,这种服务细分的架构在减少单个服务复杂度、增加复用性的同时,也不可避免地增加了服务的数量,缩短了留给每个服务的响应时间。
再有Go语言等支持用户线程等新型语言给Java带来了巨大的压力,也使得Java引入用户线程成为了一个绕不开的话题。
操作系统线程虽然简单,但是操作成本高昂;加上操作系统的线程资源本就有限;微服务大火带来的各个服务请求大增且必须极短的时间完成响应;还有其他语言带来的性能竞争压力。Java必须迈出这一步,把用户线程用起来。
协程简介
最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名——“协程”(Coroutine),完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。
协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。
在64位机器中,一个线程创建完即使什么也不做,也会占用1M的内存。协程只需要几百字节到几KB。虚拟机中线程池容量两百就不小了,而支持协程的应用可以同时并存数十万的协程。
协程的局限:需要在应用层面实现的内容(调用栈、调度器这些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程上也存在。
总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络io),不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)。
纤程-Java中的协程
Java开发组就Java中协程的实现也做了很多努力,OpenJDK在2018年创建了Loom项目,这是Java的官方解决方案,并用了“纤程(Fiber)”这个名字。
Loom项目背后的意图是重新提供对用户线程的支持,但这些新功能不是为了取代当前基于操作系统的线程实现,而是会有两个并发编程模型在Java虚拟机中并存,可以在程序中同时使用。新模型有意地保持了与目前线程模型相似的API设计,它们甚至可以拥有一个共同的基类,这样现有的代码就不需要为了使用纤程而进行过多改动,甚至不需要知道背后采用了哪个并发编程模型。
目前Java中比较出名的协程库是Quasar[ˈkweɪzɑː(r)](Loom项目的Leader就是Quasar的作者Ron Pressler),Quasar的实现原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖Java虚拟机的现场保护虽然能够工作,但影响性能。
Quasar实战
1、引入Maven依赖
<dependency>
<groupId>co.paralleluniverse</groupId>
<artifactId>quasar-core</artifactId>
<version>0.7.9</version>
</dependency>
2、示例代码
import co.paralleluniverse.fibers.Fiber;
import co.paralleluniverse.fibers.SuspendExecution;
import co.paralleluniverse.strands.Strand;
import org.springframework.util.StopWatch;
import java.util.concurrent.CountDownLatch;
import java.util.stream.IntStream;
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 "aa";
}
}.start());
count.await();
stopWatch.stop();
System.out.println("结束了: " + stopWatch.prettyPrint());
}
}
3、配置VM参数(Quasar的实现原理是字节码注入,所以在运行应用前,需要配置好quasar-core的java agent地址):
-javaagent:D:\maven_repo\co\paralleluniverse\quasar-core\0.7.9\quasar-core-0.7.9.jar
4、执行第2步的示例代码,输出结果如下:
结束了: StopWatch '': running time = 1657592400 ns
---------------------------------------------
ns % Task name
---------------------------------------------
1657592400 100%
对比使用线程池的示例代码:
import org.springframework.util.StopWatch;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
public class Standard
{
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();
}
}
输出结果:
// Executors.newCachedThreadPool() 输出结果如下
结束了: StopWatch '': running time = 5283791500 ns
---------------------------------------------
ns % Task name
---------------------------------------------
5283791500 100%
// Executors.newFixedThreadPool(200) 输出结果如下
结束了: StopWatch '': running time = 50519546700 ns
---------------------------------------------
ns % Task name
---------------------------------------------
50519546700 100%
扩展:Executors.newCachedThreadPool()
这一种是没有线程数量限制的,当然生产上不允许这样使用。Executors.newFixedThreadPool(200)
这一种有线程数量上限,执行速度更慢。
结论:协程在需要处理大量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("duke").unstarted(runnable);
// Thread.ofPlatform() 则创建传统意义的实例
或者
Thread.startVirtualThread(Runnable)
并通过Executors.newVirtualThreadPerTaskExecutor()提供了虚拟线程池功能。
在具体实现上,虚拟线程当然是基于用户线程模式实现的,JDK 的调度程序不直接将虚拟线程分配给处理器,而是将虚拟线程分配给实际线程,是一个 M: N 调度,具体的调度程序由已有的ForkJoinPool提供支持。
但是虚拟线程不是协同调度的,JDK的虚拟线程调度程序通过将虚拟线程挂载到平台线程上来分配要在平台线程上执行的虚拟线程。在运行一些代码之后,虚拟线程可以从其载体卸载。此时平台线程是空闲的,因此调度程序可以在其上挂载不同的虚拟线程,从而使其再次成为载体。
通常,当虚拟线程阻塞 I/O 或 JDK中的其他阻塞操作(如BlockingQueue.take ())时,它将卸载。当阻塞操作准备完成时(例如,在套接字上已经接收到字节) ,它将虚拟线程提交回调度程序,调度程序将在运营商上挂载虚拟线程以恢复执行。虚拟线程的挂载和卸载频繁且透明,并且不会阻塞任何 OS 线程。
守护线程
Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。一般用不上,比如垃圾回收线程就是Daemon线程。
特点:当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。
使用:可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。
注意:Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时Daemon线程中的finally块并不一定会执行。在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。不推荐finally回收资源的一个原因,无法保证finally代码一定会被执行
线程间的通信和协调、协作
线程间进行通信,或者配合着完成某项工作,离不开线程间的通信和协调、协作。
管道输入输出流
进程间有好几种通信机制,其中包括了管道,其实Java的线程里也有类似的管道机制,用于线程之间的数据传输,而传输的媒介为内存。
工作中遇到的场景:
1、互联网金融行业:App点击下载合同,触发导出任务,然后将mysql 中的数据根据导出条件查询出来,生成 Pdf文件,然后将文件上传到 oss,最后发布一个下载文件的链接。
2、大数据告警系统:年底做业务告警统计时,从本地某个数据源查询数据后,生成 Excel文件,给到指定的 ftp、或是 oss 供客户去下载查阅,或者直接发送到客户的邮箱。
一般做法是,先将文件写入到本地磁盘,然后从文件磁盘读出来上传到云盘,但是通过Java中的管道输入输出流一步到位,则可以避免写入磁盘这一步。
Java中的管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream
、PipedInputStream
、PipedReader
和PipedWriter
,前两种面向字节,而后两种面向字符。
字节用来处理二进制,字符用来处理文本
示例代码:
import java.io.IOException;
import java.io.PipedReader;
import java.io.PipedWriter;
public class Piped
{
public static void main(String[] args)
throws Exception
{
PipedWriter out = new PipedWriter();
PipedReader in = new PipedReader();
/* 将输出流和输入流进行连接,否则在使用时会抛出IOException */
out.connect(in);
Thread printThread = new Thread(new Print(in), "PrintThread");
printThread.start();
int receive = 0;
try
{
/* 将键盘的输入,用输出流接收,在实际的业务中,可以将文件流导给输出流 */
while ((receive = System.in.read()) != -1)
{
out.write(receive);
}
}
finally
{
out.close();
}
}
static class Print implements Runnable
{
private PipedReader in;
public Print(PipedReader in)
{
this.in = in;
}
@Override
public void run()
{
int receive = 0;
try
{
/*
* 输入流从输出流接收数据,并在控制台显示 在实际的业务中,可以将输入流直接通过网络通信写出
*/
while ((receive = in.read()) != -1)
{
System.out.print((char)receive);
}
}
catch (IOException ex)
{
}
}
}
}
join方法
面试题
现在有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行?
join()
把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B剩下的代码。
synchronized内置锁
Java支持多个线程同时访问一个对象或者对象的成员变量,关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,又称为内置锁机制。
对象锁和类锁:
对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。我们知道,类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。
对象锁:非静态方法,代码块;类锁:静态方法,类的Class
注意:对Integer对象加锁,并且执行了++
操作时,加锁会失效
因为++
操作字节码翻译后,是执行的Integer.valueOf(int i)
方法,根据源码这个方法每次返回的是一个新对象,每次加锁的都是新对象,所以锁失效
// Integer#valueOf源码如下
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
volatile,最轻量的通信/同步机制(注意这里是机制,不是锁)
volatile保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
示例代码:
/**
* 类说明:演示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("6174");
}
;// 无限循环
System.out.println("number = " + number);
}
}
public static void main(String[] args)
throws InterruptedException
{
new PrintThread().start();
SleepTools.second(1);
number = 51;
ready = true;
SleepTools.second(5);
System.out.println("main is ended!");
}
}
// SleepTools工具类:
import java.util.concurrent.TimeUnit;
/**
* 类说明:线程休眠辅助工具类
*/
public class SleepTools
{
/**
* 按秒休眠
*
* @param seconds 秒数
*/
public static final void second(int seconds)
{
try
{
TimeUnit.SECONDS.sleep(seconds);
}
catch (InterruptedException e)
{
}
}
/**
* 按毫秒数休眠
*
* @param seconds 毫秒数
*/
public static final void ms(int seconds)
{
try
{
TimeUnit.MILLISECONDS.sleep(seconds);
}
catch (InterruptedException e)
{
}
}
}
上面示例中ready不加volatile修饰时,主线程的修改子线程感知不到,所以程序不会退出循环。
加了volatile后,子线程可以感知主线程修改了ready的值,迅速退出循环。
扩展:循环体中添加打印语句也可以退出循环,因为打印方法使用了synchronized关键字
// System.out.println源码如下
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
另外循环体中添加Thread.sleep()
也可以退出循环,原因猜想:线程sleep重新分配CPU后可能会清理缓存