文章导读
引言
本篇文章接上一篇文章(一些和多线程相关的面试考点),主要讲的是多线程中一些加锁的策略和面试考点,同样的,如果读者可以认真看完和总结积累,相信对面试会有很大帮助。这一部分文字居多,读者要组织好语言以面对面试官的提问。
考点一、重量级锁 VS 轻量级锁
重量级锁与轻量级锁是并发编程中用于实现线程同步的两种重要锁机制,它们在锁的获取和释放的开销、效率以及应用场景上有所不同。
1、定义与原理
重量级锁:
- 定义:重量级锁是基于操作系统的互斥量(Mutex Lock)或信号量(Semaphore)而实现的锁,它会导致线程在用户态和内核态之间切换,相对开销较大。
- 原理:当一个线程尝试获取重量级锁时,如果该锁已被其他线程持有,则当前线程会被挂起(即进入阻塞状态),并等待锁的释放。
- 优缺点:这种机制确保了资源的独占性,但也带来了较大的开销,包括上下文切换和线程调度等。
轻量级锁:
- 定义:轻量级锁是相对于重量级锁而言的,它的设计初衷是在没有多线程竞争或竞争较少的情况下,减少重量级锁的使用以提高系统性能。
- 原理:轻量级锁主要通过CAS(Compare-and-Swap)操作来实现。当一个线程尝试获取轻量级锁时,它会利用CAS操作尝试修改对象头中的状态信息。如果该锁没有被其他线程占有,那么CAS操作就会成功,当前线程就可以获取到该锁;如果该锁已被其他线程占有,那么当前线程就会不断执行CAS操作(自旋),直到该锁被释放。
- 优缺点:这样的机制保证了当前线程能够在第一时间获取到空闲的锁,但是线程不断自旋也可能会占用了大量的CPU资源。
锁升级:
- 在锁竞争不激烈的情况下,轻量级锁可以有效提高程序的运行效率,但是如果竞争锁的线程过多或线程持有锁的时间过长时,大量的线程因为得不到锁而不断地自旋,CPU资源就会被大量的占用,程序运行效率就会大打折扣。当轻量级锁无法有效处理锁竞争时,JVM会考虑将锁升级为重量级锁,让请求锁的线程释放CPU资源,阻塞等待被唤醒(自旋状态切换成阻塞状态),这一过程就叫 锁升级。
2、主要区别
重量级锁 | 轻量级锁 | |
---|---|---|
开销 | 较大,涉及操作系统层面的调度和上下文切换 | 较小,主要通过CAS操作实现,不涉及操作系统层面的切换 |
效率 | 较低,特别是在竞争较少的情况下 | 较高,在竞争较少时能够显著提高性能 |
适用场景 | 适用于高度竞争的情况,确保资源的独占性 | 适用于竞争较少或没有竞争的情况,减少不必要的开销 |
实现方式 | 基于操作系统的互斥量或信号量实现 | 基于CAS操作实现 |
锁升级 | 不存在锁升级的概念,一旦获取就是重量级锁 | 在竞争激烈时,轻量级锁可能会升级为重量级锁 |
3、适用场景
- 轻量级锁适用场景:适用于锁竞争不激烈、锁持有时间短的场景。在这种场景下,轻量级锁能够快速响应并减少线程阻塞和上下文切换的开销。
- 重量级锁适用场景:适用于锁竞争激烈、锁持有时间长的场景。重量级锁通过阻塞竞争失败的线程来避免CPU资源的浪费,并在锁释放后唤醒阻塞的线程进行后续操作。
考点二、乐观锁 VS 悲观锁
乐观锁(Optimistic Locking)和悲观锁(Pessimistic Locking)是两种处理并发控制的策略,它们在数据库管理系统(DBMS)、分布式系统以及多用户环境中广泛应用,以确保数据的一致性和完整性。这两种策略的主要区别在于它们对数据并发访问的假设以及如何处理可能的冲突。
1、悲观锁(Pessimistic Locking)
- 假设:悲观锁假设冲突随时可能发生,因此在数据被读取时就会立即锁定,以防止其他事务对其进行修改。
- 实现方式:
- 数据库层面:通过SQL语句实现,如使用
SELECT ... FOR UPDATE
语句在数据库中锁定选定的数据行,直到当前事务结束(提交或回滚)。 - 应用层面:在应用程序中,通过某些机制(如锁对象、信号量等)在代码级别控制对共享资源的访问。
- 数据库层面:通过SQL语句实现,如使用
- 优点:能有效防止数据在读取后被其他事务修改,避免脏读、不可重复读和幻读等问题。
- 缺点:在高并发场景下,大量锁定资源可能会导致性能瓶颈,甚至死锁。
2、乐观锁(Optimistic Locking)
- 假设:乐观锁假设冲突不会频繁发生,因此在数据读取时不会加锁,而是在更新数据时通过某种机制来检查数据是否已被其他事务修改。
- 实现方式:
- 版本号(Version Number):为数据库表中的每一行数据添加一个版本号字段,每次更新数据时版本号加一。更新数据时,检查版本号是否与读取时一致,若一致则更新并增加版本号,否则放弃更新。
- 时间戳(Timestamp):与版本号类似,但使用时间戳来记录数据最后更新的时间,并在更新时进行比较。
- CAS(Compare-And-Swap):在一些高级数据库或缓存系统中,可以使用CAS操作来实现乐观锁,该操作在数据更新前会比较数据是否发生了变化。
- 优点:系统开销小,在冲突不频繁的情况下性能较好,适合读多写少的场景。
- 缺点:在冲突频繁的情况下,大量更新操作可能因版本冲突而失败,需要重试,增加了系统的复杂性和开销。
3、总结
选择乐观锁还是悲观锁,取决于具体的应用场景和性能需求。在冲突不频繁且对性能要求较高的系统中,乐观锁可能是一个更好的选择;而在冲突频繁或需要严格保证数据一致性的系统中,悲观锁可能更为合适。此外,还可以根据业务需求和数据库特性,结合使用两种锁策略来达到最佳效果。
考点三、读写锁
读写锁(Readers-Writer Lock)是一种用于并发编程中的同步机制,它允许多个线程同时读取共享资源,但在写入时需要独占访问权限。这种锁分为读锁和写锁两部分,各自具有不同的特性和使用场景。
1、读写锁的特性
- 读读不互斥:多个线程可以同时获得读锁,因为读操作通常是线程安全的,不会修改数据。
- 读写互斥:读锁和写锁不能同时被持有,即当有一个线程持有写锁时,其他线程不能获取读锁或写锁;同样,当有一个线程持有读锁时,其他线程不能获取写锁。
- 写写互斥:多个线程不能同时获得写锁,因为写操作会修改数据,需要独占访问权限。
2、读写锁的实现
在Java中,读写锁通常通过ReentrantReadWriteLock
类实现。这个类提供了读锁(ReadLock
)和写锁(WriteLock
)的获取和释放方法。
- 公平性选择:
ReentrantReadWriteLock
支持非公平性(默认)和公平性的锁获取方式。非公平锁的吞吐量较高,但可能导致线程饥饿;公平锁则按照请求锁的顺序来授予锁,但可能降低性能。 - 重入性:支持重入,即同一个线程可以多次获取读锁或写锁,避免死锁。
- 锁降级:写锁可以降级为读锁,即持有写锁的线程可以获取读锁后再释放写锁,这有助于提升并发性能。但需要注意的是,锁升级(在持有读锁的情况下获取写锁)是不被支持的。
3、读写锁的使用场景
读写锁特别适用于读多写少的场景,如缓存系统、数据库访问、文件系统操作和消息队列等。在这些场景中,读操作远远超过写操作,使用读写锁可以显著提高并发性能。
- 缓存系统:允许多个线程同时读取缓存数据,但只有一个线程可以进行缓存的更新或写入操作。
- 数据库访问:多个线程可以同时读取数据库中的数据,但只有一个线程可以进行写入操作(如更新、插入或删除记录)。
- 文件系统操作:多个线程可以同时读取文件内容,但只有一个线程可以进行写入操作(如修改文件内容或更新文件属性)。
- 消息队列:多个线程可以同时从队列中读取消息,但只有一个线程可以进行向队列中添加或删除消息的操作。
4、示例代码
以下是一个简单的Java示例,演示了如何使用ReentrantReadWriteLock
:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class DataContainer {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private int data = 0;
public void writeData(int newValue) {
lock.writeLock().lock();
try {
data = newValue;
System.out.println("写入数据: " + data);
} finally {
lock.writeLock().unlock();
}
}
public void readData() {
lock.readLock().lock();
try {
System.out.println("读取数据: " + data);
} finally {
lock.readLock().unlock();
}
}
}
在这个示例中,DataContainer
类包含了一个整数data
作为共享资源,以及一个ReentrantReadWriteLock
对象来控制对data
的访问。writeData
方法使用写锁来确保在写入data
时没有其他线程可以读取或写入它,而readData
方法则使用读锁来允许多个线程同时读取data
。
考点四、其他常见锁策略
1、自旋锁 VS 挂起等待锁
-
自旋锁:
- 定义:自旋锁是一种轻量级的锁,在尝试获取锁时,如果锁已被其他线程持有,则当前线程会不断循环检查锁的状态,而不是进入睡眠状态等待锁被释放。
- 优点:
- 响应快:由于线程在等待锁时不会进入睡眠状态,因此一旦锁被释放,线程可以立即获取锁,减少了线程唤醒和调度的时间开销。
- 避免上下文切换:自旋锁避免了线程在等待锁时的上下文切换,从而减少了系统资源的消耗。
- 缺点:
- CPU资源浪费:如果锁被持有时间较长,自旋的线程会长时间占用CPU资源,导致CPU资源的浪费。
- 适用场景有限:自旋锁适用于锁被持有时间较短的场景,如果锁被持有时间较长,使用自旋锁会导致性能下降。
-
挂起等待锁:
- 定义:挂起等待锁是一种重量级的锁机制,在尝试获取锁时,如果锁已被其他线程持有,则当前线程会被挂起(即进入等待队列),直到锁被释放后由操作系统唤醒。
- 优点:
- 避免CPU资源浪费:线程在等待锁时被挂起,不会占用CPU资源,从而避免了CPU资源的浪费。
- 适用场景广泛:挂起等待锁适用于锁被持有时间较长的场景,以及线程竞争激烈的情况。
- 缺点:
- 线程唤醒开销大:线程被唤醒时需要进行上下文切换,这会增加系统的开销。
- 可能导致死锁:在复杂的并发场景中,如果不当使用挂起等待锁,可能会导致死锁的发生。
2、公平锁 VS 非公平锁
-
公平锁(Fair Lock)
- 定义:公平锁是一种按照请求顺序授予锁的机制,即先请求锁的线程会先获得锁,后请求锁的线程会后获得锁。
- 特性:
- 避免饥饿:所有线程都有机会获得锁,不会出现某些线程长期得不到锁的情况。
- 可预测性:锁的获取是按顺序进行的,具有较好的可预测性。
- 性能开销:由于需要维护一个队列来管理等待的线程,公平锁在管理上有一定的性能开销。
- 上下文切换增加:由于公平锁可能需要频繁地切换线程,导致上下文切换的次数增加,影响性能。
-
非公平锁(Unfair Lock)
- 定义:非公平锁是一种不按照请求顺序授予锁的机制,即任何线程都有可能在任何时候获得锁,而不考虑请求顺序。
- 特性:
- 高性能:由于没有队列管理的开销,非公平锁通常性能较高,特别是在高并发场景下。
- 减少上下文切换:非公平锁可以减少线程之间的上下文切换,提升效率。
- 可能导致饥饿:某些线程可能长时间得不到锁,导致线程饥饿。
- 不可预测性:锁的获取是随机的,具有较低的可预测性。
-
应用场景与选择
- 公平锁:适用于需要保证线程获取锁的顺序和公平性的场景,如需要避免线程饥饿或保证任务处理的顺序性。
- 非公平锁:适用于对性能要求较高,且可以接受一定程度线程饥饿现象的场景。在高并发环境下,非公平锁可以提高系统的吞吐量。
3、可重入锁 VS 不可重入锁
- 可重入锁:可重入锁,也称为递归锁,是一种支持同一个线程多次获取同一把锁的锁机制。这意味着,如果一个线程已经持有某个锁,它仍然可以在其内部递归地再次获取该锁,而不会导致死锁。
- 不可重入锁:不可重入锁是一种不支持同一个线程多次获取同一把锁的锁机制。当一个线程已经持有某个不可重入锁时,如果它再次尝试获取该锁,则会被阻塞,直到当前持有锁的线程释放锁。
考点五、锁粗化
锁粗化(Lock Coarsening)是一种编译器优化技术,主要用于改进多线程程序中的锁性能。这种技术通过将多个连续的、独立的锁操作合并为一个更大的锁操作,从而减少锁的竞争次数,提高程序的执行效率。以下是对锁粗化的详细解释:
1、定义与原理
- 定义:锁粗化是将多个连续的、对同一个对象进行加锁和解锁的操作合并为一个更大的锁操作的过程。
- 原理:在多线程编程中,频繁的加锁和解锁操作会带来额外的性能开销,包括线程切换和调度等。通过将多个小锁合并为大锁,可以减少锁的获取和释放次数,从而降低这些开销,提高程序性能。
2、应用场景
锁粗化通常用于以下场景:
- 当编译器检测到代码中存在多个连续的、对同一个对象进行加锁和解锁的操作,且这些操作之间没有其他代码干扰时,会考虑进行锁粗化。
- 在Java中,锁粗化常用于优化循环体内的锁操作。如果循环体内有多个对同一对象的同步块,编译器可能会将这些同步块合并为一个大的同步块。
3、优点与注意事项
- 优点:
- 减少锁的获取和释放次数,降低线程切换和调度的开销。
- 提高程序在并发环境下的执行效率。
- 注意事项:
- 锁粗化并非总是能提高程序性能,其效果取决于具体的场景和代码实现。
- 在某些情况下,锁粗化可能会导致锁竞争加剧,因为单个线程持有锁的时间变长,其他线程需要等待更长的时间才能获得锁。
- 锁粗化是编译器或运行时系统自动完成的,开发者无需手动干预,但可以通过编写高质量的代码来配合这种优化。
4、示例
假设有以下Java代码:
public void processData(List<Data> dataList) {
for (Data data : dataList) {
synchronized (this) {
// 处理数据的代码块1
}
// 一些无关的代码
synchronized (this) {
// 处理数据的代码块2
}
}
}
在这个例子中,每个循环迭代中都对this
对象进行了两次加锁和解锁操作。编译器可能会将这些连续的锁操作合并为一个大的锁操作,从而优化代码性能。
考点六、锁消除
锁消除(Lock Elimination)是一种用于优化多线程程序中锁性能的技术,主要通过编译器或运行时系统在代码优化阶段,通过静态分析或动态优化检测到某些情况下不需要进行同步的代码块,并将其对应的锁操作去除。以下是关于锁消除的详细解释:
1、定义
锁消除是编译器或运行时系统在代码优化阶段,识别并消除不必要的锁操作的一种优化技术。其目标是通过减少不必要的同步操作来提高程序的性能。
2、作用
- 提高性能:通过消除不必要的锁操作,减少了线程之间的同步开销,提高了程序的执行效率。
- 减少资源消耗:锁的创建、销毁和同步操作都需要消耗系统资源,锁消除有助于减少这些资源的消耗。
3、应用场景
锁消除通常应用于以下场景:
- 当一个锁只在单线程中使用时,该锁可以被消除,因为不存在多线程竞争的情况。
- 当一个共享变量在程序中只被读取而不被修改时,对该变量的访问不需要加锁,因此可以消除相关的锁操作。
4、示例
假设有以下Java代码片段:
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public void process() {
Counter counter = new Counter();
for (int i = 0; i < 1000000; i++) {
counter.increment();
}
}
}
在这个例子中,increment
方法被synchronized
关键字修饰,意味着每次调用时都会进行加锁和解锁操作。然而,如果编译器或运行时系统通过静态分析发现Counter
对象在process
方法内部被创建且不会被外部线程访问,那么它就可以推断出increment
方法中的锁操作是不必要的,从而进行锁消除。
5、实现方式
锁消除的实现主要依赖于编译器或运行时系统的优化能力。编译器通过静态分析代码,识别出不需要进行同步的代码块,并在优化阶段将其中的锁操作去除。运行时系统则可能通过动态监控程序的运行情况,收集相关信息以辅助优化决策。
6、注意事项
- 锁消除可能会引入潜在的并发问题,因此在进行锁消除时需要谨慎,确保消除的锁确实不会引起并发访问冲突。
- 锁消除一般适用于具有良好的代码结构和线程安全性的程序。对于复杂的程序或存在线程安全问题的程序,可能不适合进行锁消除。
考点七、synchronized 中的锁机制
1、加锁过程
synchronized 关键字在Java中被JVM划分成4个状态:无锁,偏向锁,轻量级锁,重量级锁。这4个状态其实就是锁升级的过程,且该过程是不可逆的,如无法从重量级锁转变成轻量级锁,但可以从轻量级锁转变成重量级锁。
- 无锁状态:初始时默认 synchronized 修饰的对象是不加锁的(乐观锁特性),即认为不存在资源竞争。
- 偏向锁:当发现存在资源竞争时,synchronized 会给对象头加上一层 ”偏向锁标记“,记录这个锁属于哪个线程(此时并没有真正加锁),如果后续没有其他线程来竞争资源,那么就不用加锁,反之就会从偏向锁状态进入轻量级锁。
- 轻量级锁:随着锁的竞争加剧,线程就会进入轻量级锁的状态,占用CPU资源,不断自旋直到加锁成功。
- 重量级锁:当锁竞争达到一定程度,轻量级锁无法处理时,synchronized 会使线程进入重量级锁状态,请求锁的线程阻塞等待,释放CPU资源,直到锁被释放后才被唤醒。
2、特性
在以上加锁过程中可以总结出以下 synchronized 的特性:
- 既是乐观锁,也是悲观锁
- 既是轻量级锁,也是重量级锁
- 是可重入锁
- 是非公平锁
- 不是读写锁
3、ReentrantLock 类 与 synchronized 关键字的区别
ReentranLock 是可重⼊互斥锁,和synchronized定位类似,都是⽤来实现互斥效果,保证线程安全。
区别:
-
ReentrantLock 类有独立的加锁(lock),超时加锁(trylock,尝试加锁一段时间后得不到锁就放弃)和解锁(unlock)操作,加锁解锁需要手动设置,而 synchronized 关键字是全自动的。
-
synchronized是非公平锁,ReentrantLock默认是⾮公平锁,但是可以通过构造方法传入⼀个 true 开启公平锁模式。
// ReentrantLock 的构造⽅法 public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
-
更强⼤的唤醒机制。synchronized 是通过 Object 的 wait/notify 实现等待-唤醒。每次唤醒的是⼀个随机等待的线程。ReentrantLock 搭配 Condition类实现等待-唤醒,可以更精确控制唤醒某个指定的线程。
总结:
在锁竞争不太激烈的情况下使用 synchronized 关键字可以提高效率,但是在锁竞争激烈的情况下,使用ReentrantLock 可以更加灵活的调整加锁行为,防止因死等而拖慢程序运行效率。
八、相关面试题
还是一样,相关面试题不会给出具体答案,需要读者自己总结。
- 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
- 介绍下读写锁?
- 什么是⾃旋锁,为什么要使⽤⾃旋锁策略呢,缺点是什么?
- 什么是偏向锁?
- synchronized 实现原理是什么?