什么是进程、线程、协程?
进程
进程是计算机科学中的一个核心概念,它指的是在操作系统中正在执行的一个程序的实例。进程是操作系统中的一个独立执行单元,具有独立的内存空间和系统资源。每个进程都有自己独立的地址空间和文件描述符,不同进程之间的通信需要使用特定的机制,如管道、套接字等。
进程特征
独立性:每个进程都是独立的,拥有自己的地址空间、内存、文件描述符和其他系统资源。进程之间彼此隔离,互不干扰。
资源分配和调度:进程是操作系统进行资源分配和调度的基本单位。它可以申请和拥有系统资源,如CPU时间、内存空间等。
动态性:进程是动态的概念,它描述了程序在数据集上运行的过程,包括程序的执行、数据的处理和状态的改变等。
进程组成
进程控制块: 保存进程运行期间相关的数据,是进程存在的唯一标志。
程序段: 能被进程调度程序调度到 CPU 运行的程序的代码段。
数据段: 存储程序运行期间的相关数据,可以是原始数据也可以是相关结果。
进程状态
进程在其生命周期中会经历不同的状态,如新建、就绪、运行、等待(阻塞)、终止等。
新建:进程被创建,但尚未开始执行。
就绪:进程已准备好执行,等待被调度器分配CPU。
运行:进程正在CPU上执行。
阻塞(等待):进程因等待某些事件(如I/O操作)而暂停执行。
终止:进程执行完毕或因错误而结束。
在 Linux 中可通过 top 和 ps 工具查看进程状态: S 列表示进程的状态,有 R、D、Z、S、I 和 T 、X
R :Running,表示进程在 CPU 的就绪队列中,正在运行或者正在等待运行。
D:Disk Sleep,不可中断状态睡眠,表示进程正在跟硬件交互,并且交互过程不允许被其他进程或中断打断。
Z:Zombie,表示僵尸进程,实际上进程已经结束了,但其父进程还没有回收它的资源。
S:Interruptible Sleep,可中断状态睡眠,表示进程因为等待某个事件而被系统挂起。
I:Idle,空闲状态,用在不可中断睡眠的内核线程上。可能实际上没有任何负载。
T:Stopped,表示进程处于暂停或者跟踪状态。
X:Dead,表示进程已经消亡,不会在 top 和 ps 命令中看到。
进程模型
单进程、多进程
传统UNIX网络服务器采用模型有:
PPC(Process Per Connection):指每次有新的连接就创建一个进程去专门处理这个连接。
Prefock :提前创建进程,预先创建好进程才开始接受用户的请求(省去 fork 进程的操作)。
线程
线程是进程内的一个执行流,共享同一进程的内存空间和系统资源。多个线程可以同时执行不同的任务,每个线程有自己的栈和局部变量,但共享进程的全局变量和堆内存。
线程特征
轻量级:线程的创建、销毁和切换的开销比进程小得多。
独立调度:线程可以被操作系统独立调度,但通常线程的调度是由其所属的进程控制的。
共享资源:同一进程内的线程可以共享进程的资源,如内存空间、文件句柄、全局变量等。
并发执行:在多核处理器上,线程可以实现真正的并行执行。
上下文切换快:线程间的上下文切换比进程间的切换要快,因为线程共享了进程的大部分资源。
线程组成
线程ID:每个线程都有一个唯一的标识符,用于区分不同的线程。线程 ID 通常由操作系统分配。
程序计数器:程序计数器存储了线程下一条将要执行的指令的地址。每个线程都有自己的程序计数器,以确保线程可以独立地执行指令。
寄存器集合:线程执行时会使用到一组寄存器,包括数据寄存器、地址寄存器等。这些寄存器用于存储临时数据和指令。每个线程都有自己的寄存器集合,以保证线程执行的独立性。
栈(Stack):线程拥有自己的栈,用于存储局部变量、方法参数、返回地址等。栈是线程执行时的内存管理结构,支持线程的函数调用和返回。
线程状态
新建状态:线程被创建,但还没有开始执行。在这个阶段,线程可能正在初始化其运行所需的资源。
就绪状态:线程已经准备好运行,它已经获得了除CPU之外的所有必要资源。线程处于就绪状态时,它将被放入就绪队列中,等待调度器的调度。
运行状态:线程正在CPU上执行。在多线程环境中,由于CPU时间的分配,一个线程可能在运行状态和其他状态之间多次切换。
阻塞状态:线程因为等待某些资源(如输入/输出操作、文件读写、网络通信等)而暂停执行。在阻塞状态,线程不会消耗CPU资源,直到它等待的事件发生。
等待状态:线程通过调用如wait()、LockSupport.park()
等方法进入等待状态,主动放弃CPU资源,直到其他线程调用notify()、notifyAll()
或LockSupport.unpark(Thread)
来唤醒它。
超时等待状态:与等待状态类似,但线程在指定的超时时间后会自动唤醒。这通常通过wait(long timeout)、sleep(long millis)
或LockSupport.parkNanos()、LockSupport.parkUntil()
等方法实现。
终止状态:线程的执行已经完全结束,可能是因为它执行到了逻辑的终点,或者因为其他线程通过调用stop()
方法(虽然不推荐使用,因为它是不安全的)强制终止了线程。
多线程模型
常见服务器高性能的多线程模式:
TPC:每次有新连接就创建新线程。
Prethread:提前创建好线程,例如线程池,每次有新连接就从线程池里拿取。
协程
协程(Coroutine)是一种程序组件,它允许挂起和恢复执行,与线程相比,协程是更轻量级的并发单元。协程通常在用户态管理,而不是由操作系统内核管理,这意味着它们的创建、切换和管理的开销远小于线程。协程主要用于提高程序的并发性能,尤其是在I/O密集型和高级别的结构化异步编程中。
协程特征
轻量级:协程的创建和切换开销小,因为它们通常不需要操作系统内核的介入。
非抢占式:协程的执行由程序逻辑控制,一个协程可以选择主动挂起,让出控制权给另一个协程。
用户态管理:协程的调度通常由用户程序控制,而不是由操作系统内核管理。
协作式多任务:协程之间是协作的,它们需要彼此协作来实现多任务并发。
适用于I/O密集型任务:协程非常适合处理I/O密集型任务,因为它们可以在等待I/O操作时挂起,让出CPU给其他协程使用。
协程组成
协程控制块:类似于线程的线程控制块(Thread Control Block, TCB),CCB 包含了协程的元数据,如协程的状态、寄存器状态、调用栈信息等。CCB 通常由协程库或运行时环境管理。
程序计数器:用于存储协程执行时下一条指令的地址。当协程被挂起时,程序计数器记录了协程的执行状态,以便之后能够恢复执行。
寄存器集合:协程执行时使用的寄存器,包括局部变量、函数参数等。协程挂起时,需要保存其寄存器状态,以便恢复时能够继续执行。
调用栈:协程的调用栈用于存储函数调用的上下文,包括局部变量、返回地址等。协程的调用栈通常比线程的调用栈小,因为协程通常用于处理较短的任务。
协程状态
初始化(Initialized):协程已经被创建,但还没有开始执行。这个阶段通常涉及到协程控制块的设置和必要的初始化操作。
就绪(Ready):协程已经准备好执行,正在等待调度器分配执行的机会。在这个阶段,协程可能被放置在就绪队列中。
运行(Running):协程正在执行。在单线程的协程调度器中,一次只有一个协程处于运行状态;在多线程环境中,协程可能会被映射到线程上并真正地并行运行。
挂起(Suspended):协程在执行过程中主动或被动地暂停执行。挂起状态通常发生在协程等待某个事件(如I/O操作、锁的获取等)完成时。挂起是协程的一个关键特性,它允许协程在不执行时释放CPU资源。
恢复(Resumed):挂起的协程在等待的事件完成后被重新调度执行。在这个阶段,协程从挂起状态恢复到就绪或运行状态。
完成(Completed):协程已经完成了它的执行逻辑,不会再被调度。完成状态标志着协程生命周期的结束。
取消(Cancelled):协程在执行过程中被外部请求终止。这可能是由于错误处理、超时或其他逻辑要求。
错误(Error):协程在执行过程中遇到了错误,导致它不能继续执行。错误状态可能需要特别的处理逻辑,比如错误恢复或资源清理。
阻塞(Blocked):协程正在等待一个资源或事件,但它不能主动挂起自己。阻塞状态与挂起状态不同,因为协程在阻塞状态下通常不能被调度器挂起。
进程、线程、协程 区别?
比较项 | 进程 | 线程 | 协程 |
---|---|---|---|
资源分配与独立性 | 进程是资源分配的基本单位,每个进程都有独立的内存空间和系统资源。进程间通信需要通过特定的机制,如管道、套接字等。 | 线程是CPU调度的基本单位,它共享所属进程的内存空间和系统资源。线程间通信主要通过共享内存来实现,开销较小但可能面临数据一致性和同步问题。 | 协程不是由操作系统内核管理的,而是由用户程序控制。协程拥有自己的寄存器上下文和栈,但切换开销非常小,因为它不涉及内核态的切换。 |
并发与并行 | 进程之间的并发执行是通过操作系统的时间片轮转机制来实现的,多个进程可以在不同的时间片内交替执行。但由于进程切换的开销较大,因此进程级的并发效率相对较低。 | 线程之间的并发执行是在同一进程内进行的,多个线程可以共享进程的内存空间和系统资源,因此线程级的并发效率较高。但由于线程间共享资源,因此需要处理好同步和互斥问题。 | 协程的并发执行是在用户程序中实现的,通过协程的调度器来管理多个协程的执行。协程之间的切换开销非常小,因此可以实现高效的并发执行。但协程的并发执行仍然是基于单线程的,因此无法充分利用多核CPU的并行处理能力。 |
调度与切换 | 进程的调度和切换是由操作系统内核完成的,包括进程的创建、销毁、状态转换和上下文切换等。进程切换的开销较大,因为它需要保存和恢复进程的上下文信息。 | 线程的调度和切换也是由操作系统内核完成的,但线程切换的开销相对较小,因为线程共享进程的内存空间和部分系统资源。线程切换主要涉及内核栈和硬件上下文的保存和恢复。 | 协程的调度和切换是由用户程序控制的,不涉及操作系统内核的切换。协程切换的开销非常小,因为它只需要保存和恢复协程的寄存器上下文和栈信息。 |
同步与互斥 | 进程间通信需要通过特定的机制来实现,如管道、套接字等。进程间的同步和互斥问题需要通过操作系统提供的同步原语(如信号量、互斥锁等)来解决。 | 线程间通信主要通过共享内存来实现,但这也带来了同步和互斥问题。线程间的同步和互斥可以通过互斥锁、条件变量等同步原语来解决。 | 协程之间的同步和互斥问题相对简单,因为协程的调度和切换是由用户程序控制的。协程可以通过协程库提供的同步机制(如协程锁、协程事件等)来解决同步和互斥问题。 |
Java协程(JDK21虚拟线程)
当前 Java 中的多线程并发编程绝对是另我们都非常头疼的一部分,感觉就是学起来难啃,用起来难用。但是转头看看使用其他语言的朋友们,根本就没有这个烦恼嘛,比如 GoLang,感觉人家用起来就很丝滑呢。
JDK21 中就在这方面做了很大的改进,让Java并发编程变得更简单一点,更丝滑一点。确切的说,在 JDK19或JDK20中就有这些改进了。
虚拟线程(Virtual Threads)
虚拟线程是基于协程的线程,它们与其他语言中的协程具有相似之处,但也存在一些不同之处。
虚拟线程是依附于主线程的,如果主线程销毁了,那虚拟线程也不复存在。
相同之处:
虚拟线程和协程都是轻量级的线程,它们的创建和销毁的开销都比传统的操作系统线程要小。
虚拟线程和协程都可以通过暂停和恢复来实现线程之间的切换,从而避免了线程上下文切换的开销。
虚拟线程和协程都可以使用异步和非阻塞的方式来处理任务,提高应用程序的性能和响应速度。
不同之处:
虚拟线程是在 JVM 层面实现的,而协程则是在语言层面实现的。因此,虚拟线程的实现可以与任何支持 JVM 的语言一起使用,而协程的实现则需要特定的编程语言支持。
虚拟线程是一种基于线程的协程实现,因此它们可以使用线程相关的 API,如 ThreadLocal、Lock 和 Semaphore。而协程则不依赖于线程,通常需要使用特定的异步编程框架和 API。
虚拟线程的调度是由 JVM 管理的,而协程的调度是由编程语言或异步编程框架管理的。因此,虚拟线程可以更好地与其他线程进行协作,而协程则更适合处理异步任务。
虚拟线程入门
先声明一个线程类,implements 自 Runnable,并实现 run方法。
public class TestRunner implements Runnable{
@Override
public void run() {
System.out.println("当前线程名称:" + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Java中的new Thread()获取到的即对应操作系统中的线程。不过在JDK21中,给了他更明确的概念,平台线程PlatformThread。虚拟线程则命名VirtualThread。
//线程,即平台线程。两种方式
//Thread platformThread = new Thread(new TestRunner());
Thread platformThread = Thread.ofPlatform().name("platformThread").start(new TestRunner());
//虚拟线程。跟一下源码,可知他是依赖于池化的ForkJoinPool的
Thread virtualThread = Thread.ofVirtual().name("virtualThread").start(new TestRunner());
性能比较
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.Date;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
public class Main {
/**
* 基于15个线程池实现的虚拟线程
* 执行一万个任务,每个任务耗时1000毫秒,总共耗费2637毫秒
*/
public static void virtualThread(int count) throws Exception {
StopWatcher stopWatcher = new StopWatcher();
stopWatcher.start();
CountDownLatch countDownLatch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
Thread.ofVirtual().start(new TestRunner(countDownLatch));
}
countDownLatch.await();
stopWatcher.stop();
System.out.printf("本次执行耗时:%s毫秒", stopWatcher.getTimeInterval().toMillis());
}
/**
* 基于15个池化线程
* 执行一万个任务,每个任务耗时1000毫秒,总共耗费11分钟
*/
public static void platformThread(int count) throws Exception {
StopWatcher stopWatcher = new StopWatcher();
stopWatcher.start();
CountDownLatch countDownLatch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
CompletableFuture.runAsync(new TestRunner(countDownLatch));
}
countDownLatch.await();
stopWatcher.stop();
System.out.printf("本次执行耗时:%s毫秒", stopWatcher.getTimeInterval().toMillis());
}
public static void main(String[] args) throws Exception {
int count = 10000;
//virtualThread(count);
platformThread(count);
}
public static class TestRunner implements Runnable {
private final CountDownLatch countDownLatch;
public TestRunner(CountDownLatch countDownLatch) {
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
try {
System.out.println(Thread.currentThread() + " start " + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
Thread.sleep(1000);
System.out.println(Thread.currentThread() + " stop " + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
countDownLatch.countDown();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
public static class StopWatcher {
private long start;
private long stop;
public StopWatcher() {
}
public void start() {
this.start = System.currentTimeMillis();
}
public void stop() {
this.stop = System.currentTimeMillis();
}
public Duration getTimeInterval() {
return Duration.ofMillis(this.stop - this.start);
}
}
}
两种方式,分别模拟处理10000个阻塞任务,每个任务阻塞1秒。
PlatformThread: 15个池化线程
VirtualThread: 15个池化线程,但是采用了虚拟线程方式
我的硬件情况就不详细描述了,直接对比结果,就能清楚明了感受到差异。
执行结果
线程类型 | 耗时对比 | CPU使用率对比 |
---|---|---|
PlatformThread | 11分钟 | 10%左右 |
VirtualThread | 2秒 | 50%左右 |
分别有多少种实现线程的方法?
在并发编程的广阔领域中,线程的实现构成了其最坚实的基石。唯有掌握了多线程的构建,我们方能顺利踏上后续复杂并发操作的探索之旅。因此,本教程的起点聚焦于并发编程的核心——如何实现线程,旨在为你打下坚实的基础。尽管表面看来,线程实现可能显得直接且基础,但其背后却蕴含着丰富的细节与深刻的原理。关于线程的实现方式,通常的观点认为存在多种途径,如二种、三种乃至四种之多。
实现 Runnable 接口
第一种实现多线程的方式是通过实现Runnable接口。这种方法的核心在于创建一个类,该类实现Runnable接口,并在这个类中重写run()方法。run()方法包含了线程需要执行的代码。随后,你不需要直接继承Thread类来创建线程,而是可以创建一个Thread类的实例,并将实现了Runnable接口的类的实例作为构造函数的参数传递给这个Thread实例。这样,当Thread实例被启动时,它会调用传递给它的Runnable实现类的run()方法,从而实现了多线程的执行。
以下是一个简单的示例代码,展示了如何通过实现Runnable接口来创建并启动一个线程:
// 首先,定义一个实现了Runnable接口的类
class RunnableThread implements Runnable {
@Override
public void run() {
// 这里编写线程需要执行的代码
System.out.println("线程运行中...");
}
}
public class Main {
public static void main(String[] args) {
// 创建RunnableThread类的实例
RunnableThread runnableThread = new RunnableThread();
// 将Runnable实现类的实例作为参数传递给Thread类的构造函数
Thread thread = new Thread(runnableThread);
// 启动线程
thread.start();
// main线程继续执行其他任务
System.out.println("主线程继续执行...");
}
}
继承 Thread 类
第二种实现多线程的方式是通过继承Thread类。与第一种方式(实现Runnable接口)不同,这里不是通过实现一个接口来定义线程的行为,而是直接继承Thread类,并重写其run()方法。在run()方法中,我们编写线程需要执行的代码。之后,创建该继承类的实例,并调用其start()方法来启动线程,这会导致run()方法在新线程中执行。
以下是一个简单的示例代码,展示了如何通过继承Thread类来创建并启动一个线程:
// 继承Thread类,并重写run()方法
class MyThread extends Thread {
@Override
public void run() {
// 这里编写线程需要执行的代码
System.out.println("通过继承Thread类实现的线程运行中...");
}
}
public class Main {
public static void main(String[] args) {
// 创建MyThread类的实例
MyThread myThread = new MyThread();
// 启动线程
myThread.start();
// main线程继续执行其他任务
System.out.println("主线程继续执行...");
}
}
线程池创建线程
第3种实现多线程的方式是通过使用线程池(ThreadPool)。线程池是一种基于池化技术的多线程管理机制,它预先创建并管理一组线程,当需要执行新的任务时,不是直接创建新线程,而是将任务提交给线程池中的空闲线程执行。这种方式可以有效地减少线程创建和销毁的开销,提高系统的响应速度和吞吐量。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
// 一个简单的任务类,实现了Runnable接口
class MyTask implements Runnable {
private int taskId;
public MyTask(int taskId) {
this.taskId = taskId;
}
@Override
public void run() {
// 模拟任务执行
System.out.println("任务 " + taskId + " 正在执行...");
try {
// 假设任务执行需要一些时间
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 保持中断状态
System.out.println("任务 " + taskId + " 被中断");
}
System.out.println("任务 " + taskId + " 执行完成");
}
}
public class ThreadPoolDemo {
public static void main(String[] args) {
// 创建一个固定大小的线程池,这里设置为3个线程
ExecutorService executorService = Executors.newFixedThreadPool(3);
// 提交任务给线程池执行
for (int i = 0; i < 10; i++) {
int taskId = i + 1;
executorService.submit(new MyTask(taskId));
}
// 关闭线程池,但尝试完成已提交的任务
// 注意:这里使用shutdown()而不是shutdownNow(),因为shutdownNow()会尝试停止正在执行的任务
executorService.shutdown();
// 等待所有任务完成,或者等待超时时间后继续执行
// 这里我们简单地使用awaitTermination来等待,没有设置超时时间
try {
if (!executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) {
// 如果等待超时,这里可以处理超时的情况,但在这个例子中我们不会进入这个分支
System.out.println("线程池任务未能在指定时间内完成");
}
} catch (InterruptedException e) {
// 当前线程在等待过程中被中断
executorService.shutdownNow(); // 停止所有正在执行的任务
// 注意:这里应该处理中断异常,比如重新设置中断状态等
Thread.currentThread().interrupt();
System.out.println("线程池等待过程中被中断");
}
System.out.println("所有任务执行完成,线程池已关闭");
}
}
有返回值的 Callable 创建线程
第4种线程创建方式利用了Callable接口,与Runnable接口不同,Callable接口允许线程执行完毕后返回一个结果。当与ExecutorService的submit方法结合使用时,可以提交一个实现了Callable接口的任务,并立即获得一个Future对象,该对象代表了异步计算的结果。Future对象提供了方法来检查计算是否完成、等待计算完成以及检索计算结果。
为了具体实现这一方式,我们通常会定义一个类来实现Callable接口,并指定一个泛型作为返回值的类型,比如Integer。在实现的call方法中,我们编写线程的执行逻辑,并在执行完毕后返回一个结果。这个结果被封装在Future对象中,可以通过调用Future的get方法来获取,但需要注意的是,get方法是阻塞的,它会等待直到计算结果准备好。
以下是一个简化的示例代码,展示了如何使用Callable接口、Future以及ExecutorService来创建一个线程,该线程执行完毕后返回一个随机数:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
// 实现Callable接口,指定返回类型为Integer
class RandomNumberCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
// 模拟耗时操作,然后返回一个随机数
Thread.sleep(1000); // 休眠1秒模拟耗时操作
return (int) (Math.random() * 100); // 返回一个0到99之间的随机数
}
}
public class CallableThreadDemo {
public static void main(String[] args) {
// 创建一个ExecutorService
ExecutorService executorService = Executors.newSingleThreadExecutor();
// 提交Callable任务给ExecutorService,并立即获得Future对象
Future<Integer> future = executorService.submit(new RandomNumberCallable());
try {
// 等待任务完成,并获取结果
Integer result = future.get(); // 这会阻塞,直到任务完成
System.out.println("生成的随机数是: " + result);
} catch (InterruptedException | ExecutionException e) {
// 处理异常情况
e.printStackTrace();
}
// 关闭ExecutorService
executorService.shutdown();
}
}
为何说只有一种实现线程的方法?
在探讨Java中创建线程的方式时,确实存在两种基本方法,尽管从更广泛的角度来看,如线程池、定时器(Timer)等可以视为这些基本方法的封装或扩展。这里我们深入解析这两种基本方法,并阐述它们如何在本质上统一,同时探讨运行内容的来源。
本质上的统一性
在Java中,创建线程的基本方式可以归结为两种:
实现Runnable接口:通过实现Runnable接口的run方法,并将该实现类的实例传递给Thread类的构造函数,从而创建一个线程。这种方式允许你的类继承其他类(Java不支持多重继承),同时实现线程的执行逻辑。
继承Thread类:通过直接继承Thread类并重写其run方法,可以直接创建线程。这种方式简单直接,但限制了类的继承体系,因为Java不允许多重继承。
尽管这两种方式在表面上有所不同,但它们在本质上是统一的。这是因为无论是哪种方式,最终都是调用了Thread类的start方法来启动线程,而start方法最终都会调用到Thread类或其子类的run方法。Runnable接口的实现方式,其run方法的执行是通过将Runnable实例作为target传递给Thread类的构造函数,并在Thread类的run方法中调用target.run()来实现的。
实现 Runnable 接口比继承 Thread 类实现线程要好吗?
在探讨Java中创建线程的方式时,实现Runnable接口相较于直接继承Thread类,确实展现出了一些显著的优势。下面我们将详细对比这两种方式,并阐述为何实现Runnable接口通常被认为是更好的选择。
-
代码架构的清晰与解耦
首先,从代码架构的角度来看,Runnable接口仅定义了一个run()方法,它专注于线程需要执行的任务内容。通过将任务逻辑与线程管理(如线程的启动、属性设置等)分离,实现了更好的解耦。这种分离使得代码更加清晰,易于理解和维护。相比之下,继承Thread类的方式将任务逻辑与线程管理紧密绑定在一起,降低了代码的模块性和可重用性。 -
性能优化与资源利用
其次,在性能方面,实现Runnable接口的方式通常更加高效。当使用继承Thread类的方式时,每次需要执行新任务时,都需要创建新的Thread实例,这涉及到线程的创建和销毁过程,这些操作相对昂贵。如果任务执行时间很短,那么线程创建和销毁的开销可能会超过任务本身执行的时间,导致资源浪费。而通过实现Runnable接口,我们可以将任务提交给线程池(ExecutorService),由线程池中的固定线程来执行这些任务,从而避免了频繁的线程创建和销毁,提高了性能并优化了资源利用。 -
更好的扩展性和灵活性
第三,Java语言不支持多重继承,这意味着如果一个类继承了Thread类,那么它就不能再继承其他类了。这限制了类的扩展性和灵活性。在实际开发中,我们可能需要让一个类继承自某个特定的基类(比如某个框架提供的类),同时又想让这个类具备执行线程任务的能力。在这种情况下,实现Runnable接口就成为了更好的选择,因为它不会限制类的继承体系。 -
易于与Java并发工具集成
最后,实现Runnable接口的方式更容易与Java并发工具包(如java.util.concurrent)中的其他组件集成。例如,ExecutorService、Future、Callable等高级并发工具都是基于Runnable接口设计的。这使得我们可以更方便地利用这些工具来管理线程、处理并发任务,并享受它们带来的便利和性能提升。
线程状态是如何转换的?
线程6种状态
新建(New):线程对象已被创建,但还没有调用start()方法。
可运行(Runnable):线程已经调用了start()方法,它就进入了可运行状态。在这种状态下,线程可能正在运行,也可能正在等待CPU时间片,与其他线程共享CPU资源。
阻塞(Blocked):线程因为等待监视器锁(也就是等待进入同步块/方法)而进入阻塞状态。当线程试图进入一个已经被其他线程持有的对象的同步区域时,它将被阻塞。
等待(Waiting):线程通过调用wait()、join()或者LockSupport.park()方法进入等待状态。在这种状态下,线程不会被分配CPU执行时间,它们需要被其他线程唤醒。
超时等待(Timed Waiting):线程通过调用带有超时参数的sleep(long millis)、wait(long timeout)、join(long millis)、LockSupport.parkNanos() 或者LockSupport.parkUntil()方法进入超时等待状态。与等待状态类似,不过在指定的等待时间过后,线程会自动唤醒。
终止(Terminated):线程的运行结束。这可能是因为线程正常完成任务,或者因为某个未捕获的异常导致线程结束。
为什么wait
和notify
必须放在synchronized
中?
在多线程编程的上下文中,wait
方法被设计用来暂停当前线程的执行,直到另一个线程在同一对象上调用notify
或notifyAll
方法。这种机制是实现线程间通信和同步的关键手段之一。值得注意的是,wait、notify
和notifyAll
方法在Java
中有着特定的使用规则,它们必须被包裹在同步代码块(synchronized block)
或同步方法(synchronized method)
中。
wait/notify
的使用
wait/notify的使用demo
Object lock = new Object();
new Thread(() -> {
synchronized (lock) {
try {
System.out.println("wait before");
// 调用 wait 方法
lock.wait();
System.out.println("wait after");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
Thread.sleep(100);
synchronized (lock) {
System.out.println("execute notify");
// 调用 notify 方法
lock.notify();
}
为什么wait/notify/notifyAll
必须在synchronized
保护的同步代码中使用?
我们先不使用synchronized
使用wait/notify
。
private static void Test()throws InterruptedException {
Object lock = new Object();
new Thread(()->{
try {
System.out.println("wait before");
System.out.println("wait after");
}catch(InterruptedException e){
e.printStackTrace();
}).start();
Thread.sleep( millis:100);
System.out.println("执行 notify");
lock.notify();
}
无论是wait
还是notify
,如果不配合synchronized
一起使用,在程序运行时就会报IllegalMonitorStateException
非法的监视器状态异常,而且notify
也不能实现程序的唤醒功能了。
原因:JVM
在运行时会强制检查wait
和notify
有没有在synchronized
代码中,如果没有的话就会报非法监视器状态异常(IllegalMonitorStateException
),但这也仅仅是运行时的程序表象,那为什么Java
要这样设计呢?其实这样设计的原因就是为了防止多线程并发运行时,程序的执行混乱问题。
为什么
wait/notify/notifyAll被定义在
Object类中,
sleep定义在
Thread类中
?
因为 Java 中每个对象都有一把称之为monitor
监视器的锁,由于每个对象都可以上锁,这就要求在对象头中有一个用来保存锁信息的位置。这个锁是对象级别的,而非线程级别的,wait/notify/notifyAll
也都是锁级别的操作,它们的锁属于对象,所以把它们定义在Object
类中是最合适,因为Object
类是所有对象的父类。
因为如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。
wait/notify 和 sleep 方法的异同?
相同点:
-
它们都可以让线程阻塞。 -
它们都可以响应 interrupt 中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出 InterruptedException 异常。
不同点:
-
wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。 -
在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁。 -
sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复。 -
wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。
什么是线程安全?
线程安全是多线程编程中的一个重要概念,它关乎到多线程环境下对共享资源的访问和管理。具体来说,线程安全指的是在多线程环境中,当多个线程同时访问某个共享资源或数据时,不会出现数据污染、数据竞争、死锁或不可预期的结果,从而保证程序的正确性和稳定性。
线程安全的定义
线程安全的代码会通过同步机制或其他技术手段,确保多个线程在访问共享资源时能够正确地协调执行,不会因为线程的并发执行而导致数据的不一致或程序的错误行为。如果一个类或方法在多线程环境下,其执行结果不受线程调度和交替执行的影响,那么这个类或方法就是线程安全的。
线程安全的重要性
在多线程编程中,线程安全是至关重要的。由于多个线程可能同时访问和修改同一个资源,如果没有适当的同步机制来保护这些资源,就可能导致数据不一致、死锁等问题,进而影响程序的正确性和稳定性。因此,在多线程环境下进行编程时,必须考虑线程安全的问题,并采取相应的措施来确保程序的线程安全。
线程安全的实现方式
线程安全的实现方式多种多样,常见的包括以下几种:
-
同步机制:使用synchronized关键字、Lock接口等同步机制来确保线程在访问共享资源时的互斥性,即同一时刻只有一个线程能够访问该资源。 -
使用线程安全的数据结构:如 ConcurrentHashMap、Atomic
类等,这些数据结构内部已经实现了必要的同步机制,可以直接在多线程环境下安全使用。 -
避免共享状态:尽量设计无状态的类或方法,减少线程间的依赖和共享资源的需求,从而降低线程安全问题的复杂性。 -
使用ThreadLocal:为每个线程提供独立的变量副本,从而避免线程间的数据共享和冲突。
线程安全的判定
判断一个类或方法是否线程安全,通常需要分析其在多线程环境下的行为。如果一个类或方法在多线程环境中能够保持正确的状态和行为,且不会出现数据竞争、死锁等问题,那么它就是线程安全的。需要注意的是,线程安全并不是绝对的,它依赖于具体的实现和使用方式。因此,在设计和使用多线程程序时,必须仔细考虑线程安全的问题,并采取相应的措施来确保程序的正确性和稳定性。
三种典型的线程安全问题?
并发执行导致程序的行为不符合预期?
由于并发执行导致程序的行为不符合预期。这类问题往往与线程之间的交互有关,尤其是在处理共享资源时。以下是几种可能导致“运行结果错误”的典型线程安全问题:
-
并发修改异常(Concurrent Modification Exception):
当一个线程正在遍历集合(如ArrayList),而另一个线程正在修改该集合时,可能会抛出ConcurrentModificationException
。为了解决这个问题,可以使用Iterator
的remove()
方法安全地删除元素,或者使用CopyOnWriteArrayList
这样的线程安全集合。 -
竞态条件(Race Condition):
竞态条件发生在多个线程对同一共享变量进行读写操作时,如果没有正确的同步措施,可能会导致程序行为不符合预期。例如,两个线程同时对一个计数器进行递增操作,如果没有同步控制,最终的结果可能比预期的小。 -
虚假唤醒(Spurious Wakeup):
Java的wait()
和notify()
方法用于线程间通信时,可能会遇到虚假唤醒的问题。虚假唤醒指的是某个线程被唤醒,但实际上它不应该被唤醒,因为相应的条件还没有满足。这种情况虽然不会直接导致运行错误,但会导致程序逻辑错误,例如线程提前退出等待状态,而实际上资源还未准备好。
解决这些问题的方法: -
使用同步机制:通过synchronized关键字或ReentrantLock类来确保只有一个线程能执行某个代码段。 -
使用原子变量:如AtomicInteger,这些类提供了原子操作,可以保证操作的不可分割性。 -
使用线程局部变量:通过ThreadLocal类来确保每个线程都有自己的变量副本,从而避免共享数据。 -
使用不可变对象:不可变对象自然就是线程安全的,因为它们的内部状态在创建后不能被改变。 -
使用线程安全的集合类:如ConcurrentHashMap,这些集合类内部实现了线程安全的机制。
发布和初始化导致线程安全问题?
在Java中,由于对象的发布(publication)和初始化(initialization)不当,可能会导致线程安全问题。这些问题主要出现在对象的创建过程以及多线程环境下的对象访问过程中。以下是几种典型的由发布和初始化导致的线程安全问题:
-
对象创建过程中的未完全初始化问题(Partially Initialized Objects):
在Java中,当一个对象实例化后,它的构造函数执行完毕之前,对象的状态可能是部分初始化的。如果在这个过程中,其他线程能够访问到这个部分初始化的对象,那么就可能出现未定义的行为。这是因为构造函数内的代码可能还没有完成必要的初始化工作,而其他线程就已经开始使用这个对象。解决方案:确保对象完全初始化之后再对外发布。可以使用构造函数内部的synchronized
代码块或者构造函数本身的同步来防止其他线程访问尚未完全初始化的对象。另外,可以使用final
字段来确保字段在构造函数中一旦被初始化就不能再改变,从而增强对象的不变性。 -
对象引用的过早发布(Premature Publication of Object Reference :
如果一个对象在构造期间其引用被暴露给了其他线程,那么在构造完成后,其他线程可能仍然持有指向旧状态的引用。这意味着其他线程可能会看到对象的一个不一致状态。解决方案:确保对象的引用只在其构造完成后才被发布给其他线程。可以通过构造函数私有化和提供工厂方法或构建者模式来控制对象的创建过程,确保对象完全初始化后再发布。 -
对象的引用传递导致的线程安全问题:
当对象作为参数传递给其他方法时,如果该方法不在一个同步的上下文中执行,并且该对象包含可变状态,那么可能会引发线程安全问题。这是因为对象的引用可以在不同的线程之间共享,导致多个线程同时修改对象的状态。解决方案:对于需要在线程间共享的对象,应该确保它们是不可变的(immutable),或者在访问这些对象时使用同步机制。如果对象必须是可变的,那么在多线程环境中访问这些对象时应该使用synchronized
关键字或其他同步工具类来保护对象的状态。
为了安全地发布对象,可以采取以下措施: -
使用同步机制:确保对象在发布之前已经完全初始化。例如,可以使用 synchronized
块或方法来保护对象的初始化代码。 -
使用volatile关键字:确保对象引用的写入对其他线程立即可见。 volatile
可以防止指令重排,确保在构造函数执行完毕后,对象的引用不会在初始化之前被其他线程看到。 -
使用final关键字:如果对象的字段被声明为 final
,那么在构造函数中对这些字段的赋值将会在构造函数结束后对所有线程可见。 -
延迟发布:直到对象完全初始化后再发布给其他线程。例如,可以使用工厂方法来创建并初始化对象,然后返回一个已经初始化好的对象引用。
在Java多线程编程中,死锁、活锁和饥饿是常见的线程安全问题。下面分别解释这三个概念及其解决办法:
死锁、活锁和饥饿问题
死锁(Deadlock)
死锁是指两个或多个线程相互等待对方持有的锁,从而造成所有线程都处于无限等待的状态。最常见的死锁形式是一个线程持有一个锁,然后试图获取第二个锁,而另一个线程已经持有第二个锁并且试图获取第一个锁。
假设有两个线程 A 和 B,两个锁 X 和 Y。线程 A 持有锁 X 并尝试获得锁 Y,而线程 B 持有锁 Y 并尝试获得锁 X。这时就会发生死锁。
解决方法:
-
按顺序加锁:确保所有线程按照相同的顺序获取锁,可以避免循环等待。 -
超时机制:使用带超时的锁获取方法(如 tryLock
),并在超时后释放已持有的锁。 -
检测死锁:实现死锁检测算法,但这种方法通常比较复杂且可能影响性能。
活锁(Livelock)
活锁是指两个或多个线程在不断重复相同的动作,而无法继续向前推进,尽管每个线程都在不断地做功。活锁不同于死锁的地方在于,活锁中的线程并没有处于等待状态,而是不停地重复执行某些操作。
两个线程 A 和 B 都在尝试进入一个资源,但是每次当 A 进入时,B 就会退出,然后 B 再次尝试进入,A 又退出。如此循环往复,没有任何一方能够成功地独占资源。
解决方法:
-
随机延迟:增加随机延迟,使得线程不太可能在同一时刻尝试相同的操作。 -
优先级机制:给线程分配优先级,高优先级的线程可以优先获取资源。
饥饿(Starvation)
饥饿是指某个或某些线程因为某种原因一直得不到执行的机会,从而无法完成其任务的现象。这通常是由于优先级调度、不公平的锁机制等原因导致的。
在一个使用不公平锁(unfair lock)的情况下,新来的线程总是优先于已经在等待队列中的线程获取锁,导致后者永远得不到锁。
解决方法:
-
公平锁:使用公平锁(如 ReentrantLock(true)
),确保按照请求的顺序分配锁。 -
轮询调度:使用轮询的方式调度线程,确保每个线程都有机会被执行。 -
优先级调整:动态调整线程的优先级,确保长期等待的线程有机会获得资源。
哪些场景需要特别注意线程安全问题?
访问共享变量或资源
在多线程编程中,当多个线程需要访问相同的资源时,就涉及到了共享变量或共享资源的访问。这些共享资源包括但不限于访问共享对象的属性(即对象内部的字段)、访问类的静态变量(由于它们属于类本身,被所有实例共享)、以及访问共享的缓存系统(缓存数据被多个线程共享以加速数据访问)。
由于这些共享资源或变量不仅可能被单个线程单独访问,而且经常面临多个线程同时访问的情况,因此,在没有适当同步控制的情况下,就容易出现并发读写的问题。这些问题可能导致数据不一致、数据竞争、甚至是死锁等线程安全问题。数据不一致表现为某个线程修改后的数据未被其他线程及时感知,数据竞争则可能发生在多个线程同时尝试修改同一数据时,而死锁则是因为线程之间在等待对方释放资源而造成的相互阻塞。
为了确保线程安全,避免上述问题,程序员需要采取一定的同步措施,如使用关键字、接口提供的锁机制、或者包中提供的线程安全集合和原子类等,来确保在并发环境下对共享资源的访问是安全的、有序的。这样不仅可以保护数据的完整性,还能保证程序的稳定性和可靠性。
依赖时序的操作
在多线程编程中,一个需要特别关注的场景是那些依赖于特定执行时序的操作。当操作的正确性完全或部分依赖于操作的执行顺序时,这些操作就被视为是时序敏感的。然而,在多线程环境下,由于操作系统的调度机制,线程的执行顺序并不能被程序直接控制或保证。因此,即使我们在设计程序时预想了某个特定的执行顺序,实际运行时也可能因为线程调度的不确定性而导致执行顺序与预期不符。
当这种情况发生时,就可能引发线程安全问题。因为时序敏感的操作可能依赖于某个先前操作的完成状态或结果,如果这些操作被不同的线程以不确定的顺序执行,就可能导致数据不一致、逻辑错误或程序崩溃等问题。
这样类似的场景都是同样的道理,“检查与执行”并非原子性操作,在中间可能被打断,而检查之后的结果也可能在执行时已经过期、无效,换句话说,获得正确结果取决于幸运的时序。这种情况下,我们就需要对它进行加锁等保护措施来保障操作的原子性。
不同数据之间存在绑定关系
线程安全场景是不同数据之间存在相互绑定关系的情况。有时候,我们的不同数据之间是成组出现的,存在着相互对应或绑定的关系,最典型的就是 IP 和端口号。有时候我们更换了 IP,往往需要同时更换端口号,如果没有把这两个操作绑定在一起,就有可能出现单独更换了 IP 或端口号的情况,而此时信息如果已经对外发布,信息获取方就有可能获取一个错误的 IP 与端口绑定情况,这时就发生了线程安全问题。在这种情况下,我们也同样需要保障操作的原子性。
对方没有声明自己是线程安全的
我们使用其他类时,如果对方没有声明自己是线程安全的,那么这种情况下对其他类进行多线程的并发操作,就有可能会发生线程安全问题。举个例子,比如说我们定义了 ArrayList,它本身并不是线程安全的,如果此时多个线程同时对 ArrayList 进行并发读/写,那么就有可能会产生线程安全问题,造成数据出错,而这个责任并不在 ArrayList,因为它本身并不是并发安全的。
如果我们把 ArrayList 用在了多线程的场景,需要在外部手动用 synchronized 等方式保证并发安全。
所以 ArrayList 默认不适合并发读写,是我们错误地使用了它,导致了线程安全问题。所以,我们在使用其他类时如果会涉及并发场景,那么一定要首先确认清楚,对方是否支持并发操作,以上就是四种需要我们额外注意线程安全问题的场景,分别是访问共享变量或资源,依赖时序的操作,不同数据之间存在绑定关系,以及对方没有声明自己是线程安全的。
为什么多线程会带来性能问题?
主要有两个方面,一方面是线程调度,另一个方面是线程协作。
调度开销
上下文切换:
在软件开发实践中,特别是在处理高并发场景时,尽管现代CPU拥有多个核心,但实际应用中创建的线程数量往往会远远超过CPU核心数。操作系统通过复杂的调度算法,为这些线程分配有限的时间片资源,确保它们都能得到执行机会。然而,这一过程中的上下文切换机制却带来了不可忽视的性能开销。特别是在执行时间极短的任务时,频繁的上下文切换可能会消耗大量CPU资源,导致系统整体性能下降。
因此,开发者在设计和实现多线程应用时,需要谨慎考虑线程的数量与任务的性质,以避免因过度创建线程而引发的性能瓶颈。通过合理设计任务粒度、优化线程管理策略(如使用线程池)等方式,可以在确保应用并发性能的同时,有效降低上下文切换带来的开销,从而实现更高效、更稳定的系统表现。
缓存失效: 在程序执行过程中,性能问题不仅源于上下文切换,缓存失效同样是一个重要因素。为了提高数据访问速度,程序常利用缓存机制存储频繁访问的数据。然而,当线程调度发生时,CPU转向执行其他线程的代码,原先缓存的数据可能因不再被当前线程所需而变得无效,这时需重新加载新数据到缓存中,这一过程增加了额外的开销。
为了减少上下文切换的频率及其带来的性能损失,线程调度器通常会为被调度的线程设定一个最小执行时间片,确保线程在执行完该时间段内的任务前不会被打断,从而降低了上下文切换的频率。
另一方面,密集的上下文切换往往由特定情况触发,如程序中的锁竞争激烈或频繁的I/O阻塞。这些情况会导致线程频繁地等待资源或执行中断,迫使操作系统频繁地进行上下文切换,显著增加系统开销。因此,在设计程序时,应努力优化锁的使用策略,减少不必要的I/O操作,以避免造成密集的上下文切换,从而提高程序的整体性能和效率。
协作开销
除了线程调度之外,线程协作同样也有可能带来性能问题。因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中,等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。
## 其它关注公众号【 java程序猿技术】获取更多相关文章