Java并发常见知识点总结(上)
一、线程
⭐️ 什么是线程和进程?
何为进程?
进程是操作系统分配资源的基本单位,它是程序的一次执行过程。每个进程都有独立的内存空间和系统资源。系统运行一个程序时,操作系统会为该程序创建一个进程,直到程序执行完毕或者被终止为止。
在 Java 中,当你启动一个应用程序时,JVM 会为你创建一个进程,这个进程是 Java 程序执行的环境。程序中的 main
方法对应的线程是该进程中的第一个线程,即主线程。通过操作系统的任务管理器(如 Windows 的任务管理器),你可以查看正在运行的进程。每个进程都有独立的地址空间和系统资源,多个进程之间的内存空间是相互隔离的。
何为线程?
线程是进程中的一个执行单元,一个进程至少有一个线程,这个线程通常称为主线程。在一个进程内,可以有多个线程,这些线程共享进程的资源,如堆和方法区中的数据,但每个线程有独立的程序计数器、栈和本地方法栈。线程的切换比进程的切换开销小,因此线程通常被称为轻量级进程。
在 Java 中,程序是多线程的,即使你没有显式地创建线程,JVM 也会为程序提供多个线程来执行不同的任务。例如,JVM 会为垃圾回收、线程调度等创建线程。
Java 程序中的线程示例
Java 程序天生支持多线程,可以通过 ThreadMXBean
来获取当前 Java 程序中的所有线程信息,以下是一个代码示例:
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class MultiThread {
public static void main(String[] args) {
// 获取 Java 线程管理 MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍历线程信息,仅打印线程 ID 和线程名称信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
}
}
}
上述代码通过 ManagementFactory
获取当前 Java 程序中的所有线程信息,并输出每个线程的 ID 和名称。假设程序输出如下:
[5] Attach Listener
[4] Signal Dispatcher
[3] Finalizer
[2] Reference Handler
[1] main
从上述输出可见,一个 Java 程序通常不仅仅有一个线程。main
线程是程序的入口点,而 JVM 会创建其他多个后台线程来处理不同的任务,如信号分发、垃圾回收等。
总结
- 进程是操作系统资源分配的基本单位,每个进程有独立的内存空间。
- 线程是进程中的执行单元,多个线程共享进程的资源,但每个线程有自己的程序计数器、栈和本地方法栈。Java 程序默认是多线程的,JVM 会为其创建多个线程。
Java 线程和操作系统线程的区别
绿色线程与原生线程
在 JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这种线程是用户级线程,由 JVM 自己管理和调度,而不依赖于操作系统的线程管理。这意味着 JVM 会模拟多线程的执行,使用单个操作系统线程来调度多个 Java 线程。
绿色线程的缺点包括:
- 无法利用操作系统的原生功能,如异步 I/O。
- 所有线程必须运行在一个内核线程上,无法并行使用多核 CPU。
从 JDK 1.2 开始,Java 线程改为基于原生线程(Native Threads),即 JVM 直接使用操作系统提供的内核线程来实现 Java 线程,所有的线程调度和管理由操作系统内核负责。这样,Java 线程就能够享受操作系统提供的多核 CPU 支持,并且可以直接使用操作系统的底层功能。
用户线程与内核线程
- 用户线程:由用户程序(如 JVM)管理和调度,运行在用户空间。用户线程的创建和切换成本较低,但无法利用多核 CPU。
- 内核线程:由操作系统内核管理和调度,运行在内核空间。内核线程的创建和切换成本较高,但能够利用操作系统的调度器并且支持多核 CPU。
线程模型
Java 中线程的实现方式由操作系统的线程模型决定。线程模型是指用户线程与内核线程之间的关系,常见的线程模型有三种:
- 一对一模型(One-to-One):每个用户线程对应一个内核线程,Java 线程和操作系统线程一一对应。现代操作系统普遍采用这种模型。
- 多对一模型(Many-to-One):多个用户线程映射到一个内核线程,多个 Java 线程共享同一个操作系统线程。这种模型效率低下且容易阻塞。
- 多对多模型(Many-to-Many):多个用户线程映射到多个内核线程。这个模型能够充分利用多核 CPU,能够高效地进行线程调度。Solaris 等系统支持这种模型。
Java 线程与操作系统线程的关系
- 目前的 Java 线程实际上就是操作系统的线程,也就是Java 线程的本质是操作系统的内核线程。
- 在主流操作系统(如 Windows 和 Linux)中,Java 线程采用的是一对一模型,即每个 Java 线程都对应一个操作系统内核线程。
- 对于 Solaris 系统,Java 线程支持多对多和一对一模型,可以根据需要选择。
总结
- Java 线程:从 JDK 1.2 开始,Java 线程基于操作系统的原生线程,利用操作系统的内核线程来实现。
- 操作系统线程:操作系统管理和调度的线程,运行在内核空间。
- 用户线程:由用户程序管理和调度的线程,运行在用户空间。
- 线程模型:定义了用户线程和内核线程之间的映射关系,常见的模型有一对一、多对一和多对多。现代操作系统采用一对一模型。
⭐️ 线程与进程的关系、区别及优缺点
关系
进程:是操作系统分配资源的最小单位。每个进程都有自己的内存空间、文件描述符等资源。一个进程可以包含多个线程,这些线程共享该进程的资源(如堆和方法区),但每个线程有独立的程序计数器、虚拟机栈和本地方法栈。
线程:是操作系统调度的最小单位。线程是进程中的执行单元,它们共享进程的资源(如堆内存),但每个线程有自己的执行上下文(如程序计数器和栈)。在 JVM 中,每个线程都有自己的栈空间和程序计数器,而堆和方法区则是线程共享的。
区别
项目 | 进程 | 线程 |
---|---|---|
定义 | 进程是程序执行的实例。 | 线程是进程中执行的基本单位。 |
内存空间 | 每个进程有独立的内存空间。 | 线程共享进程的内存空间,只有堆和方法区共享,栈是独立的。 |
资源开销 | 进程的创建和销毁开销较大。 | 线程的创建和销毁相对较轻,开销较小。 |
通信 | 进程间通信(IPC)相对复杂且开销大。 | 线程间通信简单,可以通过共享内存实现。 |
调度 | 进程由操作系统进行调度。 | 线程也由操作系统调度,但切换比进程更加轻量。 |
独立性 | 进程之间相对独立,一个进程崩溃不会影响其他进程。 | 线程共享资源,一个线程崩溃可能导致整个进程崩溃。 |
优缺点
进程的优点:
- 进程之间相对独立,互不干扰,崩溃不会影响其他进程。
- 进程的资源管理和保护机制较为严格。
进程的缺点:
- 创建和切换进程的开销大,资源占用多。
- 进程间通信复杂,通常需要使用进程间通信机制(如管道、消息队列、共享内存等)。
线程的优点:
- 线程的创建和切换开销小,能够高效地利用 CPU。
- 线程之间共享进程的内存,可以通过共享变量轻松进行通信。
线程的缺点:
- 线程共享内存空间,容易导致数据竞争、死锁等并发问题。
- 一个线程崩溃可能会影响整个进程,导致所有线程都崩溃。
程序计数器、虚拟机栈与本地方法栈的私有性
程序计数器:每个线程有自己的程序计数器,用于记录当前执行到哪条指令。在多线程环境中,线程切换时需要知道从哪里恢复执行,因此程序计数器必须是线程私有的。
虚拟机栈和本地方法栈:每个线程都有自己的虚拟机栈和本地方法栈。虚拟机栈用于保存方法的局部变量、操作数栈等信息,而本地方法栈则用于执行 Native 方法。由于每个线程的方法执行过程需要独立的栈帧,栈和本地方法栈是线程私有的,以保证线程的独立性。
堆与方法区的共享性
- 堆:堆是所有线程共享的内存区域,用于存储对象实例。所有线程都可以访问堆中的对象,因而需要通过同步机制来保证对堆内存的安全访问。
- 方法区:方法区也是线程共享的,用于存储类的元数据、静态变量、常量、即时编译器编译后的代码等信息。所有线程都可以访问方法区中的数据。
总结
- 进程是资源分配的基本单位,线程是进程的基本执行单位。
- 进程之间相互独立,内存空间完全隔离,创建和切换开销较大;线程之间共享进程的资源,创建和切换开销较小,但也更容易引发并发问题(如数据竞争、死锁等)。
- 在 Java 中,多个线程共享堆和方法区,但每个线程拥有独立的程序计数器、虚拟机栈和本地方法栈。
下面是该知识点的扩展内容!
下面来思考这样一个问题:为什么程序计数器、虚拟机栈和本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?
程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
一句话简单了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
如何创建线程?
在 Java 中,创建线程有多种方式,常见的包括:
- 继承
Thread
类 - 实现
Runnable
接口 - 实现
Callable
接口(结合Future
获取返回结果) - 使用线程池(
ExecutorService
) - 使用
CompletableFuture
这些方法提供了不同的方式来管理线程,但本质上,它们都是依赖于通过 new Thread().start()
来启动线程。下面简要说明每种方法:
1. 继承 Thread
类
继承 Thread
类并重写 run()
方法,使用 start()
方法启动线程。
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread is running");
}
}
public class ThreadExample {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // 启动线程
}
}
- 优点:简洁直观,适合快速实现。
- 缺点:Java 不支持多继承,因此如果类已经继承了其他类,则无法再继承
Thread
类。
2. 实现 Runnable
接口
实现 Runnable
接口并将其传递给 Thread
对象,通过 start()
启动线程。
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable thread is running");
}
}
public class RunnableExample {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start(); // 启动线程
}
}
- 优点:更灵活,能够避免类继承的限制,可以将
Runnable
实现传递给多个线程。 - 缺点:需要额外创建一个
Runnable
类或匿名实现类。
3. 实现 Callable
接口(结合 Future
)
Callable
接口类似于 Runnable
,但它可以返回一个结果,且可以抛出异常。通常与 ExecutorService
配合使用。
import java.util.concurrent.*;
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
System.out.println("Callable thread is running");
return 123;
}
}
public class CallableExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable myCallable = new MyCallable();
ExecutorService executorService = Executors.newSingleThreadExecutor();
Future<Integer> future = executorService.submit(myCallable);
System.out.println("Result: " + future.get()); // 获取返回值
executorService.shutdown();
}
}
- 优点:能够返回结果并且处理异常,适用于需要返回值的线程任务。
- 缺点:需要结合
ExecutorService
使用。
4. 使用线程池(ExecutorService
)
使用线程池管理线程,可以避免频繁地创建和销毁线程。线程池中有多个线程可用,适合高效地处理大量任务。
import java.util.concurrent.*;
public class ThreadPoolExample {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2); // 创建一个固定大小的线程池
executorService.submit(() -> System.out.println("Thread from thread pool"));
executorService.shutdown();
}
}
- 优点:线程池能够重用线程,减少线程创建的开销,管理线程更加灵活。
- 缺点:需要管理线程池生命周期,配置相对复杂。
5. 使用 CompletableFuture
CompletableFuture
是 Java 8 引入的类,用于异步执行任务。它提供了更方便的 API 来处理异步操作,支持组合和链式调用。
import java.util.concurrent.CompletableFuture;
public class CompletableFutureExample {
public static void main(String[] args) {
CompletableFuture.runAsync(() -> {
System.out.println("CompletableFuture thread is running");
});
}
}
- 优点:异步执行任务并提供强大的功能,如组合、等待结果等。
- 缺点:较为复杂,对于简单的任务可能过于繁琐。
本质上的线程创建
无论你采用哪种方式创建线程,最终都依赖于 Thread
类的 start()
方法。这个方法底层调用的是操作系统提供的线程机制,从而真正创建并调度一个系统线程。其他方法只是在不同的层次上封装了线程的创建和管理。
结论
- 创建线程有多种方式,每种方式有其适用场景。
- 在所有方法中,最终线程的创建与启动依赖于操作系统的原生线程机制,通过
Thread
类的start()
来启动线程。 - 推荐使用线程池或
CompletableFuture
来处理并发任务,以避免频繁创建和销毁线程。
线程的生命周期和状态
Java 线程的生命周期中,线程根据不同的条件和操作可能会处于以下 6 种状态:
NEW(新建状态)
- 线程被创建出来,但还没有调用
start()
方法。此时线程处于新建状态,尚未准备好执行。
- 线程被创建出来,但还没有调用
RUNNABLE(可运行状态)
- 线程调用了
start()
方法,进入可运行状态。此时线程已经准备好执行,但是否真的执行取决于操作系统的调度器。在 Java 中,RUNNABLE 状态并不区分操作系统层面的 READY 和 RUNNING 状态,Java 将这两个状态统一为 RUNNABLE。
- 线程调用了
BLOCKED(阻塞状态)
- 当线程请求进入同步代码块时,如果其他线程已经持有该代码块的锁,当前线程将会进入 BLOCKED 状态,直到它获取到锁。
WAITING(等待状态)
- 线程进入此状态是因为它调用了
Object.wait()
或Thread.join()
等方法。此时,线程需要等待其他线程发出通知或完成某些操作才能继续执行。
- 线程进入此状态是因为它调用了
TIME_WAITING(超时等待状态)
- 与 WAITING 状态类似,线程进入此状态是因为它调用了带有时间参数的等待方法,如
Thread.sleep(long millis)
或Object.wait(long millis)
。线程将在指定的时间后自动返回 RUNNABLE 状态,除非被其他线程唤醒。
- 与 WAITING 状态类似,线程进入此状态是因为它调用了带有时间参数的等待方法,如
TERMINATED(终止状态)
- 线程完成其任务后,进入 TERMINATED 状态。如果线程由于异常终止,也会进入此状态。此状态表示线程的生命周期结束,无法再次启动。
线程状态变迁
NEW → RUNNABLE
线程通过调用start()
方法从新建状态变为可运行状态。RUNNABLE → BLOCKED
当线程在执行同步代码块时,若没有获得锁,它将进入 BLOCKED 状态,等待其他线程释放锁。RUNNABLE → WAITING / TIME_WAITING
线程调用wait()
、join()
或sleep()
等方法时,进入等待或超时等待状态。WAITING / TIME_WAITING → RUNNABLE
等待状态的线程会在接收到其他线程的通知后(如通过notify()
、notifyAll()
或时间超时),转回 RUNNABLE 状态。RUNNABLE → TERMINATED
线程执行完毕或因异常退出,进入 TERMINATED 状态。
总结
- NEW:线程刚被创建,尚未启动。
- RUNNABLE:线程准备好执行并等待 CPU 调度。
- BLOCKED:线程因同步问题被阻塞,等待获取锁。
- WAITING:线程因调用
wait()
等方法进入等待状态,需其他线程唤醒。 - TIME_WAITING:线程因调用带超时参数的方法进入等待,超时后自动返回。
- TERMINATED:线程结束,生命周期完结。
Java 线程状态之间会随着不同操作(如 start()
、wait()
、notify()
、锁竞争等)不断切换,线程的调度和状态管理由 JVM 和操作系统共同控制。
线程上下文切换
线程上下文切换是指操作系统从一个线程切换到另一个线程执行时所发生的一系列操作。每个线程都有自己的执行上下文(例如程序计数器、栈信息等),在进行上下文切换时,需要保存当前线程的上下文状态,并恢复下一个线程的上下文状态。
线程上下文切换的触发条件
线程上下文切换通常发生在以下几种情况:
主动让出 CPU:
- 当线程主动让出 CPU 使用权时,比如调用了
sleep()
、wait()
或join()
等方法,当前线程会进入等待或休眠状态,此时操作系统会切换到其他线程。
- 当线程主动让出 CPU 使用权时,比如调用了
时间片用完:
- 操作系统采用时间片轮转调度算法时,线程在 CPU 上执行的时间有一个最大限制,即时间片。当时间片用完时,操作系统会进行线程切换,将当前线程从 CPU 上移除,切换到另一个线程执行。
线程阻塞:
- 当线程请求某些阻塞操作(例如 I/O 操作或锁等待),线程会被阻塞,此时操作系统会切换到其他线程继续执行。比如请求文件读取、数据库访问等操作时,线程可能需要等待,这时会发生上下文切换。
线程终止或结束运行:
- 当线程完成任务或被终止时,操作系统会切换到其他线程执行。
上下文切换的过程
上下文切换过程包括以下几个步骤:
保存当前线程的上下文:
- 操作系统将当前线程的所有相关状态保存起来,通常包括程序计数器、CPU 寄存器的值、栈信息、线程本地数据等。
选择下一个线程:
- 操作系统根据调度算法选择一个准备好执行的线程(例如,时间片用完时,选择下一个就绪队列中的线程)。
恢复下一个线程的上下文:
- 恢复下一个线程的上下文,加载程序计数器、寄存器等状态,确保该线程从它上次中断的位置继续执行。
切换执行:
- 完成上下文恢复后,CPU 开始执行新选中的线程。
上下文切换的性能开销
线程上下文切换是操作系统中非常重要的功能,但它也是一个有开销的操作。每次切换时都需要保存和恢复大量的状态信息,如寄存器、栈、程序计数器等,这些操作占用了 CPU 和内存资源。特别是当线程频繁发生上下文切换时,可能导致以下问题:
- CPU 开销: 上下文切换需要保存和恢复大量的信息,会增加 CPU 的负担。
- 内存开销: 每个线程的上下文信息需要占用一定的内存,频繁切换可能导致内存压力。
- 资源竞争: 在多个线程之间频繁切换时,可能会导致资源的竞争,影响整体系统性能。
因此,频繁的上下文切换会降低程序执行效率,造成性能损失。这也是多线程程序设计中需要考虑的重要问题,尤其是在需要高效并发的场景下。
Thread.sleep()
和 Object.wait()
方法对比
共同点:
- 两者都可以暂停当前线程的执行,暂时让出 CPU 给其他线程执行。
区别
释放锁的行为:
sleep()
方法不会释放线程持有的锁。即使线程在调用sleep()
时正在持有锁,它依然保持锁的占用。wait()
方法会释放线程持有的锁。当线程调用wait()
时,它会释放锁,并进入等待队列,直到其他线程调用同一个对象上的notify()
或notifyAll()
方法,才能恢复执行。
用途:
sleep()
方法通常用于让当前线程暂停执行一段时间,常见于控制线程的执行速度或模拟延迟。wait()
方法通常用于线程间的交互和通信,它是同步工具的一部分,通常用于多线程环境下协调线程之间的操作。
线程恢复的机制:
sleep()
方法执行完成后,线程会自动恢复执行,或者也可以使用wait(long timeout)
来指定超时,线程会在超时后自动醒来。wait()
方法执行后,线程不会自动恢复。线程进入等待队列后,必须由其他线程调用同一对象的notify()
或notifyAll()
方法才能唤醒,恢复执行。
方法所属的类:
sleep()
是Thread
类的静态方法,线程对象调用该方法时无需依赖锁。它属于线程控制的工具方法。wait()
是Object
类的实例方法。任何对象上的wait()
都需要线程持有该对象的锁,调用wait()
会导致线程释放对该对象的锁,并进入等待状态。
设计上的考虑
sleep()
是Thread
类的静态方法,因为它不依赖于任何对象,操作的是当前线程的执行。无论线程是否持有锁,它都可以调用sleep()
来暂停自己。wait()
是Object
类的方法,因为它是基于对象的同步机制。每个对象都有一个监视器锁(monitor),wait()
的调用需要线程持有该对象的锁,才能进入等待状态。这是为了实现线程间基于共享资源的协作。
为什么 wait()
方法不定义在 Thread
中?
wait()
方法定义在 Object
类中,而不是 Thread
类,主要原因在于它操作的是对象的锁,而不是线程自身。wait()
方法的核心功能是让当前线程释放持有的对象锁并进入等待状态,等待某个条件满足后再被唤醒。每个对象都有一个监视器锁(monitor lock),线程需要获取该对象的锁才能执行 wait()
方法。因此,wait()
必须与对象本身相关联,而不是线程。
关键点:
wait()
方法是与对象的锁相关,而不是与线程相关。- 线程在调用
wait()
时会释放当前对象的锁,进入阻塞状态,直到其他线程通过notify()
或notifyAll()
唤醒它。 - 因为每个对象都有一个监视器锁,所以
wait()
方法在Object
类中定义,允许线程通过持有特定对象的锁来调用wait()
方法。
为什么 sleep()
方法定义在 Thread
中?
sleep()
方法定义在 Thread
类中,因为它是用来让当前线程暂停执行的,而不涉及到对象锁。调用 Thread.sleep()
让当前线程休眠指定的时间,这一过程不需要持有任何对象的锁,也不依赖于其他线程的操作,只是单纯地让当前线程在指定时间内不执行。
关键点:
sleep()
方法是与线程本身相关,而不是与任何对象的锁相关。- 它可以暂停线程的执行,并且不需要与其他线程协调。
sleep()
方法对所有线程通用,因此它定义在Thread
类中。
总结来说,wait()
和 sleep()
的区别主要在于它们作用的对象不同:wait()
作用于对象的锁,而 sleep()
作用于线程本身。
可以直接调用 Thread
类的 run()
方法吗?
答案是:不可以通过直接调用 run()
方法来启动线程。
原因分析:
start()
方法的作用:
当调用start()
方法时,线程的状态会从 新建 变为 就绪,然后操作系统会根据时间片轮转分配 CPU 给这个线程。一旦线程获得 CPU 时间片,就会执行run()
方法。start()
方法会启动新的线程,并在该线程中执行run()
方法里的代码。直接调用
run()
方法的情况:
如果直接调用run()
方法,它并不会启动一个新的线程,而是将run()
方法当作普通的 同步方法 在当前线程中执行。也就是说,这时run()
方法的代码会在调用它的线程中执行,而不是在一个新线程中执行。直接调用
run()
方法与普通方法没有什么区别,代码并不会以多线程的方式运行,它依然是单线程执行的。
示例:
class MyThread extends Thread {
@Override
public void run() {
System.out.println("This is the run method.");
}
}
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
// 直接调用 run() 方法,不会启动新的线程
thread.run(); // 在当前线程(main线程)中执行,非多线程
// 使用 start() 方法,启动新的线程
thread.start(); // 会启动新的线程来执行 run() 方法
}
}
在上面的代码中:
thread.run()
只是普通的函数调用,run()
方法会在当前线程中执行,而不是在新线程中。thread.start()
会创建一个新线程并在新线程中执行run()
方法,从而实现多线程。
总结:
调用 start()
方法才会启动一个新线程,执行 run()
方法的内容。而直接调用 run()
方法,实际上是在当前线程中调用该方法,并不会启动新线程,因此不会执行多线程操作。
二、多线程
并发与并行的区别是什么?
并发:指的是多个作业在同一 时间段 内交替执行,但并不一定是同时进行。多个作业在一个时间段内被切换执行,看起来像是同时进行,但实际上只有一个 CPU 核心在运行(在单核 CPU 上),或者多个核心之间共享执行时间。并发并不要求任务是同时执行的,而是要求它们可以在同一时间段内交替执行。
并行:指的是多个作业在同一 时刻 同时执行。并行通常发生在多核 CPU 环境中,多个任务同时在不同的核心上执行,真正实现了物理上的同时运行。并行要求任务是同时执行的。
关键区别:
- 是否同时执行:并发强调任务可以在同一时间段内执行,而并行强调任务在同一时刻同时执行。
- 执行方式:并发可以通过切换任务来模拟“同时”执行,而并行则是在多个处理器核心上真正实现同时执行。
示例:
- 并发示例:在单核 CPU 上,操作系统通过上下文切换,快速交替执行多个线程或进程,使得多个任务看起来像是在同时进行,但实际上它们是分时执行的。
- 并行示例:在多核 CPU 上,多个线程或进程可以同时在不同的核心上执行,从而实现物理上的并行处理。
总结:
并发和并行的区别在于,并发是多个任务在同一时间段内交替执行,而并行是多个任务在同一时刻同时执行。并行是并发的一种特殊情况,前提是有足够的硬件资源支持。
同步和异步的区别
同步:在同步操作中,调用方会在发出请求后,等待操作的结果完成再继续执行后续代码。也就是说,调用方会阻塞,直到操作完成并返回结果。同步意味着调用方与被调用方的执行是顺序进行的,调用方必须等待被调用方完成。
异步:在异步操作中,调用方发出请求后,不需要等待操作完成,可以继续执行其他任务。异步操作不会阻塞调用方,调用方在发出请求后,立即返回,操作完成的结果会通过回调、事件或其他机制告知调用方。
关键区别:
- 阻塞与非阻塞:同步操作是阻塞的,异步操作是非阻塞的。
- 等待与不等待:同步调用会等待返回结果后才继续,异步调用则在发出请求后立刻返回,不等待结果。
示例:
- 同步示例(Java):
public class SyncExample {
public static void main(String[] args) {
System.out.println("Start");
doTask(); // 同步调用
System.out.println("End");
}
public static void doTask() {
try {
Thread.sleep(2000); // 模拟任务执行
System.out.println("Task Done");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在这个示例中,doTask()
是同步执行的,main()
方法会等待 doTask()
执行完毕后才会打印 "End"。
- 异步示例(Java):
import java.util.concurrent.CompletableFuture;
public class AsyncExample {
public static void main(String[] args) {
System.out.println("Start");
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(2000); // 模拟任务执行
System.out.println("Task Done");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("End");
}
}
在这个示例中,runAsync
会异步执行任务,main()
方法不会等待任务完成,直接打印 "End"。
总结:
- 同步:调用会阻塞,直到操作完成才继续执行后续代码。
- 异步:调用不会阻塞,发出请求后立即返回,操作的结果通常通过回调或其他机制通知。
⭐️为什么要使用多线程?
先从总体上来说:
- 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
- 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
再深入到计算机底层来探讨:
- 单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
- 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 核心上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
⭐️ 单核 CPU 支持 Java 多线程吗?
答案是:单核 CPU 完全支持 Java 多线程。
虽然单核 CPU 一次只能处理一个线程,但通过操作系统的线程调度机制,多个线程可以在 CPU 上交替执行。操作系统通过 时间片轮转(Time Slicing)技术,使得每个线程在一定的时间段内执行,然后切换到下一个线程,这样用户就能感知到多个任务是同时进行的。
线程调度机制
在单核 CPU 上,多线程并不是并行执行的(即不是同时执行),而是通过线程的 快速切换,给人一种并行执行的错觉。具体的线程调度方式有两种:
1. 抢占式调度(Preemptive Scheduling):
- 操作方式:操作系统负责决定何时暂停当前线程,切换到另一个线程执行。系统通过时钟中断或者高优先级事件(如 I/O 操作完成)来触发线程切换。
- 优点:能更有效地利用 CPU 资源,因为线程切换是由操作系统控制的,且有较好的公平性。不同优先级的线程可以得到相应的 CPU 时间片。
- 缺点:存在上下文切换的开销,因为线程切换需要保存和恢复上下文。
Java 使用的线程调度方式就是 抢占式调度。JVM 将线程调度委托给操作系统,而操作系统根据线程的优先级和时间片来决定线程的执行顺序。
2. 协同式调度(Cooperative Scheduling):
- 操作方式:线程主动让出 CPU,通知系统切换到下一个线程。
- 优点:可以减少上下文切换的开销,因为线程不会被强制中断。
- 缺点:不容易实现公平性,如果一个线程没有主动让出 CPU,可能会导致其他线程一直得不到执行,导致阻塞。
Java 中的线程调度
- JVM 的角色:JVM 本身并不直接管理线程的调度,它将线程调度的责任交给了操作系统。JVM 只是通过
Thread
类等提供了多线程编程的接口。 - 操作系统的调度:操作系统根据线程的优先级和系统的负载来调度线程执行。线程调度通常是由操作系统内核负责的,不同的操作系统可能使用不同的策略来管理线程(如 Linux 使用 CFS 调度器,Windows 使用多级反馈队列)。
结论
- 在单核 CPU 上,多线程的核心优势是通过时间片轮转让多个线程共享 CPU 时间,这样系统能够高效地运行多个任务,即使 CPU 只能同时处理一个线程。
- Java 多线程的实现依赖于操作系统的调度机制,无论是单核还是多核 CPU,Java 都能通过操作系统的调度来实现多线程处理。
⭐️ 单核 CPU 上运行多个线程效率一定会高吗?
答案是:不一定。 单核 CPU 上是否能提高效率,取决于任务的性质以及线程的类型。
1. CPU 密集型任务:
定义:CPU 密集型任务主要是进行大量的计算和处理,几乎不需要等待外部资源。例如复杂的数学计算、数据处理、图像处理等。
影响:在单核 CPU 上,多个线程同时运行时,操作系统需要不断地切换线程,这会导致频繁的上下文切换。每次切换都需要保存和恢复线程的状态,这样会消耗额外的 CPU 时间,导致 线程切换开销。对于 CPU 密集型任务,频繁的上下文切换会大大降低性能。
结论:在单核 CPU 上,开启多个线程对于 CPU 密集型任务可能 不会提高效率,反而可能因为线程切换开销而降低效率。
2. IO 密集型任务:
定义:IO 密集型任务主要涉及输入/输出操作,如读写文件、网络请求等。IO 操作通常需要等待外部设备响应,而在等待期间 CPU 并不需要做计算。
影响:在单核 CPU 上,如果一个线程进行 IO 操作而阻塞,其他线程可以继续利用 CPU 执行其他任务。因为线程并不会在等待 IO 时占用 CPU 资源,多个线程可以交替执行,从而充分利用 CPU 在等待 IO 响应时的空闲时间。
结论:在单核 CPU 上,IO 密集型任务 会从多线程中受益,因为多个线程可以在等待 IO 时交替执行,从而提升效率。
3. 线程数的上限:
- 线程数过多:无论是 CPU 密集型还是 IO 密集型任务,线程数如果过多,也会增加系统的负担,导致线程调度的开销增加,甚至导致性能下降。每个线程都有一定的管理和调度成本,因此需要合理控制线程的数量。
总结:
- CPU 密集型任务:在单核 CPU 上,多个线程并不会提高效率,反而可能由于频繁的上下文切换增加系统负担。
- IO 密集型任务:在单核 CPU 上,多个线程可以更好地利用 CPU 的空闲时间,提升效率。
因此,单核 CPU 上是否开启多个线程,取决于任务的性质。对于 IO 密集型任务,多线程能够提高效率,而对于 CPU 密集型任务,过多线程反而会导致效率下降。
使用多线程可能带来什么问题?
虽然多线程可以提高程序的并发性和效率,但也可能带来一系列的复杂问题。以下是一些常见的问题:
1. 线程安全问题
- 问题描述:多个线程同时访问和修改共享数据时,可能导致数据不一致或程序异常。比如,多个线程同时修改一个全局变量或共享资源,可能导致数据丢失或错误结果。
- 常见情况:如果多个线程同时写同一个文件,或者多个线程同时更新同一数据库记录,就可能发生数据竞争(Race Condition),从而导致不可预期的结果。
- 解决方案:
- 使用同步机制,如
synchronized
关键字、ReentrantLock
、ReadWriteLock
等。 - 使用线程安全的容器,如
ConcurrentHashMap
、CopyOnWriteArrayList
等。 - 使用原子操作类,如
AtomicInteger
、AtomicLong
等。
- 使用同步机制,如
2. 死锁(Deadlock)
- 问题描述:死锁是指两个或多个线程在执行过程中,因为争夺资源而造成的一种相互等待的局面,导致程序无法继续执行。通常发生在两个线程各自持有对方需要的锁时。
- 常见情况:线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1,导致两者永远互相等待,程序无法继续执行。
- 解决方案:
- 避免嵌套锁,尽量避免线程在持有锁的情况下去申请其他锁。
- 使用
Lock
接口(如ReentrantLock
)的tryLock()
方法,设置超时机制来避免死锁。 - 按照一定的顺序获取锁,确保多个线程在获取锁时的顺序是一致的,从而避免死锁。
3. 资源竞争(Race Condition)
- 问题描述:多个线程竞争同一资源,导致不可预期的行为。例如,两个线程同时修改一个变量或写入文件,可能导致数据丢失或文件内容错乱。
- 常见情况:多个线程同时访问和修改共享变量时,若没有适当的同步机制,就会导致不一致的状态。
- 解决方案:
- 使用同步机制来确保共享资源的访问是串行的。
- 在共享数据的读写操作上加锁,确保线程安全。
4. 线程饥饿(Thread Starvation)
- 问题描述:某些线程因系统中其他线程的长期执行而始终无法获得 CPU 时间,导致线程无法执行。这通常发生在线程优先级调度不当时。
- 常见情况:如果一个低优先级的线程一直被高优先级线程抢占 CPU 时间,它就可能永远得不到执行的机会。
- 解决方案:
- 合理设置线程优先级,避免某些线程的长时间饥饿。
- 使用线程池管理线程,保证线程的公平性。
5. 内存泄漏(Memory Leak)
- 问题描述:线程持有不必要的资源,导致无法及时释放资源,造成内存泄漏。特别是在并发程序中,线程退出时如果没有正确清理资源(如数据库连接、文件句柄、线程池等),会导致内存占用逐渐增大,最终影响系统性能甚至崩溃。
- 常见情况:线程池中的线程没有被正确地回收,或者每次创建线程时未正确释放资源。
- 解决方案:
- 使用
try-with-resources
语句来自动管理资源。 - 使用线程池,避免频繁创建和销毁线程。
- 确保线程退出时正确释放资源,避免资源泄漏。
- 使用
6. 上下文切换开销(Context Switching Overhead)
- 问题描述:当多个线程在单核 CPU 上运行时,操作系统需要频繁进行线程切换(上下文切换)。每次切换线程时,操作系统都需要保存和恢复线程的状态,这会引入额外的开销。频繁的上下文切换会导致 CPU 时间浪费,降低系统性能。
- 常见情况:当线程过多时,线程切换的开销会变得显著,影响程序的性能。
- 解决方案:
- 合理限制线程的数量,避免创建过多的线程。
- 使用线程池来复用线程,减少线程的创建和销毁次数。
7. 线程的调度问题
- 问题描述:线程的调度是由操作系统来完成的,JVM 只是请求操作系统执行线程,线程的执行顺序和时间片分配可能会受到操作系统的调度算法影响。在某些情况下,线程的执行可能并不是预期的,可能会导致线程的执行顺序不确定。
- 常见情况:线程的优先级、操作系统的调度策略等都可能影响线程的执行顺序,可能导致线程执行顺序不如预期。
- 解决方案:
- 使用线程池来控制线程的调度,避免过多的线程调度干扰。
- 合理设置线程优先级。
8. 任务分配与负载不均
- 问题描述:多线程应用中,如果任务没有合理分配,或者任务之间的工作量差异较大,可能导致线程资源的不均衡利用。某些线程可能会长时间处于空闲状态,而其他线程则因工作量过大而无法及时完成任务。
- 常见情况:在并行计算中,任务分配不合理,可能导致某些线程负载过重,成为瓶颈。
- 解决方案:
- 使用任务划分和负载均衡策略,确保各个线程的工作量尽量均匀。
- 使用线程池和任务队列进行任务分配。
总结:
虽然多线程可以提高程序的并发性和性能,但它也带来了一些潜在的风险和问题,如线程安全问题、死锁、资源竞争、内存泄漏等。因此,在设计多线程程序时,需要格外小心,合理使用同步机制、锁、线程池等工具,以确保程序的稳定性和性能。同时,要避免过度设计,尽量控制线程数量,避免因频繁的上下文切换带来性能瓶颈。
如何理解线程安全和不安全?
线程安全和线程不安全是对多线程环境中对共享数据访问的一种描述,决定了在并发情况下数据是否能够保持正确性和一致性。
1. 线程安全:
线程安全指的是在多线程环境下,对同一份数据的访问能保证其正确性和一致性,无论多少个线程同时对数据进行读写操作,都不会导致数据错误或状态不一致。这是通过适当的同步机制来实现的。
关键特性:
- 数据一致性:多个线程并发访问同一数据时,最终的结果始终是期望的,不会由于线程间的干扰而产生不一致的状态。
- 并发安全:多个线程可以安全地执行,而不会因为线程切换或资源竞争导致错误或数据损坏。
典型的线程安全实现:
- 不可变对象(Immutable Objects):如
String
类,所有字段在对象创建时即被初始化且不可更改,因此本质上是线程安全的。 - 同步方法/代码块:通过
synchronized
关键字保证同一时刻只有一个线程能访问特定代码段,保证线程安全。 - 原子操作类:如
AtomicInteger
,通过硬件支持的原子操作来保证数据的一致性。 - 线程安全集合:如
ConcurrentHashMap
,CopyOnWriteArrayList
等,内部已经实现了线程安全的机制。
示例:
public class ThreadSafeExample {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public synchronized int getCounter() {
return counter;
}
}
在上述代码中,increment()
方法和 getCounter()
方法被 synchronized
修饰,确保在多线程环境下只有一个线程能够访问这些方法,从而避免数据竞争。
2. 线程不安全:
线程不安全是指在多线程环境下,多个线程同时访问和修改同一份数据时,可能会导致数据不一致或错误。这通常是因为缺乏适当的同步机制,多个线程的操作可能会相互干扰,导致竞态条件(Race Condition)等问题。
关键特性:
- 数据竞争:多个线程对共享数据进行同时读写时,未加锁的操作可能会导致数据不一致或丢失。
- 状态不一致:在没有同步的情况下,一个线程的操作可能会干扰另一个线程的操作,导致最终结果不符合预期。
典型的线程不安全实现:
- 共享可变对象:例如在多个线程访问同一变量时,没有加锁或其他同步手段。
- 无锁数据结构:如
HashMap
(非线程安全版本),多个线程同时修改时可能会导致数据丢失或异常。
示例:
public class ThreadUnsafeExample {
private int counter = 0;
public void increment() {
counter++;
}
public int getCounter() {
return counter;
}
}
在上述代码中,increment()
方法没有使用同步,多个线程可能同时访问 counter
变量,导致数据竞争。比如,线程 A 和线程 B 同时读取 counter
的值,递增后写回,最终结果可能不正确。
3. 线程不安全的后果:
- 数据丢失:当多个线程同时修改数据时,某些修改可能被覆盖或丢失。
- 状态不一致:程序可能在某些时刻处于不可预测的状态,导致后续操作无法正常执行。
- 异常:某些情况下,线程不安全的代码会抛出异常,导致程序崩溃或产生不期望的行为。
4. 如何保证线程安全:
- 锁机制:使用
synchronized
关键字或Lock
接口来保证在某一时刻只有一个线程可以访问共享资源。 - 原子操作:使用原子操作类(如
AtomicInteger
),通过硬件支持的操作来保证对共享资源的修改是原子的。 - 不可变对象:设计不可变对象,保证对象的状态在创建时就已经固定,不会被修改。
- 线程安全集合类:使用 Java 提供的线程安全集合类(如
ConcurrentHashMap
、CopyOnWriteArrayList
)来替代非线程安全的集合类。
总结:
- 线程安全:通过适当的同步机制,确保多线程环境中对共享数据的访问不会出现数据不一致或错误。
- 线程不安全:由于缺乏同步机制或访问控制,多个线程并发访问共享数据时,可能导致竞态条件、数据丢失或状态不一致。
线程安全性是并发编程中最重要的问题之一,开发人员必须根据实际需求合理选择合适的同步机制或数据结构,确保程序在多线程环境下的正确性和稳定性。
二、⭐️死锁
什么是线程死锁?
线程死锁是指两个或多个线程在执行过程中,由于竞争资源而造成一种相互等待的局面,导致程序中的线程无法继续执行,从而进入一种永久阻塞的状态。这种情况通常发生在多个线程持有互斥锁,并且每个线程都在等待其他线程持有的资源,导致无法继续执行。
死锁通常由以下四个必要条件导致:
- 互斥条件:至少有一个资源必须处于“被占用”状态,即每次只能有一个线程使用该资源。
- 请求与保持条件:一个线程在占有某些资源的同时,申请其他资源并阻塞。
- 不剥夺条件:已经获得的资源不能被强行剥夺,必须等线程自愿释放资源后,其他线程才能获取。
- 循环等待条件:存在一个线程等待链,链中的每个线程都在等待其他线程持有的资源,形成一个闭环。
这四个条件必须同时满足,才能发生死锁。
线程死锁的示例
下面是一个简单的示例代码,模拟了死锁的情况:
public class DeadLockDemo {
private static Object resource1 = new Object(); // 资源 1
private static Object resource2 = new Object(); // 资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + " get resource1");
try {
Thread.sleep(1000); // 让线程 A 有机会获取资源 1,并让线程 B 获取资源 2
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + " waiting for resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + " get resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + " get resource2");
try {
Thread.sleep(1000); // 让线程 B 有机会获取资源 2,并让线程 A 获取资源 1
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + " waiting for resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + " get resource1");
}
}
}, "线程 2").start();
}
}
输出:
Thread[线程 1,5,main] get resource1
Thread[线程 2,5,main] get resource2
Thread[线程 1,5,main] waiting for resource2
Thread[线程 2,5,main] waiting for resource1
死锁分析:
- 线程 1 获取了
resource1
的锁,并且休眠 1 秒钟。 - 线程 2 获取了
resource2
的锁,并且休眠 1 秒钟。 - 线程 1 在休眠后,开始等待
resource2
锁,而此时线程 2 正在等待resource1
锁。 - 结果,线程 1 和线程 2 互相等待对方释放资源,造成死锁,程序无法继续执行。
死锁的四个必要条件:
- 互斥条件:
resource1
和resource2
是互斥资源,每次只有一个线程能占用。 - 请求与保持条件:线程 1 已经持有
resource1
,并请求resource2
,线程 2 已经持有resource2
,并请求resource1
。 - 不剥夺条件:线程 1 和线程 2 持有的资源无法被其他线程强行夺走。
- 循环等待条件:线程 1 等待
resource2
,线程 2 等待resource1
,形成循环等待关系。
如何避免死锁?
避免嵌套锁:
- 设计时避免让多个线程相互依赖多个资源,减少嵌套锁的使用。
- 如果必须使用多个锁,确保获取锁的顺序是一致的,避免形成循环等待关系。
使用超时机制:
- 使用
tryLock()
(如ReentrantLock
)提供的超时机制,若某个线程在一定时间内未能成功获取锁,就放弃获取并进行其他处理。
- 使用
锁排序:
- 给所有锁定义一个固定的顺序,每次申请多个资源时,按照统一顺序申请锁,防止死锁的发生。
死锁检测:
- 通过定期检查线程的状态或使用操作系统提供的工具(如 JConsole、VisualVM 等)监控死锁情况。
使用现代并发工具:
- 使用高层次的并发库(如
java.util.concurrent
包中的ExecutorService
等)来减少直接操作线程和锁的复杂度,避免潜在的死锁问题。
- 使用高层次的并发库(如
总结:
死锁是多线程编程中的一个经典问题,它通常由竞争资源、请求与保持、循环等待等条件引发。通过合理设计程序结构、使用适当的锁机制和超时控制,可以有效避免死锁的发生。
如何检测死锁?
死锁的检测方法
死锁发生时,多个线程因相互等待对方释放资源而导致永远无法继续执行。为了检测死锁,通常使用以下几种方法:
1. 使用 jstack
命令检测死锁
jstack
是一个命令行工具,能够打印出 JVM 中所有线程的堆栈信息。通过解析线程栈,可以发现死锁的发生。死锁的线程通常会出现在 阻塞状态(BLOCKED),并且每个线程都在等待持有某个锁的线程释放锁。
步骤:
使用
jstack
命令打印线程堆栈:jstack <pid>
其中
<pid>
是 Java 进程的进程 ID。查找
Found one Java-level deadlock
字样:
如果发生死锁,jstack
的输出会显示类似以下内容:Found one Java-level deadlock: ============================= "Thread-1" prio=5 tid=0x00007f17b800c800 nid=0x10a0 waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadlockDemo$Task.run(DeadlockDemo.java:42) - waiting to lock <0x00000000c02c2050> (a java.lang.Object) at java.lang.Thread.run(Thread.java:748) Locked ownable synchronizers: - None "Thread-2" prio=5 tid=0x00007f17b800d800 nid=0x10a1 waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadlockDemo$Task.run(DeadlockDemo.java:51) - waiting to lock <0x00000000c02c20a0> (a java.lang.Object) at java.lang.Thread.run(Thread.java:748) Locked ownable synchronizers: - None
从输出中可以看出,
Thread-1
和Thread-2
互相等待对方持有的资源,形成了死锁。分析死锁信息:
- 线程的堆栈信息中,通常会显示线程所持有的锁以及线程正在等待的锁。
- 如果两个或多个线程在相互等待持有的资源,并且这些资源不可抢占,那么可以确认是死锁。
2. 使用 jmap
命令检查堆内存
jmap
命令可以用于获取堆信息并进行分析,虽然它不直接检测死锁,但可以帮助分析内存占用情况。如果程序因死锁导致线程长时间处于等待状态,可能会出现异常的内存占用。
步骤:
- 使用
jmap
命令获取堆信息:jmap -heap <pid>
- 分析堆内存情况,若出现异常的内存占用(如大量线程占用 CPU)可能与死锁有关。
3. 使用 VisualVM 和 JConsole 等图形化工具
VisualVM 和 JConsole 是 JDK 提供的图形化工具,能够监控应用的运行状态,并且提供死锁检测的功能。
JConsole 检测死锁:
- 打开 JConsole(在 JDK 的
bin
目录下),连接到目标 Java 应用程序。 - 进入 线程 选项卡,点击 死锁检测 按钮。
- 如果程序存在死锁,JConsole 会显示涉及死锁的线程及其等待的资源信息。
VisualVM 步骤:
- 打开 VisualVM 并连接到目标 Java 应用。
- 在 线程 视图中查看线程状态,若有线程处于阻塞状态且相互等待,则可能是死锁。
- 可以进一步查看堆栈信息,确认是否存在死锁。
4. 通过日志和监控工具进行死锁检测
在生产环境中,可以通过日志记录和监控工具帮助定位死锁问题。
步骤:
- 日志记录:
- 在每个线程尝试获取锁时记录日志,记录锁的申请、获取和释放。
- 如果线程长时间没有释放锁,可以通过日志观察是否有线程在死锁状态。
- 监控工具:
- 使用 Prometheus、Grafana 等监控工具监控应用的线程状态,特别是 CPU 和内存的使用情况。
- 如果发现 CPU 占用过高,并且内存逐渐增加,可以进一步检查是否有死锁发生。
5. 死锁预防策略
虽然检测死锁很重要,但在编写多线程程序时,预防死锁更为关键。以下是一些常用的预防措施:
- 避免嵌套锁:尽量避免多个线程持有多个锁的情况,减少资源竞争。
- 锁的顺序:如果必须获取多个锁,确保所有线程按照相同的顺序请求锁,以避免循环等待。
- 使用
ReentrantLock
的tryLock()
方法:通过tryLock()
方法设置超时值,如果一个线程无法在规定时间内获得锁,则放弃,避免死锁的发生。 - 定期检查线程状态:通过定期检查线程的状态和锁的占用情况,及时发现死锁的迹象。
总结
检测死锁通常依赖于工具和命令,如 jstack
、jmap
、JConsole 和 VisualVM。它们通过提供详细的线程栈信息和线程状态,帮助开发人员快速定位死锁问题。在生产环境中,使用适当的监控工具并结合日志记录能够有效预防和检测死锁。此外,合理的锁策略和死锁检测机制能够减少死锁问题的发生,提高程序的稳定性和性能。
线程死锁的预防和避免
线程死锁的发生通常是因为资源的竞争和等待关系形成了环路,而每个线程都在等待其他线程释放资源。为了避免死锁,我们需要理解并打破死锁产生的四个必要条件。以下是具体的预防和避免死锁的方法。
预防死锁的方法
死锁的发生需要满足以下四个条件(资源竞争的四个必要条件),通过破坏这些条件,可以有效预防死锁。
破坏请求与保持条件(Hold and Wait)
- 定义:线程持有至少一个资源并且请求新的资源时,不释放它已经持有的资源。
- 预防策略:线程在获取任何资源之前,必须一次性请求所有资源。如果线程不能一次性获得所有所需资源,它将不会占有任何资源。
例如,线程只能在获得所有资源的情况下才开始工作。这样可以避免线程持有部分资源时,等待其他资源的情况。
synchronized (resource1) { synchronized (resource2) { // do something } }
破坏不剥夺条件(No Preemption)
- 定义:已分配的资源不能被强制剥夺,线程只能自己释放。
- 预防策略:如果线程申请不到所需资源,可以主动释放已持有的资源。这样可以防止线程在持有部分资源时因无法获得其他资源而进入阻塞。
例如,线程申请一个资源时,如果不能成功获得,则会释放之前已占有的资源,并重新尝试申请资源。
// try-lock example using ReentrantLock if (lock1.tryLock() && lock2.tryLock()) { try { // do something } finally { lock1.unlock(); lock2.unlock(); } } else { // release resources and retry }
破坏循环等待条件(Circular Wait)
- 定义:多个线程形成一种环状等待,每个线程都在等待其他线程持有的资源。
- 预防策略:规定一个资源的获取顺序,所有线程必须按照统一的顺序申请资源,这样就可以避免形成循环等待。
例如,所有线程按固定顺序(如
resource1 -> resource2 -> resource3
)请求资源,如果线程未能按顺序获取资源,就会释放已占有的资源并重试。synchronized (resource1) { synchronized (resource2) { // do something } }
破坏互斥条件(Mutual Exclusion)
- 定义:每个资源只有一个线程能够持有,不能共享。
- 预防策略:虽然互斥条件不可完全破坏(因为资源本身是不可共享的),但是可以通过避免资源的过度占用来减少死锁的风险。例如,合理设计线程和资源的使用方式,避免过长时间占用锁。
避免死锁的方法
避免死锁的核心思想是通过精确的资源分配和调度算法,使得系统保持在一个安全状态。这里有几种常见的避免死锁的方法:
使用银行家算法(Banker's Algorithm)
- 银行家算法是一种基于资源需求评估的死锁避免算法。通过检查资源的最大需求和当前可用资源,确保资源分配不会导致系统进入不安全状态。
- 系统每次分配资源前,都会判断是否会导致系统进入死锁状态。只有在分配后系统仍然处于安全状态时,才会分配资源。
采用定时锁(Timed Lock)
- 使用带有超时机制的锁(例如
ReentrantLock
的tryLock()
方法)来避免死锁。在请求锁时,如果线程无法在规定时间内获得锁,它会主动放弃并释放已持有的资源。
if (lock.tryLock(timeout, TimeUnit.SECONDS)) { try { // Perform task } finally { lock.unlock(); } }
- 使用带有超时机制的锁(例如
锁的顺序
- 确保所有线程在同一固定顺序中请求资源。这样可以避免死锁的发生。例如,所有线程都按相同顺序(如
resource1 -> resource2 -> resource3
)请求资源。
synchronized (resource1) { synchronized (resource2) { // do something } }
- 确保所有线程在同一固定顺序中请求资源。这样可以避免死锁的发生。例如,所有线程都按相同顺序(如
使用死锁检测和恢复
- 在某些系统中,可以通过实时检测线程和资源的状态,发现死锁并采取措施来恢复。检测方法如前所述的
jstack
、jmap
等工具可以在生产环境中监控死锁的发生。 - 一旦死锁被发现,可以强制终止死锁的线程,或者通过重试机制让线程释放资源并重新尝试。
- 在某些系统中,可以通过实时检测线程和资源的状态,发现死锁并采取措施来恢复。检测方法如前所述的
具体案例:避免死锁的代码示例
在下面的代码中,线程 1 和线程 2 可能会死锁,因为它们按相反顺序请求资源。为了避免死锁,我们确保所有线程都按照相同顺序请求资源。
死锁发生示例:
public class DeadlockDemo {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + " got resource1");
try { Thread.sleep(1000); } catch (InterruptedException e) {}
synchronized (resource2) {
System.out.println(Thread.currentThread() + " got resource2");
}
}
}).start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + " got resource2");
try { Thread.sleep(1000); } catch (InterruptedException e) {}
synchronized (resource1) {
System.out.println(Thread.currentThread() + " got resource1");
}
}
}).start();
}
}
避免死锁:
public class AvoidDeadlockDemo {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + " got resource1");
try { Thread.sleep(1000); } catch (InterruptedException e) {}
synchronized (resource2) {
System.out.println(Thread.currentThread() + " got resource2");
}
}
}).start();
new Thread(() -> {
synchronized (resource1) { // 按照相同顺序申请锁
System.out.println(Thread.currentThread() + " got resource1");
try { Thread.sleep(1000); } catch (InterruptedException e) {}
synchronized (resource2) {
System.out.println(Thread.currentThread() + " got resource2");
}
}
}).start();
}
}
总结
避免死锁的核心是合理的资源管理和锁定策略,以及及时检测和恢复机制。通过遵循锁的顺序、避免嵌套锁和使用超时机制等方法,我们可以有效减少死锁的风险。此外,在高并发的生产环境中,及时的死锁检测与分析工具也是非常重要的,能够帮助我们在问题发生时快速定位并解决。
三、虚拟线程
虚拟线程在 Java 21 正式发布,这是一项重量级的更新。我写了一篇文章来总结虚拟线程常见的问题:虚拟线程常见问题总结,包含下面这些问题:
- 什么是虚拟线程?
- 虚拟线程和平台线程有什么关系?
- 虚拟线程有什么优点和缺点?
- 如何创建虚拟线程?
- 虚拟线程的底层原理是什么?