挑战并发:深入理解乐观锁与悲观锁,你真的了解吗?
引言:当你在简历上写了多线程安全之类的知识,那么乐观锁和悲观锁便是几乎必问的一个点,如果能系统地回答好这个问题,将是很大的一个加分项,本文将用图文结合并将实现过程,可能产生的问题,注意事项等等,进行详细解释。
挑战并发:深入理解乐观锁与悲观锁,你真的了解吗?
题目
挑战并发:深入理解乐观锁与悲观锁,你真的了解吗?
推荐解析
什么是悲观锁?
悲观锁总是假设出现最坏的镜框,认为共享的资源在竞态的访问时会出现问题,导致共享数据被修改,因此悲观锁会在每次获取资源操作的时候,都会上锁,以保证临界区的安全性。当其他线程想要拿到共享资源,那么就必须等待上一个拿取对象锁的持有者释放锁,只有获取到锁后才能去执行临界区内的代码,也就是串行执行,每次只能有一个线程使用,其他线程 BLOCKED(阻塞)。
用 Java 语言去实现独占锁的话,有两个选择。
1)Synchronized 关键字,JVM 级别,有锁粗化、锁消除、锁自旋、偏向锁、轻量级锁等优化,每一次 JVM 的优化直接影响 Synchronized 关键字,因此可以在不改动代码的情况下可以提升 这个接口的 QPS,实现是基于监视器 Monitor
2)ReentrantLock 可重入锁,相比于 Synchronized 是 API 级别,优化空间比较少,但支持公平锁(一般不使用公平锁,性能较低),需要手动去 Lock 和 UnLock,可以设置超时时间,可以判断锁是否被其他线程所持有,并且支持 Condition 类去实现特定的线程等待和通知,可以对某类线程进行分类等待,实现是基于 AQS 抽象队列同步器。
public void test() {
synchronized (this) {
// 同步代码
}
}
Lock lock = new ReentrantLock();
lock.lock();
try {
// 同步代码
} finally {
lock.unlock();
}
在高并发情况下,锁竞争可能造成线程阻塞,大量阻塞线程导致频繁线程的上下文切换,CPU 利用率被极大降低,并且要考虑死锁问题,从而要了解死锁预防,死锁避免,死锁检测,死锁解决,银行家算法等知识。
什么是乐观锁?
乐观锁总是假设最好的情况,认为共享资源在访问的时候不会出现问题,线程可以一直执行,不需要加锁,只需要用版本号机制或者 CAS 算法去检测对应的资源有没有被其他线程修改了即可。
典型的 Java 实现
JUC 包下面的原子变量类比如 AtomicInteger,AtomicLong 等就是用乐观锁的 CAS 方式去实现的。
class TAtomicTestDecrement implements Runnable{
AtomicInteger atomicInteger = new AtomicInteger(20000);
@Override
public void run() {
for(int i = 0;i < 10000 ;i++){
System.out.println(atomicInteger.getAndDecrement());
}
}
public static void main(String[] args) {
TAtomicTestDecrement tAtomicTest = new TAtomicTestDecrement();
Thread t1 = new Thread(tAtomicTest);
Thread t2 = new Thread(tAtomicTest);
t1.start();
t2.start();
}
}
在高并发场景下,乐观锁不存在锁竞争,也不会出现死锁问题,性能更加优越,但如果竞争激烈,写多读少,还是建议用 Synchronized 等独占锁,因为乐观锁的自旋和重试会导致 CPU 的升高,同样也会影响性能。
如何实现乐观锁?
版本号机制
在数据表中加上一个数据版本号 Version 字段,表示数据被修改的次数,当数据被修改时,Version 值会加一,当数据要更新时,会读取 Version 值,在提交更新时,如果 Version 值和原来读取到的相等才会更新,否则会自旋重试更新操作,直到成功。
CAS 算法
CAS (Compare And Swap)比较并且交换,将预期值和要更新的变量值进行比较,只有两者相等才会进行更新。
CAS 操作是原子命令,不能被打断。
Java 的 Atomic 原子类的 CAS 底层调用的是 Unsafe 类的 Compare And Swapxxx,有四个参数,this 指的是当前对象,valueOffSet 是偏移量,Expect 是预期值,Update 是更新值。
this + valueOffset 构成对象的内存地址。
CAS 相关的实现是通过 C++ 内联汇编的形式实现的,要通过 JNT 去调用,具体实现和操作系统和 CPU 有关。
乐观锁存在的问题和解决方法
1)ABA 问题,当一个线程想要更新一个共享变量,初次读取这个值是 A,再次准备赋值时这个值依然是 A,但不能表明这个值没有被其他线程修改过,因为它可能在这段时间经过从 A 到 B 然后又被修改为 A 的两次修改操作,这就是 ABA 问题。
解决方法
在变量前面加上版本号或者时间戳,JDK 1.5 以后出现的 AtomicStampedReference 就是用来解决 ABA 问题,既要比较当前引用/值是否等于预期引用/值,并且当前的 Stamp 版本是否和预期的 Stamp 版本相等,如果两个都相等,才会用 CAS 进行更新操作。
2)循环时间长导致开销大
CAS 会用自旋操作进行重试,如果长时间不成功,那么 CPU 利用率会很低。
解决方法:JVM 使用处理器提供的 Pause 指令,延迟流水线执行命令,避免在退出循环时因内存顺序而引起 CPU 流水线被清空。
3)只能保证一个共享变量的原子操作
解决方法:JDK 1.5 提供了 AtomicReference 保证引用对象的原子性,可以将多个共享变量放入一个对象来进行 CAS 。
总结
1)如果锁竞争比较激烈,建议采用 Synchronized 关键字这种悲观锁。
2)锁竞争比较轻,而且竞争造成的等待时间较短的情况下,CAS 可以通过自旋操作去提升性能,因此选择乐观锁。
3)使用乐观锁要注意 ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。
其他补充
鱼聪明 AI 的回答:
鱼聪明 AI 地址:https://www.yucongming.com/
乐观锁是一种并发控制的方法,它假设在大多数情况下,冲突是不常见的。因此,它允许多个事务并发地访问数据,只在最后更新时检查冲突。通常使用版本号(Version Number)或时间戳(Timestamp)来实现。
悲观锁则是一种较为保守的并发控制方法,它在整个事务期间锁定数据,以防止其他事务的干扰。在悲观锁的情况下,一个事务获取锁后,其他事务必须等待释放锁后才能访问数据。
如何实现乐观锁
乐观锁的实现通常依赖于版本号或时间戳。在数据库中,可以通过以下步骤实现乐观锁:
添加版本号字段: 在数据表中添加一个用于存储版本号的字段,通常命名为 version
。读取数据时获取版本号: 当读取数据时,获取当前数据的版本号。 更新数据时检查版本号: 在更新数据时,比较数据库中的版本号和读取时获取的版本号是否一致。如果一致,则允许更新;否则,表示数据已经被其他事务修改,需要处理冲突。
乐观锁存在的问题和解决方法
问题
并发更新可能导致冲突: 如果多个事务同时尝试更新同一数据,可能会导致冲突。
解决方法
重试机制: 当发现版本号不一致时,可以选择重新读取数据、重新计算,并再次尝试更新,直至成功。 冲突处理: 可以采用一些冲突解决策略,如合并变更、手动解决冲突等。
总结
乐观锁: 适用于并发冲突较少的情况,通过版本号或时间戳实现,允许多个事务并发访问。 悲观锁: 适用于并发冲突较多的情况,通过在事务期间锁定数据来确保一次只有一个事务能够访问。 乐观锁适用于高并发、冲突较少的场景,而悲观锁适用于对数据一致性要求较高、冲突较多的场景。选择锁的类型要根据具体业务场景和性能需求来决定。
推荐文章和书籍
文章:https://zhuanlan.zhihu.com/p/71156910
书籍:《Java 并发编程核心 78 讲》
欢迎交流
在阅读完本文之后,你应该对乐观锁和悲观锁的概念和如何实现以及应该注意的事项有了一定的了解,相信掌握了这些知识的你一定能和面试官进行一番交流,在文末我将留下三个问题,欢迎小伙伴在评论区交流见解,一起进步!
1)如果多个事务同时读取相同的数据并且都尝试进行更新,如何出来由于版本号冲突而导致的并发问题?
2)在高并发环境下,如何避免过多的锁竞争,以提高系统的并发性能?
3)在实际应用中,如何选择乐观锁或悲观锁,以满足特定业务场景的需求?有什么因素需要考虑,例如数据一致性、并发度、系统性能等?