基础概念
- 什么是并发和并行?它们有什么区别?
详尽回答:
- 并发:指在一个时间段内管理并执行多个任务,这些任务可以是交替进行的。并发允许线程在等待部分操作(如 I/O)的同时,执行其他操作。并发重在管理任务的完成顺序与执行协调,但不同任务不一定是同时进行的。例如,在单 CPU 系统上,就只能切换时间片分配给不同线程,实现并发。
- 并行:并行指真正意义上的同时执行多个任务,通常需要多核 CPU 或多个 CPU 来实现。每个 CPU 核心可以独立地执行一个任务,从而实现任务的实际并行进行。例如,一个多核 CPU 可以同时执行多个线程的运算,从而加快处理速度。
区别:
- 并发在单核或多核系统上都能实现,而并行通常需要多核。
- 并发是任务的交替执行,而并行是任务的同时执行。
- 什么是线程和进程?进程与线程之间有何区别?
详尽回答:
- 进程:进程是操作系统中执行的基本单位,每个进程有自己独立的内存空间和资源。进程之间的通信通常比较复杂且开销较大,因为它们不能直接访问对方的内存空间。
- 线程:线程是进程中的一个执行单元。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源,但每个线程有自己的栈和寄存器。
区别:
- 内存空间:进程拥有独立的内存空间;线程共享进程的内存空间。
- 创建开销:创建和销毁进程的开销比创建和销毁线程大,因为进程需要分配和管理更多的资源。
- 通信方式:进程之间的通信通常需要通过 IPC(进程间通信)机制,而线程之间的通信可以直接通过共享内存。
- 崩溃影响:一个进程的崩溃通常不会影响其他进程,但一个线程的崩溃可能导致整个进程异常。
- 什么是上下文切换?它对性能有何影响?
详尽回答:
- 上下文切换:上下文切换是指 CPU 从一个线程或进程切换到另一个线程或进程时,需要保存当前任务的状态(如寄存器、程序计数器等),并恢复新任务的状态。这个过程包括保存和恢复 CPU 寄存器、程序计数器、内存映射等。
影响:
- 性能损耗:上下文切换需要花费时间保存和恢复状态,尤其是在切换过程中涉及的内核态和用户态切换。一旦上下文切换频繁发生,将显著影响系统的吞吐量和响应时间。
- 缓存失效:线程或进程切换可能导致 CPU 缓存失效,因为不同任务可能访问不同的数据,对缓存的利用率降低,进而影响性能。
- 资源消耗:上下文切换不仅消耗 CPU 时间,还需要占用内存和内核资源,会增加系统的开销。
- 什么是并发编程中的可见性问题?有何解决方案?
详尽回答:
- 可见性问题:在并发编程中,可见性问题指一个线程对共享变量的修改,不能立即被其他线程看到。这是因为线程可以缓存变量的值,而不立即写回主内存,使得其他线程读取到的仍旧是旧值。
解决方案:
- volatile 关键字:将变量声明为 volatile,确保对该变量的读取总是从主内存中获取,对该变量的写入立即刷新到主内存中。volatile 保证了变量的可见性,但不保证原子性。
private volatile boolean flag = true;
- synchronized 关键字:使用同步块或者同步方法,确保在获取锁的前后刷新变量到主内存。synchronized 可以保证可见性和原子性。
synchronized (this) {
// 同步区域
}
- 并发工具类:使用 java.util.concurrent 包中的原子类(如 AtomicInteger、AtomicBoolean)和高级并发工具(如 CountDownLatch、CyclicBarrier等)管理并发操作,确保可见性。
AtomicInteger atomicInteger = new AtomicInteger(0);
atomicInteger.incrementAndGet();
- Java 中的 Runnable 和 Callable 有什么区别?
详尽回答:
- Runnable 接口:
- 用法:Runnable 是一个函数式接口,包含一个抽象方法 run(),无返回值且不抛出异常。
- 实现:经常用于定义简单的任务,可以通过 Thread 或 ExecutorService 执行。
- 例子:
public class MyRunnable implements Runnable {
public void run() {
// 任务代码
}
}
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
- Callable 接口:
- 用法:Callable 是一个泛型接口,包含一个抽象方法 call(),有返回值且可以抛出异常。
- 实现:经常用于定义带返回值的任务,可以通过 ExecutorService 执行并返回一个 Future 对象。
- 例子:
public class MyCallable implements Callable<String> {
public String call() throws Exception {
// 任务代码
return "Result";
}
}
MyCallable myCallable = new MyCallable();
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(myCallable);
String result = future.get();
executor.shutdown();
区别:
- Runnable 没有返回值,也不会抛出检查型异常。而 Callable 可以返回结果,并可以抛出检查型异常。
- Runnable 适用于不需要返回结果的任务,Callable 适用于需要返回计算结果或处理异常的任务。
- 解释什么是临界区?如何保护临界区?
详尽回答:
- 临界区(Critical Section):临界区是指在多线程编程中,多个线程需要独占访问的代码区域。临界区涉及对共享资源的访问,例如共享数据、文件或设备。由于多个线程可能同时进入临界区,可能导致数据竞争和不一致性问题,因此需要保护临界区以保证线程安全。
保护临界区的方法:
- 使用 synchronized 关键字:Java 提供了 synchronized 关键字来保护临界区,确保同一时间只有一个线程可以执行被 synchronized 修饰的方法或代码块。
public synchronized void synchronizedMethod() {
// 同步方法
}
public void synchronizedBlock() {
synchronized (this) {
// 同步代码块
}
}
- 使用 Lock 接口和 ReentrantLock 类:ReentrantLock 提供了比 synchronized 更加灵活的锁机制,并且可以显式地获取和释放锁。
Lock lock = new ReentrantLock();
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock();
}
- 使用同步容器:Java 并发包提供了一些线程安全的容器(如 ConcurrentHashMap、CopyOnWriteArrayList),这些容器内部已经实现了锁机制,可以避免手动加锁。
Map<String, String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("key", "value");
通过正确地保护临时区,可以避免线程间的竞争条件,提高应用程序的稳定性和可靠性。
- 什么是线程饥饿?如何避免?
详尽回答:
- 线程饥饿(Thread Starvation):线程饥饿指的是某些线程无法获得足够的 CPU 时间或资源,导致长时间无法执行。这通常发生在低优先级线程被高优先级线程长时间占用资源的情况下。
如何避免:
- 公平锁:使用公平锁(如 ReentrantLock 的公平模式)确保线程按照等待的顺序获得锁,避免饥饿问题。
Lock lock = new ReentrantLock(true); // 启用公平锁
- 调整线程优先级:适当调整线程的优先级,避免高优先级线程长时间占用资源。Java 提供了 Thread.setPriority() 方法来设置线程的优先级。
Thread highPriorityThread = new Thread(task);
highPriorityThread.setPriority(Thread.MAX_PRIORITY);
- 避免独占资源:设计系统时应避免长时间独占资源的操作,将任务分解为短小段落,各个线程轮流执行,避免线程长时间等待。
Runnable task = () -> {
while (true) {
// Varying workloads ensure that no single thread monopolizes the CPU
}
};
- 解释什么是公平锁和非公平锁。它们各有什么优缺点?
详尽回答:
- 公平锁(Fair Lock):公平锁保证锁的公平性,即按照线程等待的顺序获取锁。公平锁避免了线程饥饿的问题,因为等待时间最长的线程会优先获得锁。
- 优点:
- 防止线程饥饿,确保每个等待线程都有机会获得锁。
- 缺点:
- 性能较低,因为需要维护等待队列并处理线程切换。
- 非公平锁(Unfair Lock):非公平锁不保证锁的公平性,当前线程可以在任意时刻获取锁,即使有其他线程正在等待。这种机制会使有些线程可能长时间得不到运行机会,导致线程饥饿。
- 优点:
- 性能高,因为减少了上下文切换和维护等待队列的开销。
- 缺点:
- 可能导致线程饥饿,长时间等待的线程可能永远得不到锁。
- 示例:
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁
线程管理与调度
- 如何创建和启动一个线程?有哪些方式?
在 Java 中,创建和启动线程主要有三种方式:
- 通过继承 Thread 类。
- 通过实现 Runnable 接口。
- 通过实现 Callable 接口(返回结果并可以抛出异常)。
详尽回答:
方式1:继承 Thread 类
通过继承 Thread 类并重写 run 方法。
class MyThread extends Thread {
@Override
public void run() {
// 线程执行的任务
System.out.println("Thread is running");
}
}
public class Main {
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start(); // 启动线程
}
}
注意:建议使用 start() 方法启动线程,而不是直接调用 run() 方法,run() 方法会在当前线程中执行,而不是创建一个新的线程。
方式2:实现 Runnable 接口
通过实现 Runnable 接口,并将其传递给 Thread 对象。
class MyRunnable implements Runnable {
@Override
public void run() {
// 线程执行的任务
System.out.println("Runnable is running");
}
}
public class Main {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread t1 = new Thread(myRunnable);
t1.start(); // 启动线程
}
}
这种方式更灵活,允许线程类继承其他类,因为 Java 不支持多继承。
方式3:实现 Callable 接口
通过实现 Callable 接口,适用于需要返回结果和抛出异常的情况,通常与 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;
class MyCallable implements Callable<String> {
@Override
public String call() {
// 线程执行的任务
return "Callable's result";
}
}
public class Main {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
MyCallable myCallable = new MyCallable();
Future<String> future = executor.submit(myCallable);
try {
String result = future.get(); // 获取返回值
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
executor.shutdown(); // 关闭线程池
}
}
这种方式允许获取任务的返回结果,并处理执行中的异常。
10、什么是线程池?为什么我们需要使用线程池?
详尽回答:
- 线程池:线程池是一种线程管理工具,用于复用一定数量的线程来执行多个任务。Java 提供了 Executor 框架来实现线程池,常见的实现有 ThreadPoolExecutor、ScheduledThreadPoolExecutor 等。
需要线程池的原因:
- 性能提升:线程池通过预创建大量线程,避免了每次创建和销毁线程的开销,从而提高性能。
- 资源管理:线程池限制了并发线程的数量,有效管理和使用系统资源,防止资源耗尽。
- 任务调度:线程池提供了便捷的任务调度机制,如定时任务、延迟任务、循环任务等。
- 线程复用:线程池可以复用空闲线程,减少线程上下文切换,提升响应速度。
- 线程生命周期管理:线程池统一管理线程的生命周期,减少开发者的管理负担。
示例代码:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
// 创建固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
Runnable worker = new WorkerThread("" + i);
executor.execute(worker);
}
executor.shutdown(); // 关闭线程池
while (!executor.isTerminated()) { // 等待所有任务完成
}
System.out.println("Finished all threads");
}
}
class WorkerThread implements Runnable {
private String command;
public WorkerThread(String s) {
this.command = s;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " Start. Command = " + command);
processCommand();
System.out.println(Thread.currentThread().getName() + " End.");
}
private void processCommand() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public String toString() {
return this.command;
}
}
这个示例使用一个固定大小的线程池来执行10个任务,每个任务模拟执行5秒钟。