大家好,我是栗筝i,从 2022 年 10 月份开始,我便开始致力于对 Java 技术栈进行全面而细致的梳理。这一过程,不仅是对我个人学习历程的回顾和总结,更是希望能够为各位提供一份参考。因此得到了很多读者的正面反馈。 而在 2023 年 10 月份开始,我将推出 Java 面试题/知识点系列内容,期望对大家有所助益,让我们一起提升。 今天与您分享的,是 Java 并发知识面试题系列的总结篇(上篇),我诚挚地希望它能为您带来启发,并在您的职业生涯中起到助益作用。衷心感谢每一位朋友的关注与支持。
解答:
并发(Concurrency)和并行(Parallelism)是两个经常被提到的概念,它们在多任务环境中有着重要的作用,但是它们之间存在着明显的区别。
总结来说,如果把并发和并行比作是跑步,那么并发就像是接力赛,一次只有一个人在跑,但是可以通过接力棒的传递,让不同的人参与到跑步中来;而并行就像是百米赛跑,每个人都在自己的跑道上同时开始跑步。
解答:
进程和线程都是操作系统进行任务调度的基本单位,但它们之间存在一些主要的区别:
总的来说,每个进程都有独立的代码和数据空间(程序上下文),线程是共享数据段的并发执行路径,线程在执行过程中,需要协作同步。不同进程的线程间要利用消息通信的方式实现同步。
解答:
线程安全问题通常出现在多线程环境下,当多个线程同时访问和修改同一份数据时,如果没有进行适当的同步控制,可能会导致数据的不一致,这就是线程安全问题。
举个例子,假设有两个线程同时对一个变量进行加 1 操作,初始值为 0。理想的结果应该是 2,但是在没有同步控制的情况下,可能会出现结果为 1 的情况。这是因为加 1 操作不是原子操作,它包括读取变量值、进行加法操作、写回结果三个步骤,当两个线程的操作交叉进行时,就可能导致结果错误。
为了避免线程安全问题,通常需要使用各种同步机制,如互斥锁(Mutex)、读写锁(ReadWriteLock)、信号量(Semaphore)等,来确保在任何时刻,只有一个线程能够访问和修改数据。
解答:
Java 线程的生命周期可以分为五个状态:
new
关键字创建了线程对象之后,即进入了新建状态,例如,Thread t = new Thread();
此时,线程尚未开始运行。
start()
方法时,线程进入就绪状态。处于就绪状态的线程并没有开始运行,但已经具备了运行条件,只是等待获取CPU的执行权。例如,t.start();
此时,线程在 JVM 的线程调度器的调度下等待被分配到时间片。
run()
方法中定义的任务。如果 run()
方法执行完毕,线程将直接进入死亡状态;如果线程的 run()
方法还没有执行完毕,它可以被切换回就绪状态,以便在将来某个时间点再次被运行。
wait()
方法,线程会释放持有的监视器锁并进入对象的等待池,只有等待其他线程调用同一个对象的 notify()
方法或 notifyAll()
方法线程才有可能被唤醒。synchronized
块或方法),如果锁被其他线程持有,则进入同步阻塞状态。sleep()
、join()
或者发生 I/O
请求时,线程会进入阻塞状态。当 sleep()
状态超时、join()
等待线程终止或者 I/O
处理完毕时,线程重新进入就绪状态。run()
方法执行完毕后,或者调用 stop()
方法(已被弃用,因为不安全),或者 run()
方法中抛出未捕获的异常,线程都将进入死亡状态。
线程一旦死亡,就不能再次启动。尝试调用已经死亡线程的 start()
方法将会抛出 java.lang.IllegalThreadStateException
。
解答:
在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
类来创建线程,这种方式可以获取线程的执行结果,适合有返回结果的场景。
解答:
在 Java 中,线程的结束主要有以下几种方式:
自然结束:当线程的 run()
方法执行完毕后,线程自然结束。这是最常见的线程结束方式,也是最理想的结束方式,因为它遵循了线程的生命周期,并且允许线程释放资源并退出。
public class MyThread extends Thread {
@Override
public void run() {
// 执行任务...
// 任务执行完毕后,线程自然结束
}
}
使用标志位:可以在线程的循环操作中使用一个标志位来控制循环是否继续,外部通过改变这个标志位的值来让线程结束。
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
异常,并编写线程代码时检查中断标志位。其他强制结束线程的方法应该避免,因为它们可能导致系统状态不稳定或者资源无法正确释放。
解答:
在 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
)则在处理线程间的数据交换时更有用。根据不同的应用场景和需求,可以选择最合适的通信机制。
解答:
在 Java 中,sleep()
和 wait()
是两种用于暂停当前线程执行的方法,但它们之间有一些重要区别:
sleep()
方法属于 Thread
类。wait()
方法是 Object
类的方法。
sleep()
方法时,它不会释放任何锁。当线程调用 wait()
方法时,它必须持有该对象的锁,调用后会释放这个对象锁,允许其他线程获取这个锁。
主要用于让当前线程暂停执行指定的时间,不涉及对象锁或监视器,常用于实现定时等待或延时。
wait()用于线程间的协作,线程调用
wait()后会阻塞,直到其他线程调用同一个对象上的
notify()或
notifyAll()` 方法。
sleep()
在指定的时间过后由系统自动唤醒,或者被其他线程调用 interrupt()
方法中断唤醒。wait()
需要等待其他线程调用相同对象的 notify()
或 notifyAll()
,或者被其他线程调用 interrupt()
来中断等待。
sleep()
方法响应中断请求时会抛出 InterruptedException
。wait()
也会在其他线程中断它时抛出 InterruptedException
。
sleep()
示例:暂停线程执行,比如在重试机制中等待一段时间后再重试。wait()
示例:生产者消费者问题,其中生产者和消费者需要相互通信以同步生产和消费的速率。
在编程时选择使用 sleep()
还是 wait()
应基于你的同步需求。如果你只是想暂停一段时间而不涉及同步资源或对象锁,那么使用 sleep()
。如果你需要多个线程间的协调和同步,wait()
通常与 notify()
或 notifyAll()
结合使用。
解答:
在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()
方法。
解答:
线程调度是操作系统中的一个机制,它负责决定哪一个线程获得处理器资源以及获得多长时间的执行。这个机制对于多线程程序的运行至关重要,因为它确保了线程之间公平地共享CPU资源,并且有效地管理线程的执行顺序。
在 Java 中,线程调度是由线程调度器(Thread Scheduler)控制的,它是 Java 虚拟机(JVM)的一部分。线程调度器根据特定的策略来分配 CPU 的时间片,以执行线程。Java 提供的线程调度是基于优先级的,并且基本上是不可预测的。线程调度器根据线程的优先级以及线程的其他信息来做出决定,但是具体的调度策略依赖于操作系统,并且在不同的操作系统和不同版本的 JVM 中可能会有所不同。
线程调度的策略主要有两种类型:
由于 Java 的线程调度模型是建立在操作系统模型之上的,所以它可能采用了其中的一种或者两种结合起来的调度策略。在 Java 中,开发者可以通过设置线程的优先级来影响线程调度器的决策,但是不能保证优先级高的线程一定会在优先级低的线程之前执行。这是因为线程调度器的决定还取决于操作系统的线程调度策略和当前的系统负载等因素。
解答:
线程优先级是一个操作系统和编程语言中用来决定线程执行顺序的一个属性。在多线程环境中,线程优先级用来指示给定线程的重要性相对于其他线程。优先级较高的线程相对于优先级较低的线程会有更多的执行机会。
在 Java 中,每个线程都有一个优先级,优先级范围从 Thread.MIN_PRIORITY
(常数值1)到 Thread.MAX_PRIORITY
(常数值10)。默认情况下,每个线程都被赋予一个具有正常优先级的优先级,即 Thread.NORM_PRIORITY
(常数值5)。
线程的优先级可以通过线程对象的 setPriority(int)
方法来设置,通过 getPriority()
方法来获取。Java 线程的优先级设置并不是绝对的保证,它只是给线程调度器一个提示,告诉它我们希望线程按照什么样的相对优先级来执行。实际上,线程调度器如何考虑线程的优先级依赖于底层操作系统的实现,并且并不是所有的操作系统都会严格按照 Java 线程优先级来执行线程。
以下是一些关于 Java 线程优先级的重要事项:
出于这些原因,建议在设计多线程应用时谨慎使用线程优先级,并考虑到底层操作系统的调度策略和行为。
解答:
守护线程(Daemon Thread)是 Java中 的一种线程,它主要用来为其他线程(用户线程)提供服务。它最大的特点在于:Java 虚拟机在所有非守护线程都结束运行时会退出,不会因为还有守护线程而继续运行。
以下是关于守护线程的几个关键点:
setDaemon(true)
方法将线程设置为守护线程。这个设置必须在线程启动(start()
方法被调用)之前完成。
守护线程和普通线程在执行上没有区别,之所以称之为"守护"是因为它们通常在后台运行,辅助其他线程执行任务,不是程序的核心部分。
举个例子:如果你有一个写日志的服务,你可能会让它在一个守护线程上运行,因为主程序停止后记录日志的需要也就不复存在了。相对地,执行核心业务逻辑的线程则不应该设为守护线程,因为它们需要确保完成所有任务。
解答:
线程组(Thread Group)是 Java 中用于管理线程的一种方式。线程组可以将线程以树状结构组织起来,每个线程组下面可以有线程对象和其他线程组,允许一个线程组包含多个线程和线程组。线程可以访问其自身所属的线程组信息,但不能访问其线程组外部的线程信息。
主要特点如下:
尽管线程组存在一定的使用价值,但在现代Java并发编程中,线程组的概念并不是特别常用,因为 Java 的并发包(java.util.concurrent)提供了更加强大且灵活的并发工具。Java 官方文档也建议开发者使用 Executor 框架来管理线程的生命周期,而不是使用线程组。
解答:
上下文切换(Context Switching)是指在多任务操作系统中,CPU 从一个进程(或线程)切换到另一个进程(或线程)执行的过程。上下文切换是多任务操作系统的核心功能之一,它允许单个处理器在多个进程或线程间高效地分配其执行时间,使得系统能同时处理多个任务。
在上下文切换过程中,操作系统完成以下任务:
上下文切换通常由以下事件触发:
上下文切换虽然是必要的,但也是有开销的。频繁的上下文切换会导致 CPU 花费较多时间在任务切换上而不是任务执行上,从而降低系统的整体效率。因此,在并发编程中,适当的管理线程数目和避免不必要的同步操作,可以减少上下文切换,提高程序性能。
解答:
线程饥饿(Thread Starvation)和线程耗尽(Thread Exhaustion)是并发编程中的两种问题,它们可以对应用程序的性能和响应能力产生负面影响。
线程饥饿发生在某些线程不能获得必要的资源去执行任务,因而不能进行有效的工作。这通常是由于线程调度不当或资源分配不均引起的。原因可能包括:
线程耗尽是指系统中没有足够的线程来执行当前的任务。这通常是由于以下原因造成的:
这两个问题都需要通过合理的设计和资源管理来解决。例如,可以通过设置合理的线程优先级,使用公平的锁机制,合理配置线程池的大小,以及确保线程在使用后能被正确地回收,来防止线程饥饿和耗尽。
解答:
线程本地存储(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
实例,它们可以在不同的线程中并发使用,而不会发生线程安全问题。
解答:
线程安全的集合是指那些设计成在多线程环境下被安全地访问和修改的数据结构。这些集合内部通过同步措施来保证各个线程对共享资源的访问不会导致数据的不一致或状态的不正确。
在 Java 中,线程安全的集合可以通过以下方式实现:
java.util.Vector
和 java.util.Hashtable
,它们使用 synchronized 方法来确保每次只有一个线程可以访问集合的状态。
java.util.concurrent
包提供了一组性能更好的线程安全集合,比如 ConcurrentHashMap
, CopyOnWriteArrayList
, 和 CopyOnWriteArraySet
。这些集合通过更细粒度的锁或无锁机制来提高并发性能。
synchronizedCollection
, synchronizedList
, synchronizedMap
, 等静态方法可以将任何集合包装成一个线程安全的集合。这些包装器会将所有对集合的操作封装在 synchronized 方法中。
Collections.unmodifiableList()
和 ImmutableList
。
线程安全的集合在多线程环境中使用时不需要额外的同步措施,但这通常是以牺牲某些性能为代价的,因为同步操作本身就需要消耗一定的系统资源。在设计程序时,应该根据实际的并发需求选择合适的线程安全集合。
解答:
线程不安全的集合是指那些没有内置同步机制,无法保证在多线程环境中同时对集合进行操作时数据一致性的集合。在多线程同时访问这样的集合时,可能会导致数据竞争、数据不一致甚至程序错误。
Java 中的一些线程不安全的集合包括:
ArrayList
是一个动态数组实现,它不是线程安全的,因此如果需要在多线程环境中使用它,必须提供额外的同步措施。
HashMap
也不是线程安全的。它允许快速访问和存储键值对,但在多线程操作时可能出现数据不一致的情况。
HashSet
底层基于 HashMap
实现,因此它同样不是线程安全的。
在多线程环境下,如果要使用这些集合类,一般有几种处理方法:
Collections.synchronizedList()
、Collections.synchronizedMap()
等工具方法对集合进行包装,使其变为线程安全的。
java.util.concurrent
包下的并发集合类,如 ConcurrentHashMap
替代 HashMap
。
ReentrantReadWriteLock
)。
在不需要保证线程安全的场景下使用线程不安全的集合可以获得更好的性能,因为不需要额外的同步开销。然而,如果在多线程环境中错误地使用它们,则可能会引发诸如 ConcurrentModificationException
等并发问题。
解答:
死锁是指两个或多个执行线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将无法向前推进。这种情况类似于两个人都在等待对方先讲话,结果都沉默不语。在计算机操作系统中,死锁是指多个进程在运行过程中因争夺资源而陷入僵局,如果没有外部干预,它们都将无法继续执行。
避免死锁通常可以采取以下措施:
除了上述基于系统资源分配策略的预防方法,还有一些技术手段可以用来避免或减少死锁的发生:
在设计系统时,应该尽可能地避免死锁的发生。但在不可避免的情况下,结合以上策略,可以大大减少死锁的发生频率,并能有效地解决死锁问题。
解答:
活锁(Livelock)和饥饿(Starvation)是并发编程中的两种问题,它们与死锁(Deadlock)类似,也会导致系统效率降低,但它们的表现形式和解决方法有所不同。
活锁发生在两个或多个执行实体尝试通过不断改变状态来解决冲突,但这些状态变化又相互抵消,导致实体无法继续执行有效的工作。它们没有被阻塞,可以执行,但由于逻辑上的互相等待,使得任务无法完成。
饥饿是指在多线程环境中,一个或多个线程因为种种原因无法获得所需的资源,导致一直无法进行工作。这种情况通常发生在系统中的资源分配不公或调度策略不当时。
与死锁相比,活锁和饥饿都是由于资源分配不均或逻辑错误导致某些线程不能有效地执行工作。死锁通常需要外部干预来打破,而活锁和饥饿则需要改进资源分配策略或调整线程逻辑。