- 👑专栏内容:Java
- ⛪个人主页:子夜的星的主页
- 💕座右铭:前路未远,步履不停
目录
- 一、乐观锁 vs 悲观锁
- 1、乐观锁
- 2、悲观锁
- 二、重量级锁 vs 轻量级锁
- 1、重量级锁
- 2、轻量级锁
- 3、理解用户态 vs 内核态
- 三、自旋锁 vs 挂起等待锁
- 1、自旋锁
- 2、挂起等待锁
- 3、总结
- 四、读写锁 vs 互斥锁
- 1、读写锁
- 2、互斥锁
- 五、公平锁 vs 非公平锁
- 1、公平锁
- 2、非公平锁
- 六、可重入锁 vs 不可重入锁
- 1、可重入锁
- 2、不可重入锁
- 七、常见问题
- 1、如何理解乐观锁和悲观锁,具体怎么实现呢?
- 2、介绍下读写锁?
- 3、`synchronized` 是可重入锁么?
- 4、什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
一、乐观锁 vs 悲观锁
乐观锁和悲观锁是两种常见的并发控制策略,用于处理多线程环境下的数据一致性和同步问题。它们从不同的角度出发,针对锁冲突的可能性采取不同的预防措施。Synchronized
初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。
1、乐观锁
乐观锁基于这样一个假设:在大多数情况下,锁冲突是不会发生的。因此,它允许多个线程在没有直接锁定资源的情况下并发访问数据,仅在数据提交更新时检查是否存在冲突。如果发现数据在读取后被其他线程修改,操作会被回滚,要求重试。乐观锁的实现通常依赖于数据版本号或时间戳。这种机制适合读操作频繁而写操作相对较少的场景,可以减少锁的开销,提高系统的并发性能。
2、悲观锁
悲观锁则是基于一个相反的假设:认为冲突在多线程访问中是常态,因此在操作数据前,会先尝试锁定资源,确保一次只有一个线程能操作数据。这种策略通过直接避免并发访问来防止冲突,直到当前持有锁的线程完成操作并释放锁后,其他线程才能访问数据。悲观锁适用于写操作频繁的场景,虽然它可能引入更高的等待时间和系统开销,但可以有效保障数据的一致性和完整性。
二、重量级锁 vs 轻量级锁
锁的核心特性”原子性“,这样的机制追根溯源是 CPU 这样的硬件设备提供的。
- CPU 提供了 “原子操作指令”。
- 操作系统基于 CPU 的原子指令,实现了
mutex
互斥锁。 - JVM 基于操作系统提供的互斥锁, 实现了
synchronized
和ReentrantLock
等关键字和类。
注意, synchronized
并不仅仅是对 mutex
进行封装,在 synchronized
内部还做了很多其他的工作
1、重量级锁
重量级锁:加锁机制重度依赖了操作系统提供了mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
2、轻量级锁
轻量级锁:加锁机制尽可能不使用 mutex
,而是尽量在用户态代码完成。实在搞不定了,再使用 mutex
。
- 少量的内核态用户态切换
- 不太容易引发线程调度
3、理解用户态 vs 内核态
用户态:
- 相当于你在银行窗口外办理业务,所有操作由你自己完成。
- 你需要填写表格、准备材料、排队等候,所有流程和时间成本都在你的掌控之中。
- 办理简单业务时,用户态效率高,因为你无需等待他人协助。
内核态:
- 相当于你在银行窗口内办理业务,由工作人员为你服务。
- 你需要向工作人员说明需求,等待他们处理,并可能需要反复沟通确认。
- 办理复杂业务时,内核态效率更高,因为专业的工作人员可以帮你处理繁琐的事务。
效率对比:
- 用户态的操作时间成本可控,因为你掌控着所有环节。
- 内核态的操作时间成本不可控,因为你需要等待工作人员处理,并可能受其他因素影响。
过度切换的代价:
- 如果你在办理业务过程中,频繁地在窗口外和窗口内之间切换,例如需要反复与工作人员沟通、重新排队等,那么效率会大幅降低。
- 在程序运行中,如果频繁地在用户态和内核态之间切换,也会导致效率降低,因为切换本身需要消耗时间和资源。
三、自旋锁 vs 挂起等待锁
1、自旋锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。
没必要就放弃 CPU,这个时候就可以使用自旋锁来处理这样的问题。synchronized
中的轻量级锁策略大概率就是通过自旋锁的方式实现的。
自旋锁伪代码:while (抢锁(lock) == 失败) {}
如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。
自旋锁是一种典型的 轻量级锁 的实现方式:
自旋锁的优势: 没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。
自旋锁的缺点: 如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源。(而挂起等待的时候是不消耗 CPU)
2、挂起等待锁
挂起等待锁是一种重量级锁的实现方式,它与自旋锁相比,在锁竞争激烈的情况下,能够更加有效地避免CPU资源浪费,但同时也带来了线程调度的开销。
工作原理: 当线程尝试获取锁时,如果锁已经被其他线程持有,则当前线程会进入挂起状态,放弃CPU资源,并等待锁被释放。当锁被释放后,操作系统会唤醒所有处于挂起状态的线程,并重新进行锁竞争。
挂起等待锁伪代码:
if (抢锁(lock) == 失败) {
挂起线程;
等待锁被释放;
}
// 获取到锁
挂起等待锁的优势:
- 在锁竞争激烈的情况下,能够有效避免CPU资源浪费。
- 线程不会一直占用CPU资源,提高了系统的整体效率。
挂起等待锁的劣势:
- 涉及线程阻塞和调度,带来了额外的开销。
- 在锁竞争不激烈的情况下,性能可能不如自旋锁。
3、总结
特性 | 自旋锁 | 挂起等待锁 |
---|---|---|
加锁方式 | 用户态,自旋循环 | 内核态,系统锁机制 |
资源消耗 | 低 (锁空闲时) / 高 (锁竞争激烈时) | 低 (锁竞争激烈时) |
适用场景 | 竞争不激烈 | 竞争激烈 |
性能表现 | 快速灵动 | 稳定可靠 |
四、读写锁 vs 互斥锁
1、读写锁
读写锁是一种特殊的锁机制,它针对读写操作进行区分,允许多个线程同时进行读操作,但只能有一个线程进行写操作,从而提高并发效率。
读写锁就是把读操作和写操作区分对待。Java 标准库提供了 ReentrantReadWriteLock
类,实现了读写锁。
ReentrantReadWriteLock.ReadLock
类表示一个读锁。这个对象提供了lock / unlock
方法进行加锁解锁。ReentrantReadWriteLock.WriteLock
类表示一个写锁。这个对象也提供了lock / unlock
方法进行加锁解锁。
其中:
- 读读不互斥:多个线程可以同时获取读锁,进行读操作。
- 写写互斥:多个线程不能同时获取写锁,进行写操作。
- 读写互斥:读线程和写线程不能同时获取锁。
读写锁的优势:
- 在读操作远多于写操作的情况下,读写锁可以大幅提高并发效率。
- 避免了互斥锁过度锁定的问题,提高了程序的整体性能。
读写锁的应用场景:
- 缓存系统:读操作远多于写操作,使用读写锁可以提高缓存命中率。
- 数据库系统:读操作远多于写操作,使用读写锁可以提高数据库的并发能力。
- 配置文件:读操作远多于写操作,使用读写锁可以提高配置文件的读取效率。
2、互斥锁
互斥锁则是传统的锁机制,它不允许任何线程同时获取锁,无论读写操作。
五、公平锁 vs 非公平锁
公平锁和非公平锁也是两种常见的锁机制,它们在锁的分配策略上存在差异,从而影响程序的运行效率和公平性。
下面以追女神为例:
1、公平锁
公平锁是线程按照申请锁的顺序获取锁,就像排队一样,先来后到。即使线程在竞争锁时处于休眠状态,也不会影响其获取锁的机会。可以保证每个线程都有平等的机会获取锁,避免线程饥饿问题。
当女神和前任分手之后,先来追女神的男生上位,这就是公平锁。
2、非公平锁
如果是女神不按先后顺序挑一个自己看的顺眼的,就是非公平锁。synchronized
是非公平锁。
六、可重入锁 vs 不可重入锁
1、可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
可重入锁允许同一个线程在递归调用过程中多次获取锁,而不会发生阻塞。就像一个人拿着自己家的钥匙,可以多次打开自己的房门一样。Java 中的 ReentrantLock
类、synchronized
关键字锁都是可重入锁。
理解可重入锁:
- 可重入锁通过计数器来实现,记录当前线程获取锁的次数。
- 当线程第一次获取锁时,计数器加 1;
- 当线程再次获取锁时,计数器再次加 1;
- 当线程释放锁时,计数器减 1;
- 只有当计数器为 0 时,锁才会被释放,其他线程才能获取锁。
可重入锁的优势:
- 避免了递归调用时死锁的发生。
- 提高了代码的简洁性。
2、不可重入锁
不可重入锁不允许同一个线程在递归调用过程中再次获取锁,否则会发生阻塞。就像一个人拿着别人的钥匙,只能打开一次门,再次尝试打开门时会因为钥匙不匹配而被阻拦。Linux 系统提供的 mutex
锁是不可重入锁。
理解不可重入锁:
- 不可重入锁没有计数器,只记录锁的持有者。
- 当线程获取锁后,其他线程无法再次获取锁,直到该线程释放锁。
七、常见问题
1、如何理解乐观锁和悲观锁,具体怎么实现呢?
锁类型 | 特点 | 实现方式 |
---|---|---|
悲观锁 | 认为多个线程访问同一共享变量冲突概率大,每次都会加锁 | 借助操作系统提供的锁机制,如 mutex,获取锁后操作数据 |
乐观锁 | 认为冲突概率不大,不加锁直接尝试访问数据,识别访问冲突 | 引入版本号,通过版本号判断数据访问是否冲突 |
悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加
锁;乐观锁认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数据。在访问的同时识别当前的数据是否出现访问冲突。
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex
),获取到锁再操作数据,获取不到锁就等待。乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突。
2、介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁。
- 读锁和读锁之间不互斥。
- 写锁和写锁之间互斥。
- 写锁和读锁之间互斥。
读写锁最主要用在 “频繁读,不频繁写” 的场景中。
3、synchronized
是可重入锁么?
是可重入锁,可重入锁指的就是连续两次加锁不会导致死锁。
实现的方式是在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数)。如果发现当前加锁
的线程就是持有锁的线程,则直接计数自增。
4、什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败,立即再尝试获取锁,无限循环直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。
相比于挂起等待锁:
- 优点:没有放弃 CPU 资源,一旦锁被释放就能第一时间获取到锁,更高效。在锁持有时间比较短的场景下非常有用。
- 缺点:如果锁的持有时间较长。就会浪费 CPU 资源。