【JavaEE】锁策略

文章介绍了乐观锁和悲观锁的概念及其应用场景,例如乐观锁假设数据冲突少,在更新时检查冲突;悲观锁则预先假设冲突多,读取数据时加锁。同时阐述了读写锁的作用,允许多个读取者并发,但写入者与其他所有线程互斥。此外,讨论了自旋锁和轻量级锁、重量级锁的区别,自旋锁在锁被快速释放时更为高效,而synchronized在不同场景下使用不同的锁策略。
摘要由CSDN通过智能技术生成

🌷 乐观锁&悲观锁

乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

举个例子: 同学 A 和 同学 B 想请教老师一个问题.

同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

这两种思路不能说谁优谁劣, 而是看当前的场景是否合适. 如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致
“白跑很多趟”, 耗费额外的资源. 如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.

Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
就好比同学 C 开始认为 “老师比较闲的”, 问问题都会直接去找老师.
但是直接来找两次老师之后, 发现老师都挺忙的, 于是下次再来问问题, 就先发个消息问问老师忙不忙, 再决定是否来问问题.
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.


🌷 读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数readers 读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

  • 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
  • 两个线程都要写一个数据, 有线程安全问题.
  • 一个线程读另外一个线程写, 也有线程安全问题.

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了lock / unlock方法进行加锁解锁.
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进 行加锁解锁.

其中,

  • 读加锁和读加锁之间, 不互斥.
  • 写加锁和写加锁之间, 互斥.
  • 读加锁和写加锁之间, 互斥.

注意, 只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.
因此尽可能减少 “互斥” 的机会, 就是提高效率的重要途径
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的).
Synchronized不是读写锁.


🌷 重量级锁 vs 轻量级锁

锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

  • CPU 提供了 “原子操作指令”.
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronizedReentrantLock 等关键字和类.

image.png
注意, synchronized并不仅仅是对 mutex 进行封装, 在 synchronized内部还做了很多其他的工作
**重量级锁: **加锁机制重度依赖了 OS 提供了 mutex

  • 大量的内核态用户态切换
  • 很容易引发线程的调度

这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 “沧海桑田”.
**轻量级锁: **加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.

  • 少量的内核态用户态切换.
  • 不太容易引发线程调度.

理解用户态 vs 内核态
想象去银行办业务.
在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的.
在窗口内, 工作人员做, 这是内核态. 内核态的时间成本是不太可控的.
如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.

synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.


🌷 自旋锁(Spin Lock)& 挂起等待锁

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度. 但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个 时候就可以使用自旋锁来处理这样的问题.
**自旋锁伪代码: **

while (抢锁(lock) == 失败) {}

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
一旦锁被其他线程释放, 就能第一时间获取到锁.
自旋锁是一种典型的 轻量级锁 的实现方式.

  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
自旋锁: 不停的询问资源是否被释放 ,如果释放了第一时间可以获取锁资源
挂起等待锁:等待通知之后再去竞争锁,并不会第一时间获取到锁资源


🌷 公平锁 vs 非公平锁

假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?

公平锁:遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁:不遵守 “先来后到”. B 和 C 都有可能获取到锁.
注意:

  • 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
  • 公平锁和非公平锁没有好坏之分, 关键还是看适用场景

synchronized 是非公平锁.


🌷 可重入锁 vs 不可重入锁

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。
而 Linux 系统提供的 mutex 是不可重入锁.


🌷 相关面试题

1) 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.

2) 介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 “频繁读, 不频繁写” 的场景中.

3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁:

  • 优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
  • 缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源.

4)synchronized实现了哪些锁策略

  1. 既是乐观锁与是悲观锁
  2. 既是轻量级锁与重量级锁
    轻量级锁是基于自旋锁实现的,重量级锁是基于挂起等待锁实现的
  3. 是普通互斥锁
  4. 既是自旋锁又是挂起等待锁
  5. 是可重入锁
  6. 是非公平锁
参考资源链接:[JavaEE多线程进阶:乐观与悲观策略解析](https://wenku.csdn.net/doc/2yo700n22w?utm_source=wenku_answer2doc_content) 在JavaEE开发中,选择合适的策略对于确保应用的性能和稳定性至关重要。乐观和悲观各自有其适用场景,而CAS(Compare and Swap)机制则提供了一种无并发控制的方式。 乐观适用于读多写少的场景,它假设在多数情况下多个线程不会同时修改同一数据。乐观通常通过版本号或者时间戳等手段来实现,在更新数据之前检查数据是否被其他线程修改过。如果版本号未变,更新成功;如果版本号变化,说明数据已被其他线程修改,需要重新读取数据并再次尝试更新。这种方式减少了的开销,但在冲突较多的场景下可能会导致大量重试。 悲观适用于写多读少的场景,它通过在数据访问前加来保证数据的一致性。在悲观策略下,数据在被访问时会加上排他,直到事务结束才释放。这可以有效避免数据的并发冲突,但增加了的开销,可能导致较高的阻塞和资源消耗。 CAS机制是一种用于实现无并发控制的技术。它通过一个原子操作检查内存位置的值是否与预期值一致,如果一致则将新值写入,否则不做任何操作。CAS避免了传统导致的线程挂起和唤醒开销,但在高竞争的环境下,连续的失败会导致大量的CPU资源消耗,即所谓的ABA问题。 在实际开发中,选择策略需要考虑业务的具体需求和系统的设计。如果系统对延迟敏感,或者读操作远多于写操作,乐观可能更为合适。反之,如果系统中写操作频繁,或者对数据一致性要求非常高,悲观可能是更好的选择。CAS机制可以在某些情况下作为补充,用于实现特定的数据结构或算法,减少的使用。 为了更深入地理解和应用这些策略和并发控制机制,推荐阅读《JavaEE多线程进阶:乐观与悲观策略解析》。该资料详细讲解了乐观与悲观的原理和使用场景,并结合CAS机制探讨了并发控制的高级应用。通过学习这些知识,开发者能够更好地设计和优化JavaEE中的多线程应用,提升系统的性能和可靠性。 参考资源链接:[JavaEE多线程进阶:乐观与悲观策略解析](https://wenku.csdn.net/doc/2yo700n22w?utm_source=wenku_answer2doc_content)
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值