一、什么是线程
线程(thread),在计算机科学中,是将进程划分为两个或多个线程(实例)或子进程,由单处理器(单线程)或多处理器(多线程)或多核处理系统并发执行。
二、Java 中的单线程
直接编写的 Java 代码都是运行在单个线程上的。
在 Java 中,线程被封装在 Thread 类中。通过创建 Thread 的方式可以创建新的线程。然后通过调用对应的方法可以启动线程执行任务。
下面是最简单的线程使用例子:
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (true) {
System.out.println("Hello, World!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
});
thread.start();
}
上面就启动了一个线程 thread 。通过调用 start() 方法启动线程。该线程约每隔 1000ms 执行一次打印 Hello, World! 的操作。
三、Java 中的多线程
Java 中多线程即创建多个 Thread 对象即可。一个简单的例子如下:
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
System.out.println("Hello, World from thread "
+ Thread.currentThread().getName());
});
Thread thread2 = new Thread(() -> {
System.out.println("Hello, World from thread "
+ Thread.currentThread().getName());
});
thread1.start();
thread2.start();
}
输出结果为:
Hello, World from thread Thread-1
Hello, World from thread Thread-0
四、线程池
池化技术指的是提前准备一些资源,在需要时可以重复使用这些预先准备的资源。
在 Linux 上进行线程创建需要由用户态切换至内核态,且需要分配内存资源、执行调度。如果存在不节制的创建,则还有可能导致资源耗尽。分散在代码各处创建的线程更加是无法统一管理的,会大大降低系统的稳定性。
所以,几乎所有的框架,容器等,都是通过池化技术来对线程进行创建和管理的。
Java 中线程池相关接口继承关系如下:
其中 ExecutorService 从 JDK19 开始继承 AutoCloseable 接口,无需在代码中手动调用 shutdown() 方法,通过 try-with-resources 语句块即可自动关闭。
一个使用线程池的简单例子:
public static void main(String[] args) {
try (ExecutorService executorService = Executors.newFixedThreadPool(10)){
IntStream.range(0, 100).forEach(i -> {
executorService.submit(() -> {
System.out.println(Thread.currentThread().getName() + " " + i);
});
});
}
}
执行结果:
pool-1-thread-6 5
pool-1-thread-5 4
pool-1-thread-4 3
pool-1-thread-2 1
pool-1-thread-9 8
pool-1-thread-6 10
pool-1-thread-10 9
............
五、虚拟线程
虽然池化技术可以帮助我们解决掉线程的创建、销毁、管理等。但是创建线程池时的开销仍然是无法避免的。如果使用 try-with-resources 语句块进行即用即丢弃的方式使用线程池,那成本可想而知。
而且由于一般 CPU 只有几个核心,至多百个级别核心。想要无节制的创建线程仍然是不可行的。比如下面的代码:
public static void main(String[] args) {
long l = System.currentTimeMillis();
try (ExecutorService executorService = Executors.newFixedThreadPool(10000)) {
// 通过线程池提交任务
IntStream.range(0, 10000).forEach(i -> executorService.submit(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(i);
}));
}
System.out.println("耗时:" + (System.currentTimeMillis() - l) + "ms");
}
大概创建到 4000 个线程左右的时候,抛出如下异常:
Exception in thread "main" java.lang.OutOfMemoryError:
unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1593)
at java.base/java.lang.System$2.start(System.java:2543)
at java.base/jdk.internal.vm.SharedThreadContainer.start(SharedThreadContainer.java:160)
at java.base/java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:953)
at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1364)
at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:123)
......
随着 CPU 的核心数和内存增加,理论上是可以创建更多的平台线程的。但是总会有 OOM 的时候。
即使可以创建很多线程,随着线程数比 CPU 核心数大很多的时候,频繁的进行上下文切换,速度反而会下降。
实际上,多线程是在用资源换取吞吐量的做法。
想要提升更高的吞吐量,就需要虚拟线程来处理了。虚拟线程本质上相当于是运行在平台线程上的一个特殊的函数。创建时不需要进行系统调用,进入内核态。是完全运行在用户态的。分配,调度等策略都是依赖于应用程序的调度,在 Java 里就是依赖于 JVM 的调度。
六、为什么需要虚拟线程
线程是应用程序运行的基石。
1、一个请求一个线程
服务端应用程序通常是彼此独立的并发处理用户请求,在整个请求持续时间内,使用一个专有线程去处理,这种方式易于理解、开发、调试、分析。
但是由于利特尔法则的存在。延时是几乎无法提高的,即使通过升级机器性能,网络请求等延时仍然是客观存在的。也就是吞吐量是和并发度成正比的。
L=λW,其中 L = 系统中平均请求数量(吞吐量);λ 为请求有效到达速率,如 5/s 表示每秒有五个请求到达(并发度);W 表示请求的平均等待时间(延时)。
不幸的是,可用线程的数量是有限的,如果每个请求消耗一个 JDK 线程,进而消耗一个操作系统线程,在 CPU 利用率和网络连接资源耗尽之前,往往先耗尽的是线程数。这就导致实际上 JDK 能产生的线程数是远低于硬件可支持的水平。即使线程被池化,减少的也只是启动新线程和复用的成本,不会突破可使用线程的上限。
2、异步多线程
通过异步方式,处理请求的代码不是在一个线程上从头到尾进行执行的。而是通过多线程实现的异步执行。对于具有大量 I/O 操作的程序,使用异步多线程可以充分的利用计算机的资源,能够有效的弥补操作系统线程稀缺对吞吐量的限制。
但是,异步编程的代价很高。需要采用一组独特的 I/O 方法,这些方法无需等待 I/O 完成,而是稍后通过回调发出完成信号。开发人员需要将处理逻辑拆分成多个小阶段,使用异步 API 组合这些阶段(如 CompletableFuture 或一些 Reactive 框架)。
另外,在异步编程风格中,一个请求的不同阶段可能是在不同的线程上执行的,并且互相之间可能是交错运行的。这对调试和最终都产生了比较大的困难。
3、虚拟线程
Java 的线程和操作系统的线程是采用 1:1 的方式进行建立的。但是在运行时实际上是可以切断这一机制的,就像操作系统可以使用很大的 Swap 空间地址来映射到有限的 RAM 地址来提供充足的内存一样。Java 也可以将大量的虚拟线程映射到少量的平台线程,到达能够提供充足线程的假象。
这样一来,每个请求就可以在每个虚拟线程中进行运行,编程方式和追踪等,都可以保持原有的模式而不受影响。
虚拟线程是十分廉价的,因此不应该被池化。每次使用时创建新的就可以了。这样可以保持其调用栈很浅。而平台线程由于创建成本高,通常需要池化处理,往往具有很长的寿命,且调用栈比较深。
七、Java 中的虚拟线程
Java 中的虚拟线程是由 Loom 项目孵化的,最早的 JEP(JDK Enhancement Proposal JDK 增强建议)是 JEP 425 提出的。目标如下:
(1)实现简单的按照线程风格编写的应用程序能够接近最佳的硬件利用率并能进行扩展。
(2)在对 java.lang.Thread 的 API 进行最小的改动的前提下,实现引入虚拟线程。
(3)现有的 JDK 工具能够轻松地对虚拟线程进行故障排除、调试和分析。
而以下目标不在本次 JEP 中:
(1)删除现有的线程模型或者静默迁移现有的模型到虚拟线程。
(2)改变 Java 的基本并发模型。
(3)提供新的并发数据结构。
有这几个目标和非目标可以看出来,JCP 在极力保证 Java 的向下兼容性,并且不断地增加新的功能和特性。
虽然 JDK 19 开始,虚拟线程进入了 Java,但是截止到 JDK 20,这个功能仍然是一个预览特性。23 年 9 月的下一个版本(JDK 21)开始,不出意外的话,会纳入到正式版本特性。具体可以参考 JEP 436 和 JEP 444。