Java 锁介绍
乐观锁
乐观地认为并发访问不会造成数据冲突,只在更新时检查是否有冲突。乐观锁和 CAS 的关系可以用 “乐观锁是一种思想,CAS 是一种具体的实现” 来理解。
当使用 CAS 操作修改数据时,如果版本号不匹配或者其他线程已经修改了要操作的数据,CAS 会返回失败。这时候,程序可以再次尝试 CAS 操作,也就是进行自旋重试,直到 CAS 操作成功。
因此,CAS 操作已经内置了自旋重试的机制,避免了使用额外的自旋锁。
适用场景:适用于并发较低
(高并发场景每次修改了去对比,还不如让加锁阻塞排队执行)、读多写少
的场景,相信数据多数情况下不会发生冲突,只在更新时进行检查,以减少对共享资源的争用。
java 中常见悲观锁实现:可以使用 java.util. concurrent.atomic 包中的原子类,比如 AtomicInteger、AtomicLong 等,来实现 CAS 操作。
mysql 实现乐观锁:版本号、时间戳
悲观锁
悲观地认为并发访问会造成数据冲突,因此在访问共享资源之前就会进行加锁,确保同一时刻只有一个线程能够访问。
适用场景:适用于高并发
、写多
的场景,通过加锁保护共享资源,确保并发访问时不会造成数据不一致性。
java 中常见悲观锁实现:synchronized 关键字、ReentrantLock(可重入锁)
mysql 中实现悲观锁:SELECT ... FOR UPDATE
、 SELECT ... LOCK IN SHARE MODE
乐观锁 ABA 问题
假设我们有一个银行账户的并发操作场景,其中账户余额初始为 100 元。现在有两个并发线程 A 和 B 同时尝试从该账户中取出 50 元。
- 线程 A 的操作:
- 线程 A 首先读取账户余额,得到 100 元(此时 V=100,A=100)。
- 线程 A 计划执行 CAS 操作,将余额从 100 元减至 50 元(B=50)。
- 但在线程 A 等待执行 CAS 操作的过程中,线程 B 介入了。
- 线程 B 的操作:
- 线程 B 也读取账户余额,得到 100 元(此时 V、A 仍为 100,但 A 对于线程 B 来说是新的读取值)。
- 线程 B 成功执行 CAS 操作,将余额从 100 元减至 50 元(B=50,操作成功,此时 V=50)。
- 假设此时有另一个操作(如存款)将账户余额重新加至 100 元。
- 线程 A 继续执行:
- 当线程 A 继续执行其 CAS 操作时,它发现账户余额仍然是 100 元(V=100,与 A 相等),因此 CAS 操作成功执行。
- 结果是账户余额从 100 元再次减至 50 元,但实际上应该只减少一次。
ABA 问题的本质
在上述示例中,虽然账户余额的值(100 元)在线程 A 的 CAS 操作前后保持不变,但实际上它已经被线程 B 修改过(从 100 元减至 50 元,然后又加回 100 元)。这就是所谓的 ABA 问题:值 A 被修改为 B,然后又改回 A,但 CAS 操作无法检测到这种中间状态的变化。
解决 ABA 问题
解决 ABA 问题的一种常见方法是使用带有版本号的原子引用类(如 Java 中的 AtomicStampedReference)。这类原子引用类在存储值时,还会附带一个版本号。每次值被修改时,版本号也会递增。这样,在 CAS 操作时,不仅需要比较值是否相等,还需要比较版本号是否一致。如果版本号不一致,则说明该值在中间已经被其他线程修改过,此时 CAS 操作应失败。
为了解决 ABA 问题,一种常见的方法是使用版本号(Version Numbering)或者时间戳(Timestamp)来标记共享变量的变化次数,每次修改共享变量时都更新版本号或时间戳,这样就能够避免因为共享变量的数值相同而导致的误判。另外,Java 中的AtomicStampedReference
类可以用于解决 ABA 问题,它通过引入一个标记来区分不同的修改次数,从而避免了传统 CAS 操作可能出现的 ABA 问题。
悲观锁 和 乐观锁 比较
相对而言,悲观锁适用于高并发,乐观锁适用于低并发
为什么乐观锁适用于并发量低:因为并发量高的时候,cas 一直失败自旋没有任何意义,损耗性能,不如让 cpu 干其他的或者等待
锁升级
在 Java 中,锁升级是指在同步代码块中锁的状态发生改变的过程。这个过程包括偏向锁、轻量级锁和重量级锁三种状态的切换。锁升级是JVM自动进行管理的。
当 JVM 检测到多个线程对同步代码块的竞争时,会根据实际情况自动进行锁的升级。这种锁升级的机制是为了在多线程竞争情况下保证程序的安全和效率,以及在不同线程竞争程度下选择合适的锁状态,从而最大限度地提高并发性能。
偏向锁:
用于处理只有一个线程访问同步块的情况,减少不必要的竞争。
偏向锁需要在对象头中记录持有偏向锁的线程 ID,避免重复的 CAS 操作。
适用于只有一个线程执行同步块的情况,从而减少不必要的同步操作。不用去加锁释放锁。
轻量级锁:
适用于短时间内只有少量线程竞争同步块的情况。
使用 CAS 操作尝试获取锁,避免了传统锁(互斥锁)的性能开销。
在少量线程竞争情况下,避免了传统锁的重量级化,提高了性能。
重量级锁:
当锁存在大量的线程竞争时会升级为重量级锁,采用传统的互斥锁实现。
保证了线程间数据同步和互斥访问,但性能开销相对较大。
偏向锁升级为轻量级锁:
当多个线程访问同步块时,偏向锁会升级为轻量级锁。这时,会使用 CAS(Compare And Swap)操作来尝试获取锁,如果成功获取锁,则表示处于轻量级锁状态。
轻量级锁升级为重量级锁:
如果轻量级锁获取失败,就会升级为重量级锁。这时,会使用传统的互斥锁机制来确保线程间的互斥访问。
公平锁
公平锁则按照请求锁的顺序来获取锁,不允许插队,即等待时间最长的线程会优先获得锁。
非公平锁
非公平锁允许抢占,即允许在等待队列中的线程随机获取锁,synchronized 关键字是非公平锁
在 Java 中,ReentrantLock 是可重入锁的一种实现,通过使用 ReentrantLock 类,可以根据构造函数的不同参数选择是公平锁还是非公平锁。例如:
1 | ReentrantLock fairLock = new ReentrantLock(true); // 创建一个公平锁 |
可重入锁:
-—-
可重入锁是一种允许同一个线程多次获取同一把锁的锁。当线程第一次获取锁后,再次尝试获取该锁时,也会成功获取而不会被阻塞,这样可以避免死锁情况的发生。Java 中的 ReentrantLock 、synchronized 就是一种可重入锁的实现。
当一个线程持有某个对象的锁时,并再次进入同步方法或同步块时,它可以重复获得这个对象的锁。这种情况被称为可重入锁。以下是一个简单的Java示例,演示一个线程重复获得已经持有的锁的情况。
1 | public class ReentrantLockExample(){ |
在这个示例中, dosomething 方法和 doAnotherThing 方法都是同步方法,因此它们使用的是同一个对象锁。当 dosomething 方法调用 doAnotherThing 方法时,即便同一个线程已经持有了此对象锁,它仍然可以重复获得这个对象的锁,而不会被阻塞。这就是可重入锁的特性。
这种情况下会输出以下内容:
1 | I am in doSomething method |
从这个示例中可以看出,线程在持有对象锁的情况下可以重复进入同步方法,而不会导致死锁或阻塞。
不可重入锁:
-—–
不可重入锁是一种不允许同一个线程多次获取同一把锁的锁。如果一个线程已经获取了该锁,再次尝试获取时会被阻塞,即使是同一个线程也不能再次获取这把锁,这可能会导致死锁情况。
在 Java 中,可以通过自定义一个不可重入的锁来模拟不可重入锁的行为。下面是一个简单的示例:
1 | public class NonReentrantLock(){ |
在这个示例中,我们创建了一个简单的非可重入锁 NonReentrantLock
,其中 lock 方法尝试获取锁,如果锁已经被持有,它会进入等待状态。unlock
方法用于释放锁。
现在我们可以使用这个自定义的不可重入锁示例来展示不可重入锁的行为:
1 | public class NonReentrantLockExample(){ |
在这个示例中,由于自定义的锁是不可重入的,第二次尝试获取同一个锁的行为会导致程序死锁。
单机锁:
-—
单机锁是指在单个计算机或单个进程中控制对共享资源的访问的锁。常见的单机锁包括 synchronized 关键字、ReentrantLock 等。单机锁通常只在单个 JVM 内有效,不能跨越多个计算机或进程。 synchronized 关键字或 ReentrantLock 都是单机锁
分布式锁:
-—-
分布式锁是用于在分布式系统中控制对共享资源的访问的锁,可以跨越多个计算机或进程。分布式锁可以通过基于数据库、基于缓存(如 Redis)、基于 ZooKeeper 等方式来实现。