Java并发面试题(题目来源JavaGuide)
问题1:什么是线程和进程?线程与进程的关系,区别及优缺点?
1. 什么是进程(Process)?
-
进程 是操作系统分配资源和调度的基本单位。它是一个 程序的执行实例,包括了程序代码、程序计数器、堆栈、数据区等资源。
-
每个进程 都有独立的内存空间、文件描述符等资源。
进程的特点:
-
独立性:进程是操作系统调度的基本单位,进程之间相互独立,内存空间、文件资源等是隔离的。
-
资源开销:每个进程都有独立的内存空间,因此创建和销毁进程的开销较大。
-
并行性:多个进程可以在多核 CPU 上并行执行。
2. 什么是线程(Thread)?
-
线程 是操作系统调度的最小单位。线程是 进程中的执行单元,是程序执行的基本单元。
-
一个进程可以包含多个线程,这些线程共享进程的内存空间和资源。
线程的特点:
-
共享资源:同一进程内的多个线程共享进程的内存空间、文件描述符等资源。
-
轻量级:相对于进程,线程更加轻量,创建和销毁的开销小。
-
并发性:多个线程可以并行执行,尤其在多核 CPU 环境下,多个线程可以同时运行,极大地提高程序的处理能力。
3. 线程与进程的关系
3.1. 进程与线程的嵌套关系
-
线程是进程的一部分:每个进程至少有一个线程,称为主线程。进程是资源分配的单位,线程是执行调度的单位。
-
一个进程可以有多个线程:同一个进程的多个线程共享该进程的内存空间和资源(如文件描述符),而每个线程则有自己的程序计数器、堆栈和局部变量。
3.2. JVM 中的进程和线程
-
JVM(Java 虚拟机) 是运行 Java 程序的 进程,每个 JVM 实例是一个进程。
-
JVM 启动时会创建一个主线程,这个主线程执行
main
方法。 -
在 Java 程序中,可以通过创建 多个线程 来并发执行任务(例如,使用
Thread
类或ExecutorService
),这些线程共享 JVM 进程的内存空间。
4. 线程与进程的区别
对比项 | 进程 | 线程 |
---|---|---|
定义 | 进程是程序的执行实例,拥有独立的内存空间和资源。 | 线程是进程中的执行单元,同一进程的多个线程共享资源。 |
资源分配 | 每个进程有独立的内存空间和资源。 | 线程共享进程的内存空间和资源。 |
开销 | 创建和销毁进程的开销较大,需要分配独立的内存。 | 创建和销毁线程的开销较小。 |
调度单位 | 进程是操作系统分配资源的基本单位。 | 线程是操作系统调度的最小单位。 |
通信方式 | 进程间通信较复杂,使用 IPC(进程间通信)机制。 | 线程间通信非常容易,可以直接共享内存和资源。 |
并发性 | 支持多个进程的并发执行,性能较差。 | 多个线程可以在同一进程内并行执行,提升计算性能。 |
稳定性 | 一个进程崩溃不会影响其他进程。 | 线程崩溃可能导致整个进程崩溃。 |
切换开销 | 进程切换开销大,因为涉及内存切换。 | 线程切换开销小,因为线程共享进程的资源。 |
5. 线程与进程的优缺点
5.1. 进程的优缺点
优点:
-
隔离性好:每个进程有自己的虚拟内存空间,进程之间互相隔离,一个进程崩溃不会影响其他进程。
-
稳定性强:进程之间的资源是完全独立的,因此不会发生线程级的资源竞争问题。
缺点:
-
开销大:每个进程需要单独分配内存空间和系统资源,因此创建和销毁进程的开销较大。
-
资源占用大:进程拥有独立的内存空间,因此需要更多的系统资源。
-
通信复杂:进程间通信(IPC)较为复杂,需要操作系统提供的机制(如管道、消息队列等)。
5.2. 线程的优缺点
优点:
-
轻量级:线程的创建和销毁比进程轻便,因为线程间共享同一进程的资源。
-
资源共享:同一进程中的多个线程共享内存和资源,减少了内存的使用。
-
高效的通信:线程间通信非常简单,可以直接通过共享内存进行通信,无需复杂的 IPC。
缺点:
-
稳定性差:多个线程共享同一进程的资源,如果某个线程崩溃或者发生死锁,可能导致整个进程崩溃。
-
线程安全问题:由于线程间共享内存,容易发生 数据竞争 或 竞态条件,因此需要同步机制(如
synchronized
或ReentrantLock
)来保证线程安全。 -
调试困难:多线程程序的调试比单线程程序复杂,死锁和竞态条件等问题比较难以发现和解决。
6. 总结
对比项 | 进程 | 线程 |
---|---|---|
定义 | 进程是操作系统分配资源的基本单位。 | 线程是操作系统调度的最小单位,同一进程中的多个线程共享资源。 |
资源占用 | 进程有独立的内存空间,资源开销大。 | 线程共享进程的内存和资源,内存开销小。 |
切换开销 | 进程切换涉及内存切换,开销大。 | 线程切换只需要保存少量的上下文,开销小。 |
通信 | 进程间通信复杂,使用 IPC。 | 线程间共享内存和资源,通信简单。 |
稳定性 | 进程崩溃不会影响其他进程。 | 线程崩溃可能导致整个进程崩溃。 |
适用场景 | 适用于任务隔离较强的场景,如独立运行的应用程序。 | 适用于需要高效并发、共享数据的场景,如 Web 服务器。 |
面试要点:
-
进程 vs 线程:进程是资源分配的单位,线程是执行调度的单位。
-
线程的优势:线程比进程更轻量,能够提高程序并发执行的能力。
-
线程的挑战:线程安全问题、死锁和调试难度较高。
问题2: 为什么要使用多线程呢?
1. 计算机角度:充分利用多核 CPU 的能力
1.1. 多核 CPU 的并行处理
-
现代计算机多配备 多核 CPU(如双核、四核、八核等),每个核心可以独立执行任务。
-
单线程程序只能利用一个核心,无法充分发挥多核 CPU 的并行计算能力。
-
多线程程序可以将多个线程分配到不同的核心上运行,从而 提高计算能力 和 执行效率,在同一时间处理更多的任务。
例子:
-
假设有一个 四核 CPU,如果程序使用四个线程,每个线程分配到一个核心上进行并行处理,四个线程将可以在 同一时刻并行运行,而不是依赖单核的顺序执行。这样,计算任务的总处理时间会大幅减少。
1.2. 任务并行处理
-
多线程程序可以把多个任务分配到多个处理核心上并行执行,从而在 更短的时间内完成更多工作。
-
多核 CPU 可以并行执行多个线程,使得多个任务可以同时进行,不再需要等待其他任务完成。
例子:
-
如果一个程序需要处理多个独立的数据集,每个数据集可以分配给一个线程,多个线程并行处理不同的数据集,减少了处理的总时间。
2. 项目角度:提升系统的性能与响应能力
2.1. 提升并发性能
-
在需要同时处理多个任务的场景中,使用多线程可以显著 提高系统的并发性能。
-
例如,在 Web 服务器 中,使用多个线程可以同时处理多个用户的请求,每个请求都分配给一个独立的线程来处理,从而 减少响应时间 和 提高吞吐量。
例子:
-
一个 Web 服务器使用 线程池 来处理每个客户端请求,多个客户端可以并行发起请求,服务器会将请求分配给空闲的线程进行处理,从而显著提升系统的处理能力和响应速度。
2.2. 提高系统响应性
-
在图形用户界面(GUI)应用程序中,UI 线程负责用户交互。如果某个耗时任务(如文件下载、数据处理等)阻塞了 UI 线程,界面就会出现卡顿或无响应。
-
使用多线程可以将这些 耗时的任务(如 I/O 操作、计算密集型任务)分配到 后台线程,而让 UI 线程 保持响应,避免界面冻结。
例子:
-
在一个桌面应用程序中,文件下载任务可以在一个 后台线程 中执行,而 UI 线程 继续响应用户输入,如按钮点击、滚动等操作。
2.3. I/O 密集型任务的高效处理
-
I/O 操作(如文件读写、网络请求) 通常是程序的瓶颈,单线程程序必须等待 I/O 操作完成才能继续执行后续任务。
-
使用 多线程 可以 并行处理多个 I/O 操作,这样当一个线程在等待 I/O 操作时,其他线程可以继续执行,从而提升整体处理效率。
例子:
-
假设一个程序需要从多个文件中读取数据,使用多个线程同时读取不同的文件,能够显著提高数据读取的速度。
2.4. 实现任务的解耦和异步处理
-
多线程 允许程序将任务 解耦,使得不同任务可以异步执行。例如,前端请求可以异步发起,等待后端处理结果时,不会阻塞其他用户请求。
-
异步任务通过多线程运行,保证系统的 高效性 和 低延迟。
例子:
-
在电商网站中,订单支付后可以将通知消息发送到另一个后台线程进行处理,而前端仍然可以向用户显示订单确认页面,避免了阻塞操作。
3. 多线程的挑战和风险
尽管多线程带来了明显的性能优势,但它也伴随着一些挑战和风险:
-
线程安全问题:多个线程共享资源时,容易引发数据竞争,需要使用同步机制(如
synchronized
或ReentrantLock
)来确保线程安全。 -
死锁:多个线程互相等待对方释放资源,可能导致程序死锁,需要避免或使用超时机制来处理。
-
调试困难:多线程程序的调试比单线程程序复杂,尤其是线程间的竞态条件、死锁等问题可能很难重现和调试。
4. 总结:为什么使用多线程?
使用多线程的原因 | 具体优势 |
---|---|
提高并发性能 | 多线程能够同时处理多个任务,减少等待时间,增加任务处理量。 |
充分利用多核 CPU | 多核处理器能够让多个线程并行执行,提高计算能力和处理速度。 |
提升响应性 | 将耗时任务放入后台线程执行,保持主线程响应,避免程序阻塞。 |
提高系统吞吐量 | 通过并发执行多个任务,提升系统整体的吞吐能力。 |
高效处理 I/O 密集型任务 | 多线程可以同时处理多个 I/O 操作,提高 I/O 性能。 |
解耦和异步处理 | 使用多线程将任务解耦,实现异步执行,提高效率和响应速度。 |
面试要点:
-
并行 vs 并发:并行是多个任务同时执行,而并发是多个任务交替执行。多线程通常指并发执行。
-
多线程的优势:主要在于 提高并发性能 和 响应能力,在计算密集型、I/O 密集型任务中尤为重要。
问题3:说说线程的生命周期和状态?
1. 线程的生命周期
线程的生命周期是指线程从创建到结束的整个过程,它经历多个状态阶段。Java 中的线程生命周期由 Thread
类 和 Thread.State
枚举类管理。
2. 线程的状态
线程的状态由 Thread.State
枚举类定义,线程可以处于以下几种状态:
2.1. 新建(New)
-
线程刚刚被创建但尚未启动,处于 新建状态。
-
线程通过
Thread
类的构造方法创建,线程对象一旦创建,但没有调用start()
方法时,线程处于新建状态。
Thread thread = new Thread();
2.2. 就绪(Runnable)
-
线程调用
start()
方法后进入 就绪状态,准备由操作系统的调度器(Scheduler)进行调度。 -
在这个状态下,线程已经准备好执行,但是具体的执行时机取决于操作系统调度器,可能会等待 CPU 资源。
-
注意:Java 中的 就绪状态 与 运行状态 都属于
Runnable
状态,所以在 Java 中,Runnable
包含了两个阶段:就绪 和 运行。
thread.start(); // 线程进入就绪状态
2.3. 运行(Running)
-
线程处于 运行状态 时,它正在执行代码,并占用 CPU 资源。
-
只有操作系统的调度器选择了线程并为其分配了 CPU 时间片,线程才会进入运行状态。
-
一旦线程获得 CPU 资源,它就开始执行任务,直到任务完成或被阻塞。
2.4. 阻塞(Blocked)
-
线程处于 阻塞状态 时,它在等待某个外部条件发生(如 I/O 操作完成,锁资源可用等),无法继续执行。
-
线程进入阻塞状态的原因:
-
等待 I/O:例如,等待文件读取、网络请求等。
-
等待锁:例如,线程尝试获取一个已经被其他线程占用的锁。
-
synchronized (object) {
// 如果对象被其他线程锁定,当前线程会进入阻塞状态,直到锁可用
}
2.5. 等待(Waiting)
-
线程处于 等待状态 时,线程处于一种 无条件等待 的状态,必须由其他线程通过 通知机制(如
notify()
或notifyAll()
)来唤醒。 -
线程进入等待状态的常见方法:
-
Object.wait()
:当前线程等待,直到其他线程调用notify()
或notifyAll()
唤醒。 -
Thread.join()
:一个线程等待另一个线程完成后再继续执行。
-
synchronized (object) {
object.wait(); // 当前线程进入等待状态
}
2.6. 超时等待(Timed Waiting)
-
线程进入 超时等待状态 时,它会等待指定的时间后自动恢复执行。
-
常见方法:
-
Thread.sleep(milliseconds)
:当前线程等待指定的时间,时间结束后线程恢复执行。 -
Object.wait(milliseconds)
:线程等待指定的时间,时间到达后恢复。 -
Thread.join(milliseconds)
:线程等待另一个线程最多指定时间后恢复。
-
Thread.sleep(1000); // 线程休眠 1 秒后恢复
2.7. 终止(Terminated)
-
线程执行完毕后,进入 终止状态。线程的生命周期结束,无法再次启动。
-
当线程的
run()
方法执行完毕或者因为异常而终止时,线程进入终止状态。
public void run() {
// 线程执行完毕后进入终止状态
}
问题4:什么是线程死锁?如何避免死锁?如何预防和避免线程死锁?
线程 死锁 是指两个或多个线程在执行过程中,因为争夺资源而导致一种 互相等待的状态,从而 无法继续执行,形成一种死循环。换句话说,死锁发生时,线程互相等待对方释放资源,而每个线程都没有机会获得所需的资源,导致整个系统无法继续执行。
死锁的条件(四个必要条件)
线程发生死锁需要满足以下四个条件,这四个条件称为死锁的 必要条件:
-
互斥条件(Mutual Exclusion):至少有一个资源必须处于 独占 状态,即某个资源每次只能被一个线程使用。
-
占有并等待(Hold and Wait):线程已经持有至少一个资源,并且在等待其他线程持有的资源。
-
非抢占条件(No Preemption):资源不能被强制从线程中抢占,线程只能在自己使用完资源后才会释放资源。
-
循环等待(Circular Wait):存在一组线程 {T1, T2, T3, ..., Tn},其中每个线程 Ti 都在等待下一个线程 Ti+1 持有的资源,而最后一个线程 Tn 又在等待线程 T1 持有的资源,从而形成一个环形依赖。
2. 死锁的代码示例
2.1. 死锁的代码实例
下面是一个经典的死锁示例,两个线程试图同时获取两个锁,导致死锁:
public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock2) {
System.out.println("Thread 1: Holding lock 1 and lock 2...");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock1) {
System.out.println("Thread 2: Holding lock 2 and lock 1...");
}
}
});
t1.start();
t2.start();
}
}
2.2. 解释死锁现象
-
线程 1 获取了
lock1
锁后,试图获取lock2
锁; -
线程 2 获取了
lock2
锁后,试图获取lock1
锁; -
由于两个线程互相等待对方持有的锁,造成死锁,程序永远不会输出
Thread 1: Holding lock 1 and lock 2...
或Thread 2: Holding lock 2 and lock 1...
。
3. 如何避免死锁?
避免死锁主要有以下几种方法,最有效的策略是 减少锁的粒度 和 控制锁的顺序。
3.1. 避免嵌套锁(减少锁的粒度)
-
不要让一个线程在持有某个锁的同时去申请其他锁,避免 多个线程持有多个锁。
-
如果必须使用多个锁,尽量在较短的时间内释放锁,避免长时间占用锁。
3.2. 控制锁的获取顺序(锁的顺序)
改进示例:
-
保证 所有线程按照相同的顺序 获取锁,避免因线程获取锁的顺序不同而造成死锁。
public class DeadlockFixed { private static final Object lock1 = new Object(); private static final Object lock2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (lock1) { System.out.println("Thread 1: Holding lock 1..."); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lock2) { System.out.println("Thread 1: Holding lock 1 and lock 2..."); } } }); Thread t2 = new Thread(() -> { synchronized (lock1) { // Ensure lock1 is always acquired first System.out.println("Thread 2: Holding lock 1..."); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lock2) { System.out.println("Thread 2: Holding lock 1 and lock 2..."); } } }); t1.start(); t2.start(); } }
3.3. 使用
tryLock
避免死锁(超时锁定) -
ReentrantLock
提供了tryLock()
方法,能够尝试获取锁,如果获取不到锁,则返回false
,可以在等待锁时避免死锁。 -
如果线程获取锁失败,可以 主动释放已持有的锁,避免死锁。
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class DeadlockAvoided { private static final Lock lock1 = new ReentrantLock(); private static final Lock lock2 = new ReentrantLock(); public static void main(String[] args) { Thread t1 = new Thread(() -> { try { if (lock1.tryLock() && lock2.tryLock()) { System.out.println("Thread 1: Successfully acquired both locks"); // Do some work... } else { System.out.println("Thread 1: Couldn't acquire both locks, retrying..."); } } finally { lock1.unlock(); lock2.unlock(); } }); Thread t2 = new Thread(() -> { try { if (lock2.tryLock() && lock1.tryLock()) { System.out.println("Thread 2: Successfully acquired both locks"); // Do some work... } else { System.out.println("Thread 2: Couldn't acquire both locks, retrying..."); } } finally { lock1.unlock(); lock2.unlock(); } }); t1.start(); t2.start(); } }
在这个例子中,
tryLock()
会尝试获取锁,如果获取不到就不会一直阻塞,从而避免了死锁。
4. 如何排查和解决死锁?
4.1. 死锁排查
死锁问题在生产环境中可能较难发现和重现,以下是常用的排查方法:
-
查看线程堆栈信息:
-
可以通过
jstack
或Thread.getAllStackTraces()
获取线程的堆栈信息。死锁的线程通常会显示出等待锁的相关信息。
-
-
使用 JVM 的
-XX:+PrintGCDetails
参数:-
启动时加上此参数来打印 GC 信息,死锁信息通常会出现在日志中。
-
-
使用
jconsole
或VisualVM
等监控工具:-
这些工具可以帮助你查看线程的状态,监控是否存在 Blocked 或 Waiting 的线程,并分析死锁。
-
4.2. 死锁解决方法
-
优化锁的获取顺序:
-
保证所有线程按照相同的顺序获取锁。
-
-
使用超时机制:
-
使用
tryLock()
来避免线程长时间等待,主动退出获取锁的循环。
-
-
减少锁的粒度:
-
尽量减少加锁的代码块,避免长时间占用锁。
-
-
使用高层次并发框架:
-
Java 提供了如
ExecutorService
等并发框架,它们通常提供更高级的锁机制,减少死锁的风险。
-
5. 总结
-
死锁 是由线程间互相等待资源而造成的无法继续执行的情况,导致程序无法前进。
-
死锁的 四个必要条件 是:互斥条件、占有并等待、非抢占条件和循环等待。
-
避免死锁 的方法有:控制锁的获取顺序、使用
tryLock()
、减少锁的粒度等。 -
在生产环境中,可以通过 线程堆栈分析、JVM 监控工具 等方式来 排查死锁。
问题5:synchronized 关键字
1. synchronized
关键字的作用
synchronized
关键字用于 方法或代码块 上,控制访问共享资源的线程。它的作用是 保证同一时刻只有一个线程 可以访问被 synchronized
修饰的代码区域,从而避免了 多线程并发访问共享资源时的线程安全问题。
1.1. synchronized
用法:
-
修饰实例方法: 对整个实例方法加锁,锁定当前实例对象。
-
修饰静态方法: 对静态方法加锁,锁定的是类的 Class 对象。
-
修饰代码块: 可以对代码块加锁,锁定指定对象。
实例方法的 synchronized
:
public synchronized void method() {
// synchronized code
}
- 锁定当前对象 (
this
),保证同一个实例的多个线程访问此方法时是互斥的。
静态方法的 synchronized
:
public synchronized static void method() {
// synchronized code
}
- 锁定的是 类的 Class 对象,即所有实例共享同一个锁。
代码块的 synchronized
:
public void method() {
synchronized(this) {
// synchronized block
}
}
锁定指定的对象(例如 this
或其他对象),可以精确控制锁的范围。
2. synchronized
关键字的底层原理
2.1. 监视器锁(Monitor)
-
synchronized
底层通过 对象监视器(Monitor) 来实现锁机制。每个对象在 JVM 中都有一个与之关联的锁(称为对象的监视器)。 -
当线程执行
synchronized
修饰的方法或代码块时,它会尝试获得相应对象的 监视器锁,如果成功获得锁,则继续执行,执行完后释放锁;如果锁被其他线程持有,则该线程会进入 阻塞状态,直到锁被释放。
2.2. 锁的粒度
-
锁的粒度由
synchronized
修饰的 代码块 和 方法 确定。 -
方法级锁:锁定的是整个方法(整个方法内的代码),不能同时执行。
-
代码块级锁:锁定的是某一段代码,可以 精确控制锁的范围,提高效率。
3. JDK 1.6 之后 synchronized
的优化
JDK 1.6 引入了对 synchronized
关键字的多项优化,目的是减少 锁的竞争 和 上下文切换的开销,提高多线程程序的性能。主要的优化包括:
3.1. 锁的分类
JDK 1.6 之后,JVM 引入了 锁的分级(锁粗化、偏向锁、轻量级锁和重量级锁)。这些优化目的是为了减少锁竞争的开销,提高性能。
3.1.1. 偏向锁(Biased Locking)
-
偏向锁是一种 锁优化,在没有其他线程竞争的情况下,JVM 会让第一个获得锁的线程 偏向 于该锁,其他线程尝试获取该锁时,会被直接跳过。
-
这样避免了不必要的锁竞争,提升单线程程序性能。
3.1.2. 轻量级锁(Lightweight Locking)
-
如果线程持有锁之后没有遇到竞争,JVM 会采用 轻量级锁(通过 CAS 操作),避免了内核级的线程切换,减少了系统的开销。
-
轻量级锁通过 CAS(Compare And Swap) 在锁的对象头部记录锁信息,当有多个线程竞争时,JVM 会升级为 重量级锁。
3.1.3. 重量级锁(Heavyweight Locking)
-
如果多个线程竞争锁,JVM 会 升级为重量级锁,这时锁的获取变得更加复杂,涉及到 操作系统内核的调度。线程的上下文切换和线程调度开销较大。
3.1.4. 锁粗化
-
锁粗化是指将多个临近的 同步代码块 合并成一个大范围的同步代码块。减少了锁的获取和释放次数,优化了性能。
3.2. 锁的升级过程
-
偏向锁 → 轻量级锁 → 重量级锁
-
初始状态下,锁为 偏向锁,如果没有竞争,锁保持偏向状态。
-
如果出现竞争,锁会升级为 轻量级锁,通过 CAS 操作快速获取锁。
-
如果竞争激烈,锁会变成 重量级锁,这种锁会导致线程的上下文切换,性能损失较大。
-
4. synchronized
与 ReentrantLock
的区别
4.1. synchronized
与 ReentrantLock
的对比
特性 |
|
|
---|---|---|
锁的粒度 | 只能修饰方法或代码块,比较简单。 | 提供了更灵活的锁管理,支持 公平锁、可中断锁 等功能。 |
是否可重入 | 是,允许同一线程多次进入。 | 是, |
是否支持公平锁 | 不支持公平锁(线程的获取锁顺序不确定)。 | 支持公平锁,可以确保线程按照请求锁的顺序获得锁。 |
中断支持 | 不支持中断,线程一旦进入 | 支持中断,线程可以在等待锁的过程中被中断。 |
性能 | JDK 1.6 后进行了优化,但仍然可能在高竞争时影响性能。 | 在高并发场景下性能优于 |
锁的获取方式 | 隐式获取锁,Java 编译器自动处理。 | 显式获取锁,需要调用 |
锁的释放方式 | 隐式释放锁,在方法执行完或代码块结束后自动释放锁。 | 需要手动调用 |
5. synchronized
和 volatile
的区别
synchronized
和 volatile
都与多线程编程相关,但作用不同:
5.1. synchronized
和 volatile
对比
特性 |
|
|
---|---|---|
作用 | 用于实现线程间的同步,保证线程安全。 | 保证可见性,确保修改后的变量对其他线程可见。 |
性能开销 | 相对较高,涉及到锁的获取和释放。 | 较低,主要是通过内存屏障保证可见性,没有锁的开销。 |
保证的功能 | 原子性 和 可见性,保证多线程环境下的同步。 | 仅保证 可见性,不能保证操作的原子性。 |
使用场景 | 用于控制访问共享资源,避免数据竞争。 | 用于确保变量的 可见性,多线程中的共享数据访问。 |
是否涉及上下文切换 | 可能会涉及到线程上下文切换。 | 不涉及线程上下文切换。 |
5.2. 区别总结
-
synchronized
:主要用于保证线程的 同步,防止线程间竞争共享资源,确保 原子性 和 可见性。 -
volatile
:主要用于确保 变量的可见性,即一个线程修改的变量对其他线程立即可见,但 不能保证原子性。
总结
-
synchronized
是一种简单且强大的线程同步工具,保证了线程间的 互斥访问 和 同步,但可能存在性能开销。 -
在 JDK 1.6 后,
synchronized
进行了多项优化,包括 锁的升级(偏向锁、轻量级锁、重量级锁)和 锁粗化。 -
ReentrantLock
提供了更丰富的功能,如 可中断锁、公平锁 和 显式加锁与解锁,在复杂的并发场景中比synchronized
更灵活。 -
volatile
保证了变量的 可见性,而synchronized
不仅保证可见性,还能保证 原子性。
问题6: 并发编程的三个重要特性
1. 原子性 (Atomicity)
-
定义:原子性是指某个操作是不可分割的,线程执行的过程中不可以被中断。原子性操作要么完全执行,要么完全不执行,不会出现执行一半的情况。
-
实际表现:在多线程环境下,如果多个线程同时操作共享资源,原子性保证了一个线程的操作不会被其他线程打断,避免了数据竞争和不一致的情况。
-
例子:
-
synchronized
关键字可以用来保证代码块或方法的原子性,确保同一时刻只有一个线程可以访问该代码块。 -
AtomicInteger
等原子类使用底层的硬件指令(如 CAS)保证操作的原子性。
-
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // 保证原子性
2. 可见性 (Visibility)
-
定义:可见性是指一个线程对共享变量的修改,能够被其他线程及时看到。即,一个线程对共享变量的修改对其他线程是可见的,避免了线程间的数据不一致问题。
-
实际表现:在多线程环境中,如果一个线程修改了共享变量,其他线程需要能够看到这个修改,确保数据一致性。
-
例子:
-
在 Java 中,
volatile
关键字可以确保变量的修改对所有线程可见。 -
使用
synchronized
或Lock
可以保证对共享资源的修改对其他线程可见。
-
private volatile boolean flag = false; // flag 的修改对其他线程可见
. 有序性 (Ordering)
-
定义:有序性是指程序中代码的执行顺序与预期一致。在单线程中,代码的执行顺序是按照代码书写的顺序执行的;但在多线程环境下,JVM 或 CPU 可能会对指令进行重排序,因此需要通过同步机制来确保正确的执行顺序。
-
实际表现:有序性确保了在多线程环境下,程序的执行顺序与我们在代码中编写的顺序一致,避免了因指令重排序引发的错误。
-
例子:
-
使用
synchronized
或volatile
保证有序性。 -
如果多个线程在访问共享变量时存在执行顺序的问题,可以通过
Lock
或CountDownLatch
等机制进行协调。
-
private volatile int a = 0;
private volatile int b = 0;
public void method1() {
a = 1;
b = 2;
}
public void method2() {
if (b == 2 && a == 1) {
// 保证有序性,避免 b 更新后 a 还未更新的情况
}
}
问题7:JMM(Java Memory Model,Java 内存模型)和 happens-before 原则。
JMM (Java Memory Model) 和 happens-before 原则 是 Java 并发编程中非常重要的概念,它们用来确保在多线程环境中,线程间的共享变量的可见性和有序性。理解这些概念对于编写正确且高效的并发程序至关重要。
1. JMM (Java Memory Model,Java 内存模型)
JMM 定义了 Java 程序中变量(共享变量)如何在不同线程之间进行交互以及如何保证线程安全。它描述了线程如何与主内存(即堆内存)进行交互,并定义了对共享变量的访问规则。
-
主内存与工作内存:
-
主内存:所有共享变量存储的地方,是所有线程共享的内存区域(一般是堆内存)。
-
工作内存:每个线程的私有内存,它保存了该线程使用到的共享变量的副本。
-
-
共享变量:
-
线程对共享变量的操作(读、写)通常不是直接操作主内存中的数据,而是操作自己的工作内存中的副本。JMM 确保线程对共享变量的操作能够在适当的时候同步到主内存中,确保线程间的数据可见性和一致性。
-
-
JMM 的关键点:
-
可见性:一个线程对共享变量的修改能及时反映到其他线程中。
-
有序性:程序中指令的执行顺序与代码顺序的匹配,避免由于指令重排序造成的错误。
-
-
JMM 的限制:
-
缓存一致性问题:多个线程可能会拥有共享变量的副本,这就会引发缓存不一致的问题。
-
指令重排序:为了优化程序的性能,JVM 或 CPU 可能会对指令进行重排序,这会影响程序的执行顺序。
-
2. happens-before 原则
happens-before 原则是 JMM 中的一条规则,规定了一个操作如何保证它之前的操作对其他线程可见。它是理解并发程序正确性的关键,确保多线程执行时,程序行为符合预期。
-
happens-before 原则的定义:
-
如果操作 A happens-before 操作 B,那么操作 A 对线程 B 是可见的,操作 A 的执行结果会对 B 起作用。即,A 必须在 B 之前完成。
-
-
常见的 happens-before 规则
1.程序顺序规则:在同一个线程内,前一个操作 happens-before 后一个操作。
-
例如,代码
a = 1; b = 2;
中,a = 1
happens-beforeb = 2
。
2.锁规则:解锁操作 happens-before 随后的加锁操作。
-
例如,
lock.unlock()
happens-beforelock.lock()
。
3.volatile 变量规则:对一个 volatile
变量的写操作 happens-before 随后的对该变量的读操作。
volatile boolean flag = false;
flag = true; // 写操作
if (flag) { // 读操作
// 此时可以保证 flag 的写操作对读操作可见
}
4.线程启动规则:对一个线程的 start()
调用 happens-before 该线程的任何操作。
Thread t = new Thread(() -> {
// 线程执行代码
});
t.start(); // t.start() happens-before t 中的代码执行
5.线程终止规则:一个线程的 join()
方法发生在它所启动线程的执行之前。
-
t.join()
happens-beforet
执行结束。
happens-before 的重要性
-
保证内存可见性:通过 happens-before 规则,可以确保在一个线程对共享变量的修改能够被其他线程看到。比如,某线程对
volatile
变量的写操作,另一个线程在读取该变量时,能看到最新的值。 -
保证程序有序性:happens-before 原则还保证了线程中操作的执行顺序不会被改变,使得多线程程序的行为符合预期。
JMM 与 happens-before 的关系
-
JMM 定义了线程如何通过工作内存与主内存交互,而 happens-before 原则 提供了保证不同线程间操作的执行顺序和可见性的规则。通过这些规则,JMM 能够确保多线程环境中的共享变量按照正确的顺序进行读写,并且能够保证线程间的操作可见。
总结
-
JMM (Java Memory Model):定义了 Java 中共享变量的访问规则,确保多线程程序中的可见性和有序性。
-
happens-before 原则:是 JMM 的重要组成部分,确保在多线程环境中,线程间的操作顺序和可见性。
这些概念帮助我们理解如何在 Java 中正确地编写并发程序,避免数据竞争、内存不一致和指令重排序等问题。
问题8: volatile 关键字
在 Java 中,volatile
是一个重要的关键字,它与 Java 内存模型 (JMM) 和 happens-before 原则 密切相关,主要用于解决多线程环境中的 可见性 问题,确保线程之间共享变量的最新值能够及时同步。
volatile
的作用
volatile
关键字主要有两个作用:
-
保证可见性:保证多个线程对变量的修改对其他线程可见。
-
禁止指令重排序:确保在某些情况下,操作的顺序与代码书写的顺序一致。
1. 保证可见性
在没有 volatile
的情况下,线程对共享变量的修改会先写入本地线程的工作内存(即 CPU 缓存),并不会立即刷新到主内存。这可能导致其他线程读取的值不是最新的,造成数据不一致的问题。
通过 volatile
关键字修饰的变量,JVM 会确保每次对该变量的读写操作都会直接访问主内存,而不是从工作内存(线程私有缓存)中读取或写入,从而保证了线程之间共享变量的可见性。
JMM 中的可见性
-
对
volatile
变量的写操作,在其他线程读取该变量时,能够立即看到最新的值。 -
在多线程环境中,如果一个线程修改了
volatile
变量的值,JVM 会保证这个修改在其他线程中可见。
举例:
public class VolatileExample {
private volatile boolean flag = false;
public void setFlag() {
flag = true; // 写操作
}
public void checkFlag() {
if (flag) {
// 这里能够看到 setFlag() 写入的值
System.out.println("Flag is true");
}
}
}
2. 禁止指令重排序
volatile
关键字还可以防止某些特定情况下的指令重排序。JVM 和 CPU 在执行指令时,可能会为了优化执行顺序而对指令进行重排序,这可能会破坏程序的逻辑顺序。使用 volatile
可以防止变量的读写操作被重排序,从而保证程序的执行顺序。
JMM 中的有序性
-
volatile
变量的读/写操作不会被重排序,即确保写操作在所有其他线程看到之前完成,读取操作会在看到写操作之前执行。 -
使用
volatile
可以使得 happens-before 规则生效,确保某些操作的顺序性。
举例:
public class VolatileReorderingExample {
private volatile int a = 0;
private volatile int b = 0;
public void write() {
a = 1; // 写操作
b = 2; // 写操作
}
public void read() {
if (b == 2 && a == 1) {
// 此时可以确保 b = 2 happens-before a = 1
System.out.println("a = " + a + ", b = " + b);
}
}
}
在没有 volatile
的情况下,JVM 或 CPU 可能会重排序 a = 1
和 b = 2
的执行顺序,导致 read
方法中 b == 2
时,a
可能并没有被设置为 1。但在使用 volatile
后,JMM 保证了写操作的顺序性,即 b = 2
happens-before a = 1
。
volatile
与 happens-before 原则
-
对一个
volatile
变量的写操作 happens-before 对该变量的任何读操作。这是由 JMM 保证的。 -
例如,一个线程写入
volatile
变量的值后,其他线程读取该变量时,可以保证读取到的是最新的值。
总结
-
volatile
关键字用于确保变量在多个线程间的可见性,同时它还禁止了指令重排序。 -
在 JMM (Java Memory Model) 中,
volatile
变量的值直接在主内存中进行读写,保证了线程间的可见性。 -
happens-before 原则:对一个
volatile
变量的写操作 happens-before 随后的读操作,保证了线程之间的数据同步。
问题9:ThreadLocal 关键字
ThreadLocal
是 Java 中用于实现 线程局部存储(Thread Local Storage,TLS)的机制,它允许每个线程都有独立的变量副本,这些副本在线程之间相互隔离。ThreadLocal
的主要目的是解决多线程环境中共享数据的问题,尤其是在需要线程私有数据时。
ThreadLocal 的底层原理
ThreadLocal
在底层通过一个 ThreadLocalMap
来实现的,每个线程都有一个 ThreadLocalMap
,用于存储线程局部变量的副本。线程通过 ThreadLocalMap
存取自己的 ThreadLocal
变量副本。
实现细节:
-
每个线程通过
Thread.currentThread()
获取当前线程的实例,再通过ThreadLocalMap
存储对应的线程局部变量。 -
ThreadLocal
本质上是通过线程的ThreadLocalMap
来为每个线程创建一个独立的存储空间。 -
ThreadLocalMap
的实现是 弱引用(WeakReference
)的,这意味着当一个ThreadLocal
变量不再被引用时,它会被垃圾回收机制回收,这可以帮助避免内存泄漏。
内存模型:
-
线程内部的
ThreadLocalMap
:每个线程持有一个ThreadLocalMap
,通过该映射关系线程能够存取线程局部变量的副本。 -
键值对:
ThreadLocal
变量是ThreadLocalMap
中的键,而ThreadLocalMap
中的值是该线程对应的线程局部变量。
内存泄漏问题
尽管 ThreadLocal
能有效地实现线程局部存储,但如果不正确使用,它也可能导致 内存泄漏 问题。
为什么会有内存泄漏?
-
ThreadLocalMap
的键是 弱引用,这意味着当ThreadLocal
变量不再被引用时,它会被回收。 -
但
ThreadLocalMap
中的值(即线程局部变量)仍然是 强引用,因此即使ThreadLocal
变量已经被回收,对应的值也不会被及时清理。长时间持有对这些值的强引用可能会导致内存泄漏,尤其在高并发的环境中,线程池中的线程会重复使用,这时不及时清理线程局部变量就可能导致内存泄漏。
如何避免内存泄漏:
- 手动清理:通过调用
ThreadLocal.remove()
方法,显式地移除线程局部变量,防止内存泄漏。
threadLocal.remove(); // 移除线程局部变量
- 线程池中的使用:在使用线程池时,线程池中的线程可能会复用,因此如果没有及时清理
ThreadLocal
变量,可能会导致线程持有不再需要的变量,从而导致内存泄漏。
ThreadLocal
的应用场景
1.数据库连接: 在多线程环境中,常常需要为每个线程提供独立的数据库连接。通过 ThreadLocal
可以避免线程之间共享同一数据库连接,减少加锁带来的性能损耗。
private static ThreadLocal<Connection> connectionHolder = ThreadLocal.withInitial(() -> {
return createDatabaseConnection();
});
public Connection getConnection() {
return connectionHolder.get();
}
2.用户会话信息: 例如,在 Web 应用程序中,每个请求可能对应一个线程,而每个请求会有用户的会话信息。通过 ThreadLocal
可以存储线程私有的会话信息。
private static ThreadLocal<UserSession> userSession = new ThreadLocal<>();
public static UserSession getSession() {
return userSession.get();
}
public static void setSession(UserSession session) {
userSession.set(session);
}
3.日志上下文: 在多线程日志记录中,可以通过 ThreadLocal
存储与线程相关的上下文信息(如日志级别、日志ID等),从而避免线程间的日志信息冲突。
线程池中的 ThreadLocal
使用
在使用线程池时,由于线程池中的线程会被复用,因此线程局部变量可能会被复用。为了避免内存泄漏问题,必须确保在线程完成任务后清除线程局部变量。通常在线程池任务完成后,调用 ThreadLocal.remove()
方法来清除线程局部变量。
ExecutorService executorService = Executors.newFixedThreadPool(10);
ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
executorService.submit(() -> {
try {
threadLocal.set(100); // 每个线程可以存储独立的值
// 执行任务
} finally {
threadLocal.remove(); // 任务完成后清理 ThreadLocal 变量
}
});
总结
-
ThreadLocal
是 Java 提供的一种为每个线程提供独立存储的机制,适用于线程私有的数据,如数据库连接、用户会话信息、日志上下文等。 -
底层原理:通过
ThreadLocalMap
来为每个线程提供独立的变量副本。 -
内存泄漏问题:如果不及时清理
ThreadLocal
中的值,可能导致内存泄漏,尤其是在线程池中使用时,复用的线程可能持有不再需要的ThreadLocal
数据。 -
避免内存泄漏:使用
remove()
方法手动清理线程局部变量。
问题10:线程池
线程池(Thread Pool)是一种线程管理技术,用于通过复用一组线程来提高系统的性能。使用线程池可以避免频繁地创建和销毁线程,减少系统资源消耗,提高响应速度,并能够有效地管理系统的线程。
线程池的类型
Java 中有多种不同类型的线程池,每种类型的线程池适用于不同的应用场景。常见的线程池类型由 Executors
工厂类提供:
1. FixedThreadPool(固定大小线程池)
-
特点:线程池中的线程数量是固定的,不会发生变化。任务被提交到线程池后,线程池会分配线程来执行任务。如果所有线程都在工作,新的任务会被放入等待队列,直到有线程空闲时才会被执行。
-
适用场景:适用于负载较均衡且线程数固定的应用场景。
-
优点:
-
控制线程的数量,不会超出固定的线程数。
-
性能稳定,适用于负载均衡的任务。
-
-
缺点:
-
任务量较多时,可能会导致任务积压在队列中,等待线程空闲。
-
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(4); // 核心线程数为4
2. CachedThreadPool(缓存线程池)
-
特点:线程池中的线程数量是动态的,线程池会根据任务的数量动态创建新的线程来处理任务。如果线程空闲时间超过一定时间,就会被销毁。
-
适用场景:适用于执行很多短期异步任务的场景,且任务的数量和线程数目不确定。
-
优点:
-
适用于任务量不确定的情况,线程池会根据需要创建和销毁线程。
-
有较强的灵活性,能够适应高并发任务。
-
-
缺点:
-
如果任务量很大,可能会导致创建过多的线程,增加系统负担,导致内存泄漏。
-
ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); // 动态创建线程
3. SingleThreadExecutor(单线程化线程池)
-
特点:线程池中只有一个线程,任务会按提交的顺序依次执行,确保任务的顺序执行。
-
适用场景:适用于必须保证任务顺序执行,且执行任务的数量有限的场景。
-
优点:
-
保证任务顺序执行。
-
线程资源消耗低,避免了多线程管理的复杂性。
-
-
缺点:
- 如果某个任务的执行时间过长,可能会影响后续任务的执行。
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); // 仅有一个线程
4. ScheduledThreadPoolExecutor(定时线程池)
-
特点:支持定时任务和周期性任务。可以定期或延迟地执行任务。
-
适用场景:适用于周期性执行任务(例如定时清理缓存、定期发送邮件等)。
-
优点:
-
可以定期执行任务,支持延迟任务和周期性任务的执行。
-
-
缺点:
-
线程池中的线程数量是固定的,如果任务过多会阻塞其他任务的执行。
-
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(2); // 固定大小线程池
scheduledExecutorService.scheduleAtFixedRate(task, initialDelay, period, TimeUnit.SECONDS); // 周期性任务
线程池的重要参数
-
corePoolSize(核心线程数):
-
核心线程池数量,即线程池在没有任务执行时,仍会保持的线程数量。
-
如果线程池中有空闲的核心线程,这些线程将被复用,而不是新建线程。
-
-
maximumPoolSize(最大线程数):
-
线程池允许创建的最大线程数。当任务数量超过核心线程数时,线程池会创建新线程直到达到最大线程数。
-
-
keepAliveTime(线程存活时间):
-
非核心线程在空闲时可以存活的最大时间。超过这个时间没有新任务提交时,非核心线程会被销毁。
-
-
BlockingQueue(任务队列):
-
用于存储等待执行的任务。当线程池中的线程都在工作时,新的任务会进入队列等待。常见的队列有
ArrayBlockingQueue
、LinkedBlockingQueue
、SynchronousQueue
等。
-
-
ThreadFactory(线程工厂):
-
用于创建线程,可以通过
ThreadFactory
提供自定义的线程创建策略(例如设置线程名字、优先级等)。
-
-
RejectedExecutionHandler(拒绝策略):
-
当线程池达到最大线程数且任务队列已满时,线程池会根据指定的拒绝策略来处理新提交的任务。常见的拒绝策略有:
-
AbortPolicy(默认):丢弃任务并抛出异常。
-
CallerRunsPolicy:调用者线程处理任务(阻塞当前线程)。
-
DiscardPolicy:丢弃任务,不抛出异常。
-
DiscardOldestPolicy:丢弃队列中最旧的任务。
-
-
线程池的执行流程
-
提交任务:任务被提交到线程池。
-
任务排队:如果有空闲线程,线程池会直接分配线程执行任务。如果没有空闲线程且队列未满,任务将进入队列等待。
-
线程创建:当线程池中的线程数未达到最大线程数且队列已满时,线程池会创建新的线程来处理任务。
-
任务执行:线程池中的线程会从队列中取出任务并执行,直到任务执行完成。
-
线程回收:线程池中的非核心线程在空闲超时后会被回收。核心线程会一直保留,直到线程池关闭。
线程池的饱和策略
当线程池的线程数量达到 maximumPoolSize
且任务队列也已满时,线程池将采取以下几种饱和策略:
-
AbortPolicy(默认):直接抛出
RejectedExecutionException
异常。 -
CallerRunsPolicy:让调用者线程自己执行当前任务,阻塞当前线程直到任务完成。
-
DiscardPolicy:直接丢弃当前任务,不抛出异常。
-
DiscardOldestPolicy:丢弃任务队列中最旧的任务,然后尝试提交当前任务。
如何设置线程池的大小
线程池的大小需要根据实际情况进行设置:
-
核心线程数 (
corePoolSize
):一般情况下,设置为 CPU 核心数的大小或稍微大一些,可以根据业务场景进行微调。 -
最大线程数 (
maximumPoolSize
):设置为 CPU 核心数的 2 到 4 倍,避免过度创建线程导致资源浪费。对于 CPU 密集型任务,建议使用较小的线程池;对于 IO 密集型任务,线程池大小可以适当增加。 -
任务队列的选择:选择合适的任务队列也很重要,对于大量短小任务,
SynchronousQueue
可能更合适,而对于长时间阻塞任务,LinkedBlockingQueue
更适合。 -
KeepAliveTime:对于长时间不执行任务的线程,建议设置适当的
keepAliveTime
,避免资源浪费。
int processors = Runtime.getRuntime().availableProcessors(); // 获取 CPU 核心数
ExecutorService threadPool = new ThreadPoolExecutor(
processors, // corePoolSize
processors * 2, // maximumPoolSize
60L, // keepAliveTime
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100), // 队列大小
new ThreadPoolExecutor.CallerRunsPolicy() // 饱和策略
);
问题11:ReentrantLock 和 AQS
ReentrantLock 和 AQS
ReentrantLock
是 Java 提供的一个显式锁,它实现了 Lock
接口,提供了比 synchronized
关键字更高级的锁机制。它是基于 AbstractQueuedSynchronizer(AQS)框架实现的。
AQS 提供了一种框架,能够帮助开发者创建自己的同步器(如锁、信号量等),并且提供了一些常见的同步器所需要的功能,比如获取和释放锁、管理线程的排队等待、线程的中断支持等。
ReentrantLock 的特性
-
可重入性:
ReentrantLock
是可重入的,即同一个线程在获取锁后,可以再次获取该锁而不会发生死锁。它通过维护一个计数器来实现这一特性。当线程获取锁时,计数器加 1,当释放锁时,计数器减 1,直到计数器为 0 时,锁才真正被释放。 -
公平性和非公平性:
-
公平锁:ReentrantLock 可以通过构造方法指定是否为公平锁。公平锁保证了等待时间最长的线程会先获得锁,避免了线程饥饿的情况。
-
非公平锁:如果不指定公平性,则默认使用非公平锁。非公平锁可能会导致某些线程长时间得不到执行,但能提高系统的吞吐量。
-
-
支持中断:
ReentrantLock
提供了lockInterruptibly()
方法,允许线程在等待获取锁的过程中被中断,这对于响应中断的任务非常有用。 -
可查询状态:
ReentrantLock
提供了isLocked()
和isHeldByCurrentThread()
方法,可以查询锁是否被占用,以及当前线程是否持有锁。 -
支持条件变量(Condition):
ReentrantLock
提供了newCondition()
方法,可以通过Condition
对象来实现等待/通知机制,比Object.wait()
和Object.notify()
更加灵活。 -
锁的公平性:通过构造函数的参数,可以选择公平锁还是非公平锁。
-
公平锁(
true
):线程会按照请求锁的顺序排队。 -
非公平锁(
false
,默认):新来的线程可能会抢先获取锁。
-
ReentrantLock 的实现原理
ReentrantLock
的实现基于 AQS(AbstractQueuedSynchronizer)。AQS 是一个抽象类,提供了一个通用的同步框架,可以帮助开发者实现不同类型的同步器。AQS 本质上是一个队列同步器,利用 FIFO 队列 来管理等待获取锁的线程。
AQS 的核心是 acquire()
和 release()
方法,这两个方法负责线程的获取和释放资源。ReentrantLock
基于 AQS 实现了其获取和释放锁的功能。
AQS 的核心原理
AQS 的设计是基于 FIFO 队列 和 锁状态 的。它的基本概念包括:
-
同步状态:AQS 维护一个整数值,代表锁的状态,通常用来表示锁的占用情况。不同的同步器可以根据自己的需求定义不同的同步状态。
-
队列:AQS 使用一个 CLH 队列(或称为 FIFO 队列)来管理线程。在一个线程等待获取锁时,它会被加入队列,直到它有机会获得锁。
-
CAS(Compare-And-Swap):AQS 使用 CAS 操作来保证同步状态的原子性,确保对同步状态的更新是线程安全的。
-
状态获取与释放:
-
tryAcquire(int acquires)
:尝试获取锁,如果当前锁的状态允许,返回 true,否则返回 false。通常会更新锁的状态。 -
tryRelease(int releases)
:尝试释放锁,更新锁的状态,唤醒等待队列中的其他线程。 -
acquire(int arg)
和release(int arg)
:分别是被 AQS 子类(如ReentrantLock
)调用来获取和释放资源的方法。
-
ReentrantLock 和 AQS 的工作流程
-
锁的获取:
-
当线程调用
lock()
时,它会调用AQS
的acquire()
方法,尝试获取锁。 -
如果当前没有其他线程持有锁,线程会成功获取锁并修改 AQS 中的同步状态。
-
如果有其他线程持有锁,则当前线程会被加入到 AQS 的队列中,等待锁的释放。
-
-
锁的释放:
-
当线程调用
unlock()
时,它会调用AQS
的release()
方法。 -
当当前线程释放锁时,会更新锁的状态并通知队列中的下一个线程,使其有机会获取锁。
-
ReentrantLock 的主要方法
-
lock()
:-
获取锁,如果锁被其他线程持有,则当前线程会被阻塞,直到获取到锁。
-
-
unlock()
:-
释放锁,允许其他线程获取锁。
-
-
lockInterruptibly()
:-
与
lock()
不同,lockInterruptibly()
方法允许在等待锁时响应中断。如果线程在等待锁时被中断,方法会抛出InterruptedException
。
-
-
tryLock()
:-
尝试获取锁,立即返回,不会阻塞。如果锁可用,返回 true;如果不可用,返回 false。
-
-
tryLock(long time, TimeUnit unit)
:-
尝试在指定的时间内获取锁。如果在指定的时间内能获取到锁,返回 true;否则返回 false。
-
-
newCondition()
:-
创建一个
Condition
对象,可以用于线程的等待和通知机制。
-
ReentrantLock 与 synchronized 的比较
特性 |
|
|
---|---|---|
是否可重入 | 是 | 是 |
公平性 | 可选(公平/非公平) | 无法控制(非公平) |
中断支持 | 支持 | 不支持 |
多条件支持 | 支持通过 | 不支持 |
锁的获取/释放 | 显式调用 | 隐式调用 |
性能(在高并发情况下) | 一般较好,特别是在竞争较激烈时 | 较低 |
总结
-
ReentrantLock
是一个可重入的显式锁,它基于 AQS 实现了线程同步的功能。通过 AQS,ReentrantLock
可以控制锁的获取与释放、支持中断、条件变量、并提供公平锁和非公平锁的选择。 -
AQS 提供了一个框架,可以帮助开发者实现不同类型的同步器。通过 FIFO 队列和 CAS 操作,AQS 实现了线程的排队和状态管理,确保多线程环境下的同步操作能够安全、高效地进行。
-
与
synchronized
相比,ReentrantLock
提供了更灵活的控制方式,如中断响应、条件变量和公平性控制等。
问题12: 乐观锁和悲观锁的区别
乐观锁和悲观锁是两种常见的锁策略,主要用于控制并发环境下对共享资源的访问。它们的区别在于对并发冲突的处理方式不同。
悲观锁 (Pessimistic Lock)
悲观锁是一种假设会发生冲突的策略,在操作共享资源之前,认为其他线程很可能会干扰当前线程的操作,因此在访问数据时,会对共享资源加锁,直到操作完成后释放锁。
特点:
-
假设资源冲突:悲观锁的核心思想是“假设会发生冲突”,因此每次对共享资源进行修改时,都需要加锁。
-
锁粒度大:因为加锁是防止冲突发生的一种手段,所以可能会导致较长时间的锁定,降低并发性能。
-
阻塞:当一个线程获取锁时,其他线程必须等待直到锁释放。
-
传统锁机制:比如
synchronized
和ReentrantLock
都是悲观锁的实现。
使用场景:
适用于冲突频繁的场景,例如银行账户操作(例如转账),多个线程对同一账户操作时,需要避免数据竞争。
synchronized (lockObject) {
// 对共享资源进行修改
}
乐观锁 (Optimistic Lock)
乐观锁是一种假设不会发生冲突的策略。在进行数据操作时,乐观锁不加锁,而是通过某些机制(如版本号、时间戳等)来保证数据的安全性,只有在提交更新时才检查是否发生了冲突。如果发生冲突,则回滚或重试。
特点:
-
假设资源不会冲突:乐观锁的核心思想是“假设不会发生冲突”,因此在操作资源时不加锁,只有在提交时才检测是否有其他线程修改过数据。
-
锁粒度小:因为没有加锁,乐观锁允许较高的并发性能,适合资源冲突较少的场景。
-
不阻塞:乐观锁不会阻塞其他线程,而是通过检查和更新来确保数据一致性。
-
回滚/重试机制:如果在更新时发现资源被修改过,乐观锁会采取回滚或重试的方式来确保数据一致性。
使用场景:
适用于冲突较少的场景,如大量读操作,或者资源更新冲突不频繁的场景。例如数据库中的 select for update
语句、AtomicInteger
的 CAS 操作等。
// 使用版本号进行乐观锁控制
while (currentVersion == expectedVersion) {
// 执行操作
if (updateSuccess) {
break;
}
// 重新获取版本号进行重试
}
两者的主要区别
特性 | 悲观锁 | 乐观锁 |
---|---|---|
假设冲突 | 假设会发生冲突 | 假设不会发生冲突 |
加锁方式 | 显式加锁 | 不加锁,使用版本号、时间戳等机制检测冲突 |
性能 | 性能较低,因为会有阻塞和锁竞争 | 性能较高,因为不会阻塞,适合读多写少的场景 |
适用场景 | 高冲突的场景,例如银行转账、库存扣减等 | 低冲突的场景,例如缓存更新、批量处理等 |
实现 | synchronized 、ReentrantLock 等 | 基于版本号、CAS 操作等 |
总结
- 悲观锁:假设会发生冲突,每次操作时都加锁,确保数据的完整性,适用于冲突频繁的场景,但性能较低。
- 乐观锁:假设不会发生冲突,通过版本号、时间戳等机制在提交时检测冲突,适用于冲突较少的场景,性能较高。
问题13:CAS 了解么?原理?什么是 ABA 问题?ABA 问题怎么解决?
CAS(Compare-And-Swap)原理
CAS(比较并交换)是一种原子操作,用于在并发编程中保证对共享变量的更新是线程安全的。CAS 是无锁的,它通过比较并更新内存中的值来避免锁的开销,从而提高并发性能。
CAS 操作涉及三个参数:
-
内存位置 V:需要更新的变量的内存地址。
-
期望值 A:当前内存位置的预期值。
-
新值 B:更新内存位置的值。
CAS 的操作步骤如下:
-
如果内存位置 V 的当前值与期望值 A 相等,那么 CAS 会将 V 的值更新为新值 B。
-
如果 V 的当前值与期望值 A 不相等,那么 CAS 不会执行更新操作,并返回当前 V 的值。
CAS 是原子的,这意味着在执行操作时不会被中断。CAS 被广泛用于并发编程中,特别是基于无锁数据结构的实现。
CAS 的优缺点
优点:
-
无锁:CAS 是无锁的操作,不需要加锁,因此可以避免因加锁造成的线程阻塞和上下文切换开销。
-
高效:CAS 的性能通常优于传统的加锁方法,尤其在数据冲突较少的情况下。
缺点:
-
ABA 问题:CAS 只会检查当前值与期望值是否一致,但它无法知道中间值是否变化过。
-
自旋:如果 CAS 操作失败,线程会反复尝试(自旋),在高并发情况下可能会导致 CPU 的高开销。
ABA 问题
ABA 问题是 CAS 操作中的一个典型问题。它描述的是这样一种情况:假设一个线程执行 CAS 操作时,变量的值发生了变化,但是这些变化在最终执行时被忽略了,导致 CAS 错误地认为值没有变化,从而执行了更新操作。
具体来说:
-
假设一个线程 A 读取了变量 X 的值为 A,执行了 CAS 操作,并且期望 X 的值为 A。
-
但是,在这个过程中,另一个线程 B 修改了 X 的值,将它从 A 更新为 B,再将它恢复为 A。
-
此时,线程 A 仍然认为 X 的值没有发生变化,因为它的值最终又变回了 A,但实际上 X 已经发生了变化。
这个问题的根本原因是 CAS 只关心变量的当前值,而不关心变量的历史变动。
ABA 问题的解决方法
为了解决 ABA 问题,常见的做法是引入 版本号 或 时间戳,以便在 CAS 操作中能够检测到变量值的变化。
1. 引入版本号(版本控制):
-
通过为每个共享变量引入一个版本号来解决 ABA 问题。每次变量更新时,不仅更新变量的值,还更新版本号。
-
在 CAS 操作时,除了检查值是否一致外,还要检查版本号是否一致。如果版本号不同,说明变量经历了变化。
实现方法:使用 AtomicStampedReference
类,它将数据值和版本号结合起来,通过版本号的改变来防止 ABA 问题。
示例:
AtomicStampedReference<Integer> reference = new AtomicStampedReference<>(100, 0);
int[] stamp = new int[1];
Integer currentValue = reference.get(stamp);
if (reference.compareAndSet(currentValue, 200, stamp[0], stamp[0] + 1)) {
// 成功更新
}
2. 使用时间戳:
-
每个更新操作都附带一个时间戳,CAS 操作时也检查时间戳。如果时间戳不同,说明变量发生了变化。
3. 使用 AtomicMarkableReference
:
-
这个类通过一个布尔标记来表示变量是否发生了变化。每次更新操作时,不仅更新数据值,还更新标记,CAS 操作时通过比较标记来防止 ABA 问题。
实现方法:使用 AtomicMarkableReference
,它封装了值和一个标记,标记位用于表示是否发生了变化。
CAS 的实际应用
-
java.util.concurrent.atomic
包中的类:-
Java 提供了多种基于 CAS 的类,比如
AtomicInteger
、AtomicLong
、AtomicReference
等。 -
这些类使用 CAS 来保证原子性,例如,
AtomicInteger
类的incrementAndGet()
方法就使用了 CAS 操作。
-
-
ConcurrentHashMap
:-
ConcurrentHashMap
使用 CAS 来保证并发修改时的线程安全。在put
和remove
操作中,当多个线程尝试更新同一个槽时,ConcurrentHashMap
使用 CAS 来检查槽的值并确保正确更新。
-
-
CopyOnWriteArrayList
:-
CopyOnWriteArrayList
是一种线程安全的集合,它通过 CAS 操作来确保在更新元素时不发生冲突。
-
总结
-
CAS(Compare-And-Swap) 是一种高效的原子操作,它通过比较当前值与期望值是否一致,来决定是否进行更新。
-
ABA 问题是 CAS 操作中的一个潜在问题,指的是中间的值发生了变化,但最终值变回原来的值,导致 CAS 错误地认为值没有变化。
-
解决 ABA 问题的方法包括使用版本号(通过
AtomicStampedReference
)、时间戳、或者标记(如AtomicMarkableReference
)来检测数据变化。 -
CAS 被广泛应用于并发编程中,尤其是在 Java 的
java.util.concurrent.atomic
包中的类,如AtomicInteger
和ConcurrentHashMap
,都通过 CAS 来保证线程安全。