乐观锁和悲观锁
悲观锁:顾名思义就是比较悲观的锁策略,在每次拿数据时总感觉会有其他人会对数据进行修改导致自己拿到一个无效数据,所以每次拿数据时都会进行加锁,这样别人想修改就会阻塞等待。
乐观锁:认为数据一般情况下不会发生并发突变,在数据进行提交更新的时候,才会对数据是否产生并发冲突进行检测,如果发现并发冲突了,就返回用户错误的信息,让用户决定如何去做。
举个例子:
假如有两个朋友要找你出去玩,友人A比较悲观,认为你可能比较忙,会先发个消息“今天下午三点有空吗,出来玩”(将相当于要给你加锁),如果你有空他就可以来找你,如果没有他就会等待你有空之后再来找你。
友人B比较乐观,认为你比较闲,直接来找你玩,如果这时你刚好有空你就可以直接跟他出去玩(没有加锁,直接访问)如果你这时比较忙,那他也不会打扰你,就会直接走下次再来(虽然没有加锁,但是能识别数据访问冲突)
所谓的乐观和悲观,是对后续锁冲突是否激烈的一种预测,如果预测接下来锁冲突概率不大,就可以少做一些工作,就是乐观锁。如果预测接下来锁冲突的概率很大,就应该多做一些工作,就称之为悲观锁 。
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
乐观锁的一个重要的功能就是检测访问数据是否发生冲突。我们可以引入"版本号"
规定:只有提交更新的版本号必须大于当前的版本号才可以完成提交(初始版本号version=1)
假如现在A有100块钱,但是此时他进行了两个操作,线程一是减50,线程二是减30.
先从主内存读出数据(余额:100 version = 1)
然后线程一对数据进行修改(余额:100-50),版本号自增加一(version=2),线程二对数据进行修改(余额:100-30),版本号自增加一(version=2)
接着线程一将数据写入到内存(提交版本号大于当前版本号)
最后线程二在想将数据写入到内存因为提交版本号(version = 2)和当前版本号(version = 2)一样,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略,所以最后会提交失败。
重量级锁和轻量级锁
重量级锁:
- 加锁机制重度依赖mutex,他会导致线程在用户态和内核态之间大量的切换,很容易引发线程的调度,相对开销较大
- 当一个线程尝试获取重量级锁时,如果该锁已被其他线程持有,则当前线程就会进入阻塞状态,直到锁释放
- 这种机制确保了资源的独占性,但也带来了较大的开销,包括上下文切换和线程调度
轻量级锁:
- 相对于重量级锁而言,一般用在锁竞争较少的情况下,以提高系统性能
- 轻量级锁主要通过CAS操作来实现。当一个线程尝试获得轻量级锁时,他会利用CAS操作尝试修改对象头中的状态信息(加锁)。如果该锁没有被其他线程占有,那么CAS就会操作成功,当前线程就可以获得到该锁,如果该锁已被其他线程占有,那么就会不断执行CAS操作(自旋),直到该锁被释放。
- 这样的机制保证线程可以第一时间获得空闲的锁,但是线程不断的自旋也会占用大量cpu资源
其实重量级锁和轻量级锁和刚刚的乐观锁,悲观锁也是有关联的,一个是预测锁冲突的概率,一个是实际消耗的开销
- 轻量级锁,锁得开销比较小(主要通过CAS操作),乐观锁通常也就是一个轻量级锁
- 重量级锁,锁的开销比较大(主要涉及操作系统层面的调度和上下文切换),悲观锁通常也就是一个重量级锁
自旋锁和挂起等待锁
自旋锁:
- 原理:如果获取锁失败,立即会再次尝试获取锁,无限循环,直到获取到锁为止。第一次获取失败,第二次的尝试会在极短的时间内到来
- 优点:没有放弃CPU,不涉及线程阻塞和调度,一旦锁被释放,第一时间就可以获得锁
- 缺点:如果锁被其他线程持有的时间比较久,那么自旋锁就会持续消耗CPU资源
- synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
- 自旋锁,就属于是一种轻量级锁的典型实现。
按照之前的逻辑,当获取锁失败后就进行阻塞等待,放弃CPU,但是可能在这个过程中又有其他线程把锁占有,可能就会消耗较多时间才能获得锁。但实际上大部分情况下,只需要等待很短的时间所就会释放,没必要放弃CPU,这时就可以使用自旋锁来解决这个问题。
挂起等待锁:
- 要借用系统api来实现,一旦出现所锁竞争,就会在内核中触发一系列动作(比如让这个线程进入阻塞状态,暂时不参与CPU的调度)
- 挂起等待锁属于一种重量级锁的典型实现
公平锁和非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?
- 公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
- 非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 默认是非公平锁,如果要使用公平锁还要额外的队列进行维护,对于公平锁刚执行完的线程会回到队列末尾处
synchronized 是非公平锁
读写锁
多线程同时读取数据不会产生线程安全问题,但是同时写数据或者一个读一个写就会产生线程安全问题。如果这两种情况下都用同一种锁,就会产生性能损耗,所以就有了读写锁。
读写锁就是把读操作和写操作区分对待
读写锁的特性:
- 读读不互斥:多个线程可以同时获取读锁,因为读操作是线程安全不会修改数据
- 读写互斥:读锁和写锁不能被同时持有,有一个线程获得读锁时,其他线程不能再获得写锁;相反,当有一个线程获取写锁时,其他线程不能再获取写锁或读锁
- 写写互斥:多个线程不能同时获得写锁
简单来说就是读写锁将加锁分成了两种
- 读加锁:读的时候,能读但是不能写
- 写加锁: 写的时候,不能读,也不能写
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
读写锁适合读多写少的场景,因为如果读操作太多线程就会频繁的发生阻塞等待并发性就会下降,效率也会降低。
Synchronized 不是读写锁.
可重入锁和不可重入锁
sychronized的锁类型
- 对于乐观悲观是自适应的
- 对于重量轻量是自适应的
- 对于自旋 挂起等待是自适应的
- 不是读写锁
- 不是可重入锁
- 是非公平锁
初始情况下sychronized会预测当前锁冲突的概率,如果概率不大就会以乐观锁的模式运行(此时也是轻量级锁,基于自旋锁的方式实现),如果锁冲突情况变多sychronized就会升级成悲观锁(也就是重量级锁,基于挂起等待锁的方式实现)。
以上就是博主对锁策略知识的分享,在之后的博客中会陆续分享有关线程的其他知识,如果有不懂的或者有其他见解的欢迎在下方评论或者私信博主,也希望多多支持博主之后和博客!!🥰🥰