大家好,我是栗筝i,从 2022 年 10 月份开始,我便开始致力于对 Java 技术栈进行全面而细致的梳理。这一过程,不仅是对我个人学习历程的回顾和总结,更是希望能够为各位提供一份参考。因此得到了很多读者的正面反馈。 而在 2023 年 10 月份开始,我将推出 Java 面试题/知识点系列内容,期望对大家有所助益,让我们一起提升。 今天与您分享的,是 Java 并发知识面试题系列的总结篇(上篇),我诚挚地希望它能为您带来启发,并在您的职业生涯中起到助益作用。衷心感谢每一位朋友的关注与支持。
1、Java并发面试题问题
1.1、并发基础
- 问题 01. 简述并发与并行的区别
- 问题 02. 简述进程与线程的区别
- 问题 03. 什么是线程安全问题
1.2、Java线程
- 问题 04. 简述 Java 线程的生命周期
- 问题 05. 简述 Java 线程的实现方式
- 问题 06. 简述 Java 线程的结束方式
- 问题 07. 简述 Java 线程的通信方式
- 问题 08. 简述 Java 线程的 sleep() 和 wait() 有什么区别?
- 问题 09. 简述 Java 线程的 run() 和 start() 有什么区别?
- 问题 10. 什么是线程调度?
- 问题 11. 什么是线程优先级?
- 问题 12. 什么是守护线程?
- 问题 13. 什么是线程组?
- 问题 14. 什么是上下文切换?
- 问题 15. 什么是线程饥饿和线程耗尽?
- 问题 16. 什么是线程本地存储?
1.3、集合
- 问题 17. 什么是线程安全的集合?
- 问题 18. 什么是线程不安全的集合?
1.4、线程死锁活锁
- 问题 19. 什么是死锁?如何避免死锁?
- 问题 20. 什么是活锁和饥饿?
2、Java并发面试题解答
2.1、并发基础
- 问题 01. 简述并发与并行的区别
解答:
并发(Concurrency)和并行(Parallelism)是两个经常被提到的概念,它们在多任务环境中有着重要的作用,但是它们之间存在着明显的区别。
- 并发(Concurrency):并发是指系统具有处理多个任务的能力,但不一定会同时执行。在单核 CPU 系统中,由于 CPU 核心只有一个,因此同一时刻只能执行一个任务,但是由于任务切换速度非常快,使得人类感觉到好像有多个任务在同时进行,这就是并发。
- 并行(Parallelism):并行是指系统同时执行多个任务的能力。在多核 CPU 系统中,由于存在多个 CPU 核心,因此可以同时处理多个任务,这就是并行。
总结来说,如果把并发和并行比作是跑步,那么并发就像是接力赛,一次只有一个人在跑,但是可以通过接力棒的传递,让不同的人参与到跑步中来;而并行就像是百米赛跑,每个人都在自己的跑道上同时开始跑步。
- 问题 02. 简述进程与线程的区别
解答:
进程和线程都是操作系统进行任务调度的基本单位,但它们之间存在一些主要的区别:
- 独立性:进程是系统资源分配的最小单位,线程是系统调度的最小单位。进程有自己独立的地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种方式使得进程间的地址空间是相互独立的。而线程是共享进程中的数据的,使用相同的地址空间,因此 CPU 切换一个线程的花费远比进程要小。
- 资源开销:相比于进程,线程的创建和销毁所需要的资源开销较小。
- 通信方式:由于同一进程下的多个线程共享数据,因此线程间的通信更方便。在进程内部的多个线程之间,可以直接读写进程数据段(如全局变量)来进行通信——线程间通信。但是进程间的通信(IPC,Inter-Process Communication)需要使用各种通信机制或者通过系统提供的 IPC 函数来进行。
- 可并发性:在系统中同时存在多个进程,但只有一个线程是在运行的,不过,如果系统有多个 CPU,则可以支持多个线程同时运行。
总的来说,每个进程都有独立的代码和数据空间(程序上下文),线程是共享数据段的并发执行路径,线程在执行过程中,需要协作同步。不同进程的线程间要利用消息通信的方式实现同步。
- 问题 03. 什么是线程安全问题
解答:
线程安全问题通常出现在多线程环境下,当多个线程同时访问和修改同一份数据时,如果没有进行适当的同步控制,可能会导致数据的不一致,这就是线程安全问题。
举个例子,假设有两个线程同时对一个变量进行加 1 操作,初始值为 0。理想的结果应该是 2,但是在没有同步控制的情况下,可能会出现结果为 1 的情况。这是因为加 1 操作不是原子操作,它包括读取变量值、进行加法操作、写回结果三个步骤,当两个线程的操作交叉进行时,就可能导致结果错误。
为了避免线程安全问题,通常需要使用各种同步机制,如互斥锁(Mutex)、读写锁(ReadWriteLock)、信号量(Semaphore)等,来确保在任何时刻,只有一个线程能够访问和修改数据。
2.2、Java线程
- 问题 04. 简述 Java 线程的生命周期
解答:
Java 线程的生命周期可以分为五个状态:
- 新建状态 (New): 当用
new
关键字创建了线程对象之后,即进入了新建状态,例如,Thread t = new Thread();
此时,线程尚未开始运行。 - 就绪状态 (Runnable): 当调用线程对象的
start()
方法时,线程进入就绪状态。处于就绪状态的线程并没有开始运行,但已经具备了运行条件,只是等待获取CPU的执行权。例如,t.start();
此时,线程在 JVM 的线程调度器的调度下等待被分配到时间片。 - 运行状态 (Running): 线程获取到CPU时间片后,进入运行状态,实际开始执行
run()
方法中定义的任务。如果run()
方法执行完毕,线程将直接进入死亡状态;如果线程的run()
方法还没有执行完毕,它可以被切换回就绪状态,以便在将来某个时间点再次被运行。 - 阻塞状态 (Blocked): 线程在Java中可能会因为几个原因进入阻塞状态:
- 等待阻塞:执行
wait()
方法,线程会释放持有的监视器锁并进入对象的等待池,只有等待其他线程调用同一个对象的notify()
方法或notifyAll()
方法线程才有可能被唤醒。 - 同步阻塞:线程在获取同步锁时(进入
synchronized
块或方法),如果锁被其他线程持有,则进入同步阻塞状态。 - 其他阻塞:通过调用线程的
sleep()
、join()
或者发生I/O
请求时,线程会进入阻塞状态。当sleep()
状态超时、join()
等待线程终止或者I/O
处理完毕时,线程重新进入就绪状态。
- 等待阻塞:执行
- 死亡状态 (Terminated/Dead): 线程的
run()
方法执行完毕后,或者调用stop()
方法(已被弃用,因为不安全),或者run()
方法中抛出未捕获的异常,线程都将进入死亡状态。
线程一旦死亡,就不能再次启动。尝试调用已经死亡线程的 start()
方法将会抛出 java.lang.IllegalThreadStateException
。
- 问题 05. 简述 Java 线程的实现方式
解答:
在Java中,创建和启动线程主要有两种实现方式:
继承 Thread
类:
这种方式需要定义一个类继承自 Thread
类,并重写 run()
方法来指定线程执行的任务代码。
public class MyThread extends Thread {
@Override
public void run() {
// 线程执行的任务
}
}
// 创建和启动线程的代码
MyThread t = new MyThread();
t.start(); // 启动线程
当调用线程的 start()
方法时,Java虚拟机会调用线程的 run()
方法,开始执行指定的代码。
实现 Runnable
接口:
另一种方式是定义一个类实现 Runnable
接口,并实现 run()
方法。然后将该类的实例传给 Thread
类的构造器,创建一个线程对象,并通过这个线程对象的 start()
方法启动线程。
public class MyRunnable implements Runnable {
@Override
public void run() {
// 线程执行的任务
}
}
// 创建和启动线程的代码
Thread t = new Thread(new MyRunnable());
t.start(); // 启动线程
实现 Runnable
接口的方式更为常用,因为它具有如下优势:
- 资源共享:实现
Runnable
接口的方式可以避免由于Java的单继承特性带来的限制,它可以使得实现Runnable
接口的类去继承其他类。 - 适合多个相同程序代码的线程去处理同一资源的情况:将线程和任务代码分离,可以多个线程共享同一个
Runnable
对象,可以无需创建多个线程对象就能执行同一份任务代码。 - 增加程序的健壮性:代码能够被多个线程共享,代码和数据是独立的,当多个线程按照一定顺序执行时代码可以作为同步的一个媒介。
从 Java 5 开始,还可以通过实现 Callable
接口和 FutureTask
类来创建线程,这种方式可以获取线程的执行结果,适合有返回结果的场景。
- 问题 06. 简述 Java 线程的结束方式
解答:
在 Java 中,线程的结束主要有以下几种方式:
自然结束:当线程的 run()
方法执行完毕后,线程自然结束。这是最常见的线程结束方式,也是最理想的结束方式,因为它遵循了线程的生命周期,并且允许线程释放资源并退出。
public class MyThread extends Thread {
@Override
public void run() {
// 执行任务...
// 任务执行完毕后,线程自然结束
}
}
使用标志位:可以在线程的循环操作中使用一个标志位来控制循环是否继续,外部通过改变这个标志位的值来让线程结束。
代码语言:javascript复制public class MyThread extends Thread {
// 终止标志位
private volatile boolean isRunning = true;
public void terminate() {
isRunning = false;
}
@Override
public void run() {
while (isRunning) {
// 执行任务...
}
// 循环结束,线程结束
}
}
中断线程:Java 提供了 interrupt()
方法来中断线程。调用线程的 interrupt()
方法并不会立即停止线程,而是设置了线程的中断状态。线程需要检查中断状态,并决定如何响应中断。
public class MyThread extends Thread {
@Override
public void run() {
try {
// 检查线程的中断状态
while (!Thread.interrupted()) {
// 执行任务...
}
} catch (InterruptedException e) {
// 线程在阻塞状态下被中断,如在sleep时,会抛出此异常
// 线程可以在这里进行资源释放等操作
}
// 处理完中断后,线程结束
}
}
线程中断主要用于终止处于阻塞状态(如调用 sleep()
, wait()
, join()
等)的线程。
使用 Future.cancel()
(如果线程是通过 ExecutorService
提交的):当使用线程池 ExecutorService
提交任务时,可以通过返回的 Future
对象来取消任务,如果任务正在执行,那么根据参数的选择可能会中断它。
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(new MyRunnable());
// 取消任务
future.cancel(true); // 参数为 true 会中断正在执行的任务
强制终止(不推荐使用):Java 中的 Thread.stop()
方法可以用来强制终止线程,但是这个方法是不安全的,不推荐使用。因为它可能会导致线程所持有的所有锁突然释放,进而导致对象处于不一致的状态。因此,这个方法已被弃用。
正确的线程结束方式应该是第一种和第二种,即让线程自然结束或通过某种协作机制让线程结束自己的工作。使用中断来结束线程也是可接受的,但必须正确处理 InterruptedException
异常,并编写线程代码时检查中断标志位。其他强制结束线程的方法应该避免,因为它们可能导致系统状态不稳定或者资源无法正确释放。
- 问题 07. 简述 Java 线程的通信方式
解答:
在 Java 中,线程之间的通信主要是指线程之间如何相互发送信号、数据交换以及同步操作。下面是一些 Java中 常用的线程通信方式:
共享变量(对象):线程可以通过共享变量进行通信。所有线程都可以访问同一个变量,通过这个变量来传递信号或数据。为了保证共享变量的可见性,通常需要将变量声明为 volatile
或通过同步块(使用 synchronized
关键字)来访问。
等待/通知机制:使用 Object
类中的 wait()
, notify()
, notifyAll()
方法可以实现等待/通知机制。当一个线程调用共享对象的 wait()
方法时,它会进入该对象的等待池并释放锁。当其他线程调用相同对象的 notify()
方法(或 notifyAll()
)时,它会唤醒一个(或所有)等待该对象的线程,让它们重新尝试获取锁。
class SharedObject {
synchronized void waitForCondition() throws InterruptedException {
while (!condition) {
wait();
}
// 处理条件满足后的逻辑
}
synchronized void changeCondition() {
condition = true;
notify();
}
}
管道通信:Java 提供了 PipedInputStream
和 PipedOutputStream
(对于字节流),以及 PipedReader
和 PipedWriter
(对于字符流),用于不同线程之间的数据传输。一个线程发送数据到输出管道,另一个线程从输入管道读取数据。
阻塞队列:java.util.concurrent
包提供了多种阻塞队列,如 ArrayBlockingQueue
, LinkedBlockingQueue
, SynchronousQueue
等。这些队列可以在生产者-消费者场景下非常方便地实现线程之间的数据交换。
信号量(Semaphores):Semaphore
是一个计数信号量,可以用来控制资源的访问。它可以用来实现生产者-消费者模式,控制可用资源的数量。
CountDownLatch/CyclicBarrier:这两个类都可以在某个点上同步多个线程。CountDownLatch
允许一个或多个线程等待直到在其他线程中进行的一组操作完成。CyclicBarrier
允许一组线程相互等待,直到所有线程都到达公共屏障点。
Future和Callable:提交给 ExecutorService
的任务可以返回一个 Future
对象,该对象表示异步计算的结果。线程可以通过 Future
对象查询计算是否完成,并且可以等待计算的完成。
Exchanger:Exchanger
类是一个同步点,在这个点上,两个线程可以交换数据。具体来说,每个线程在到达同步点时呈现一些数据,并接收由对方呈现的数据。
CompletableFuture:在 Java 8 中引入了 CompletableFuture
,它可以在将来的某个时间点完成,并且可以手动地完成(设置值或异常)。
这些通信机制中,一些(如 wait()/notify()
, BlockingQueue
, Semaphore
)更适合在处理线程同步时使用,而另一些(如 Future
, CompletableFuture
, Exchanger
)则在处理线程间的数据交换时更有用。根据不同的应用场景和需求,可以选择最合适的通信机制。
- 问题 08. 简述 Java 线程的 sleep() 和 wait() 有什么区别?
解答:
在 Java 中,sleep()
和 wait()
是两种用于暂停当前线程执行的方法,但它们之间有一些重要区别:
- 所属类:
sleep()
方法属于Thread
类。wait()
方法是Object
类的方法。 - 锁的处理:当线程调用
sleep()
方法时,它不会释放任何锁。当线程调用wait()
方法时,它必须持有该对象的锁,调用后会释放这个对象锁,允许其他线程获取这个锁。 - 使用目的:sleep()
主要用于让当前线程暂停执行指定的时间,不涉及对象锁或监视器,常用于实现定时等待或延时。
wait()用于线程间的协作,线程调用
wait()后会阻塞,直到其他线程调用同一个对象上的
notify()或
notifyAll()` 方法。 - 唤醒条件:
sleep()
在指定的时间过后由系统自动唤醒,或者被其他线程调用interrupt()
方法中断唤醒。wait()
需要等待其他线程调用相同对象的notify()
或notifyAll()
,或者被其他线程调用interrupt()
来中断等待。 - 异常:
sleep()
方法响应中断请求时会抛出InterruptedException
。wait()
也会在其他线程中断它时抛出InterruptedException
。 - 用途示例:
sleep()
示例:暂停线程执行,比如在重试机制中等待一段时间后再重试。wait()
示例:生产者消费者问题,其中生产者和消费者需要相互通信以同步生产和消费的速率。
在编程时选择使用 sleep()
还是 wait()
应基于你的同步需求。如果你只是想暂停一段时间而不涉及同步资源或对象锁,那么使用 sleep()
。如果你需要多个线程间的协调和同步,wait()
通常与 notify()
或 notifyAll()
结合使用。
- 问题 09. 简述 Java 线程的 run() 和 start() 有什么区别?
解答:
在Java中,run()
和start()
是两个与线程执行相关的方法,但它们在作用上有本质的区别:
- 方法定义:
run()
是Runnable
接口的一个方法,也被Thread
类重写。它定义了线程执行的操作和任务。start()
是Thread
类中的方法,用来启动一个新的线程。 - 线程启动:调用
start()
方法会导致操作系统为线程分配新的调用栈和必要的资源,然后JVM
调用线程的run()
方法。直接调用run()
方法并不会创建新线程,只是在当前线程中同步调用run()
方法,就像普通的方法调用一样。 - 多线程执行:当你调用
start()
方法时,线程的生命周期开始,并且当线程获得了CPU时间片后,它的run()
方法体中的代码将并发执行。如果直接调用run()
方法,则该方法中的代码将在当前线程中执行,并且不会有并发执行。 - 调用次数:每个线程对象的
start()
方法只能被调用一次。如果尝试对同一个线程对象调用多次start()
方法,将会抛出IllegalThreadStateException
。run()
方法可以被多次调用,因为它只是一个普通方法没有启动新线程的限制。 - 使用场景:当你想启动一个新线程时,你应该调用
start()
方法。如果你只想执行线程的任务,但不需要并发执行,你可以直接调用run()
方法。
总结来说,start()
用于启动一个新的线程,而 run()
只是定义了线程要执行的任务。直接调用 run()
不会启动新线程,而是在当前线程中执行 run()
方法。
- 问题 10. 什么是线程调度?
解答:
线程调度是操作系统中的一个机制,它负责决定哪一个线程获得处理器资源以及获得多长时间的执行。这个机制对于多线程程序的运行至关重要,因为它确保了线程之间公平地共享CPU资源,并且有效地管理线程的执行顺序。
在 Java 中,线程调度是由线程调度器(Thread Scheduler)控制的,它是 Java 虚拟机(JVM)的一部分。线程调度器根据特定的策略来分配 CPU 的时间片,以执行线程。Java 提供的线程调度是基于优先级的,并且基本上是不可预测的。线程调度器根据线程的优先级以及线程的其他信息来做出决定,但是具体的调度策略依赖于操作系统,并且在不同的操作系统和不同版本的 JVM 中可能会有所不同。
线程调度的策略主要有两种类型:
- 抢占式调度(Preemptive Scheduling):在这种调度方式下,线程优先级最高的线程最先执行,一个线程可以在没有执行完毕之前被另一个更高优先级的线程抢占。
- 协作式调度(Cooperative Scheduling):在这种调度方式下,一个线程执行完毕之后自愿放弃CPU的控制,交由其他线程执行。这种方式下,高优先级的线程可能会因为低优先级的线程没有释放CPU而遭受饿死。
由于 Java 的线程调度模型是建立在操作系统模型之上的,所以它可能采用了其中的一种或者两种结合起来的调度策略。在 Java 中,开发者可以通过设置线程的优先级来影响线程调度器的决策,但是不能保证优先级高的线程一定会在优先级低的线程之前执行。这是因为线程调度器的决定还取决于操作系统的线程调度策略和当前的系统负载等因素。
- 问题 11. 什么是线程优先级?
解答:
线程优先级是一个操作系统和编程语言中用来决定线程执行顺序的一个属性。在多线程环境中,线程优先级用来指示给定线程的重要性相对于其他线程。优先级较高的线程相对于优先级较低的线程会有更多的执行机会。
在 Java 中,每个线程都有一个优先级,优先级范围从 Thread.MIN_PRIORITY
(常数值1)到 Thread.MAX_PRIORITY
(常数值10)。默认情况下,每个线程都被赋予一个具有正常优先级的优先级,即 Thread.NORM_PRIORITY
(常数值5)。
线程的优先级可以通过线程对象的 setPriority(int)
方法来设置,通过 getPriority()
方法来获取。Java 线程的优先级设置并不是绝对的保证,它只是给线程调度器一个提示,告诉它我们希望线程按照什么样的相对优先级来执行。实际上,线程调度器如何考虑线程的优先级依赖于底层操作系统的实现,并且并不是所有的操作系统都会严格按照 Java 线程优先级来执行线程。
以下是一些关于 Java 线程优先级的重要事项:
- 线程优先级继承性:当一个线程启动其他线程时,子线程将继承父线程的优先级。
- 线程优先级并不保证线程的执行顺序:优先级较高的线程并不一定先于优先级较低的线程执行。
- 在一些操作系统中,线程优先级可能会被忽略,例如在很多时间片轮转(time slicing)调度算法中,每个线程都会获得等量的 CPU 时间,无论它们的优先级。
- 不恰当的使用线程优先级可能导致"线程饥饿",即优先级较低的线程可能永远得不到执行的机会。
- 滥用线程优先级可能会导致系统性能下降,特别是当高优先级的线程执行长时间操作,而不释放CPU给其他线程时。
出于这些原因,建议在设计多线程应用时谨慎使用线程优先级,并考虑到底层操作系统的调度策略和行为。
- 问题 12. 什么是守护线程?
解答:
守护线程(Daemon Thread)是 Java中 的一种线程,它主要用来为其他线程(用户线程)提供服务。它最大的特点在于:Java 虚拟机在所有非守护线程都结束运行时会退出,不会因为还有守护线程而继续运行。
以下是关于守护线程的几个关键点:
- 生命周期:守护线程的生命周期取决于创建它的用户线程,当最后一个非守护线程结束时,守护线程会自动被终止,不会执行 finally 块中的代码。
- 用途:守护线程通常被用来执行后台任务,比如垃圾回收器(Garbage Collector)、自动保存功能、后台打印服务等。
- 创建:可以通过调用线程对象的
setDaemon(true)
方法将线程设置为守护线程。这个设置必须在线程启动(start()
方法被调用)之前完成。 - 注意事项:一旦线程启动,就不能修改守护状态。不应该在守护线程中执行I/O操作或计算逻辑,因为它们可能会因为虚拟机的突然退出而导致资源未释放或数据不一致。
守护线程和普通线程在执行上没有区别,之所以称之为"守护"是因为它们通常在后台运行,辅助其他线程执行任务,不是程序的核心部分。
举个例子:如果你有一个写日志的服务,你可能会让它在一个守护线程上运行,因为主程序停止后记录日志的需要也就不复存在了。相对地,执行核心业务逻辑的线程则不应该设为守护线程,因为它们需要确保完成所有任务。
- 问题 13. 什么是线程组?
解答:
线程组(Thread Group)是 Java 中用于管理线程的一种方式。线程组可以将线程以树状结构组织起来,每个线程组下面可以有线程对象和其他线程组,允许一个线程组包含多个线程和线程组。线程可以访问其自身所属的线程组信息,但不能访问其线程组外部的线程信息。
主要特点如下:
- 分组管理:线程组提供了一个方便的方式来管理相关联的线程,比如可以一次性中断一组线程,设置一组线程的优先级等。
- 权限控制:线程组可以被用作访问控制,在安全管理器(Security Manager)中可以对线程组进行权限设置。
- 自动传递属性:新创建的线程默认属于创建它的线程的线程组。此外,可以在创建线程时指定它所属的线程组。
- 收集信息:可以通过线程组对象收集组内线程的状态信息。
尽管线程组存在一定的使用价值,但在现代Java并发编程中,线程组的概念并不是特别常用,因为 Java 的并发包(java.util.concurrent)提供了更加强大且灵活的并发工具。Java 官方文档也建议开发者使用 Executor 框架来管理线程的生命周期,而不是使用线程组。
- 问题 14. 什么是上下文切换?
解答:
上下文切换(Context Switching)是指在多任务操作系统中,CPU 从一个进程(或线程)切换到另一个进程(或线程)执行的过程。上下文切换是多任务操作系统的核心功能之一,它允许单个处理器在多个进程或线程间高效地分配其执行时间,使得系统能同时处理多个任务。
在上下文切换过程中,操作系统完成以下任务:
- 保存旧任务的状态:系统会保存当前正在执行的任务的状态,也就是保存当前进程或线程的上下文信息,包括程序计数器、寄存器内容、系统调用状态、内存映射等。
- 加载新任务的状态:系统接着加载另一个任务的上下文信息到CPU的寄存器中,这个任务可以是一个完全不同的进程,或者是同一个进程中的另一个线程。
- 恢复执行:加载了新的上下文信息后,CPU 开始执行新任务的代码。
上下文切换通常由以下事件触发:
- 时间片用尽:现代操作系统通常采用时间分片的方式来分配 CPU 资源,当一个进程或线程的时间片用尽,系统会进行上下文切换,让出 CPU 给其他任务。
- 等待IO操作:当一个进程或线程需要等待 IO 操作(例如,读取磁盘文件或网络数据)完成,系统会进行上下文切换,以避免 CPU 空闲。
- 优先级变更:系统中存在更高优先级的任务需要立即执行时,可能会中断当前任务进行上下文切换。
- 同步事件:进程或线程等待同步事件(如信号量、互斥锁)时,也会触发上下文切换。
上下文切换虽然是必要的,但也是有开销的。频繁的上下文切换会导致 CPU 花费较多时间在任务切换上而不是任务执行上,从而降低系统的整体效率。因此,在并发编程中,适当的管理线程数目和避免不必要的同步操作,可以减少上下文切换,提高程序性能。
- 问题 15. 什么是线程饥饿和线程耗尽?
解答:
线程饥饿(Thread Starvation)和线程耗尽(Thread Exhaustion)是并发编程中的两种问题,它们可以对应用程序的性能和响应能力产生负面影响。
线程饥饿发生在某些线程不能获得必要的资源去执行任务,因而不能进行有效的工作。这通常是由于线程调度不当或资源分配不均引起的。原因可能包括:
- 线程优先级:低优先级的线程可能会长时间得不到执行,因为高优先级的线程不断地被调度。
- 锁占用:当某些线程长时间持有锁,而其他线程都在等待这个锁时,等待的线程可能会饥饿。
- 线程死锁:在死锁情况下,涉及的线程都在等待其他线程释放资源,导致它们无法继续执行。
线程耗尽是指系统中没有足够的线程来执行当前的任务。这通常是由于以下原因造成的:
- 资源限制:每个线程都会消耗系统资源(如内存)。当创建的线程数量过多,可能会耗尽系统资源,导致新的线程无法创建。
- 线程泄漏:由于编程错误,线程在完成工作后没有被回收,导致越来越多的线程占用系统资源。
- 大量并发请求:系统收到的并发请求过多,超出了线程池的处理能力,无法及时为新的任务分配线程。
这两个问题都需要通过合理的设计和资源管理来解决。例如,可以通过设置合理的线程优先级,使用公平的锁机制,合理配置线程池的大小,以及确保线程在使用后能被正确地回收,来防止线程饥饿和耗尽。
- 问题 16. 什么是线程本地存储?
解答:
线程本地存储(Thread-Local Storage,TLS)是一种允许数据在多个线程中被独立地存取而不需要同步访问的机制。这种方式为每个线程提供了数据的私有副本。
在 Java 中,ThreadLocal
类提供了线程本地存储的功能。每个线程通过ThreadLocal
对象可以存储其独立的对象副本,而这个副本对其他线程是不可见的。这通常用于保持线程安全,避免了共享资源的同步问题。
例如,如果你想要在多个线程中使用简单的日期格式对象(SimpleDateFormat
),由于SimpleDateFormat
不是线程安全的,就可以为每个线程创建一个实例,这样每个线程都有自己的 SimpleDateFormat
实例,互不干扰。
使用 ThreadLocal
的基本步骤是:
- 创建一个
ThreadLocal
实例。 - 使用
set()
方法来为当前线程设置值。 - 使用
get()
方法来获取当前线程设置的值。 - 当不再需要使用时,可以调用
remove()
来清除当前线程的值,以防止内存泄漏。
这是一个 ThreadLocal
的使用示例:
public class Example {
private static final ThreadLocal<SimpleDateFormat> dateFormat =
new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd");
}
};
public String formatDate(Date date) {
return dateFormat.get().format(date);
}
}
在这个例子中,每个线程都将拥有自己的 SimpleDateFormat
实例,它们可以在不同的线程中并发使用,而不会发生线程安全问题。
2.3、集合
- 问题 17. 什么是线程安全的集合?
解答:
线程安全的集合是指那些设计成在多线程环境下被安全地访问和修改的数据结构。这些集合内部通过同步措施来保证各个线程对共享资源的访问不会导致数据的不一致或状态的不正确。
在 Java 中,线程安全的集合可以通过以下方式实现:
- 内部同步机制:集合通过内部的同步机制来保证操作的原子性。比如
java.util.Vector
和java.util.Hashtable
,它们使用 synchronized 方法来确保每次只有一个线程可以访问集合的状态。 - 并发集合:从 Java 1.5 开始,
java.util.concurrent
包提供了一组性能更好的线程安全集合,比如ConcurrentHashMap
,CopyOnWriteArrayList
, 和CopyOnWriteArraySet
。这些集合通过更细粒度的锁或无锁机制来提高并发性能。 - 集合包装器:使用 Collections 类的
synchronizedCollection
,synchronizedList
,synchronizedMap
, 等静态方法可以将任何集合包装成一个线程安全的集合。这些包装器会将所有对集合的操作封装在 synchronized 方法中。 - 不可变集合:不可变对象天生是线程安全的,因为它们的状态在创建后不能更改。在 Guava 库和 JDK 中,有不可变版本的集合实现,如
Collections.unmodifiableList()
和ImmutableList
。
线程安全的集合在多线程环境中使用时不需要额外的同步措施,但这通常是以牺牲某些性能为代价的,因为同步操作本身就需要消耗一定的系统资源。在设计程序时,应该根据实际的并发需求选择合适的线程安全集合。
- 问题 18. 什么是线程不安全的集合?
解答:
线程不安全的集合是指那些没有内置同步机制,无法保证在多线程环境中同时对集合进行操作时数据一致性的集合。在多线程同时访问这样的集合时,可能会导致数据竞争、数据不一致甚至程序错误。
Java 中的一些线程不安全的集合包括:
- ArrayList:
ArrayList
是一个动态数组实现,它不是线程安全的,因此如果需要在多线程环境中使用它,必须提供额外的同步措施。 - HashMap:
HashMap
也不是线程安全的。它允许快速访问和存储键值对,但在多线程操作时可能出现数据不一致的情况。 - HashSet: 由于
HashSet
底层基于HashMap
实现,因此它同样不是线程安全的。
在多线程环境下,如果要使用这些集合类,一般有几种处理方法:
- 使用
Collections.synchronizedList()
、Collections.synchronizedMap()
等工具方法对集合进行包装,使其变为线程安全的。 - 使用
java.util.concurrent
包下的并发集合类,如ConcurrentHashMap
替代HashMap
。 - 实施外部同步,即在访问集合的每个方法前后都使用同步块(synchronized block)或读写锁(
ReentrantReadWriteLock
)。 - 避免共享:改为使用局部变量、方法参数或返回值,这样每个线程都使用自己的集合副本,而不是共享一个集合实例。
- 采用不变性:使用不可变集合,一旦创建就不能更改,可以安全地在多个线程间共享。
在不需要保证线程安全的场景下使用线程不安全的集合可以获得更好的性能,因为不需要额外的同步开销。然而,如果在多线程环境中错误地使用它们,则可能会引发诸如 ConcurrentModificationException
等并发问题。
2.4、线程死锁活锁
- 问题 19. 什么是死锁?如何避免死锁?
解答:
死锁是指两个或多个执行线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将无法向前推进。这种情况类似于两个人都在等待对方先讲话,结果都沉默不语。在计算机操作系统中,死锁是指多个进程在运行过程中因争夺资源而陷入僵局,如果没有外部干预,它们都将无法继续执行。
避免死锁通常可以采取以下措施:
- 互斥条件:确保资源足够多,使得进程无需互斥地占有资源。虽然在很多情况下,这一条件无法避免,但可以通过资源的复用或者虚拟化来降低互斥发生的可能性。
- 保持和等待条件:一次性申请所有资源,避免分阶段锁定资源。这可以通过让进程在开始执行之前必须先声明在整个运行过程中所需要的最大资源,只有一次性获取了所有所需资源,才能执行。
- 不可抢占条件:实现资源的可抢占机制,即当某个进程无法获得所有所需资源时,它必须释放已占有的资源,以便其他进程使用。
- 循环等待条件:通过资源排序、分配顺序来预防环形等待的条件。例如,系统可以规定所有进程必须按照资源编号顺序申请资源。
除了上述基于系统资源分配策略的预防方法,还有一些技术手段可以用来避免或减少死锁的发生:
- 超时机制:进程等待资源超过一定时间后放弃,之后再次尝试。
- 死锁检测与恢复:操作系统或中间件周期性地检查死锁,并采取措施,如终止进程或回滚操作来解除死锁。
- 锁顺序:给程序中使用的锁分配一个全局顺序,强制所有线程按照这一顺序加锁。
- 锁定协议:数据库管理系统常用的避免死锁的方法,比如两阶段锁定协议。
- 事务退避:当事务发现可能进入死锁状态时,主动释放占有的所有资源,并过一段时间再重新尝试。
- 有序资源分配:系统以一定的顺序分配资源,破坏死锁循环等待的条件。
- 资源预留:在进程启动之初就预留所需的全部资源。
在设计系统时,应该尽可能地避免死锁的发生。但在不可避免的情况下,结合以上策略,可以大大减少死锁的发生频率,并能有效地解决死锁问题。
- 问题 20. 什么是活锁和饥饿?
解答:
活锁(Livelock)和饥饿(Starvation)是并发编程中的两种问题,它们与死锁(Deadlock)类似,也会导致系统效率降低,但它们的表现形式和解决方法有所不同。
活锁发生在两个或多个执行实体尝试通过不断改变状态来解决冲突,但这些状态变化又相互抵消,导致实体无法继续执行有效的工作。它们没有被阻塞,可以执行,但由于逻辑上的互相等待,使得任务无法完成。
- 举例说明:两个人试图通过一扇狭窄的门,互相谦让对方先走,结果就是都没能通过门。
- 解决方法:通常的解决方案包括引入随机性,使得冲突的实体在下一次尝试时能够不总是选择同一种解决方案,从而有机会打破这种无尽的循环。
饥饿是指在多线程环境中,一个或多个线程因为种种原因无法获得所需的资源,导致一直无法进行工作。这种情况通常发生在系统中的资源分配不公或调度策略不当时。
- 原因:可能是因为线程的优先级设置得太低,总是有其他高优先级的线程抢先获得资源;或者某些线程持续请求与其它线程相同的锁,但总是在竞争中失败。
- 解决方法:可以通过确保所有线程都有公平的资源获取机会来解决饥饿问题。例如,使用公平的锁(Fair Locks)可以确保按请求资源的顺序来分配资源。
与死锁相比,活锁和饥饿都是由于资源分配不均或逻辑错误导致某些线程不能有效地执行工作。死锁通常需要外部干预来打破,而活锁和饥饿则需要改进资源分配策略或调整线程逻辑。