常见锁策略

锁策略

锁策略,即在加锁/解锁/锁冲突的时候会怎么做
注意: 接下来讲解的锁策略不仅仅是局限于 Java . 任何和 “锁” 相关的话题, 都可能会涉及到以下内容.

乐观锁 vs 悲观锁

  • 加锁的时候,预测当前锁冲突的概率小,后续要做的工作往往会更少,加锁开销就更小 => 乐观锁
  • 加锁的时候,预测当前锁冲突的概率大,后续要做的工作往往会更多,加锁开销就更大 => 悲观锁

Java的synchronized支持自适应,一开始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.

C++的std::mutex 属于悲观锁

乐观锁做的工作少,消耗资源多(频繁尝试获取锁)但效率高,悲观锁反之

重量级锁 vs 轻量级锁

乐观锁往往就是轻量级锁(加锁过程做的事少)
悲观锁往往就是重量级锁(加锁过程做的事多)

这两组概念可能会被混着用

自旋锁 vs 挂起等待锁

自旋锁是轻量级锁的一种典型实现方式,基于CAS机制实现

伪代码:

 	void lock() {
        while (true) {
            if(锁被占用) continue;
            获取到锁
            break;
        }
    }

如果获取锁失败, 就会立即再次尝试获取锁, 无限循环, 直到获取到锁为止。⼀旦锁被其他线程释放, 就能第⼀时间获取到锁(synchronized是自适应的自旋锁,自旋不会⼀直持续, 而是达到⼀定的时间/重试次数, 就不再自旋了,也就是所谓的 “自适应”)

一直自旋就会导致CPU在空转,忙等,消耗了更多的CPU资源,但锁一旦被释放就能第一时间拿到锁,拿锁的速度更快。

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

挂起等待锁是重量级锁的一种典型实现方式,通过内核实现,调用系统API

借助系统中的线程调度机制,当一个线程尝试加锁但锁被占用了的时候,就会让当前这个尝试加锁的线程阻塞,此时这个线程就不会参与调度了。直到锁对象被释放才唤醒这个线程,重新尝试获取锁。拿锁的速度更慢,但节省CPU资源

synchronized 轻量级锁的部分基于自旋锁实现; 重量级锁的部分,基于挂起等待锁实现

可重入锁 vs 不可重入锁

  • synchronized就是可重入锁。允许同⼀个线程多次获取同⼀把锁

⼀个递归函数里有加锁操作,递归过程中这个锁不会自己阻塞自己,那么这个锁就是可重人锁(因为这个原因可重入锁也叫做递归锁)。

  • C++的 std::mutex就是不可重入锁。一个线程针对同一把锁连续加锁两次,会死锁

公平锁 vs 非公平锁

  • 公平锁:严格按照先来后到的顺序来获取锁,也就是哪个线程等待的时间长,哪个线程就先拿到锁
  • 非公平锁:各凭本事,随机获取到锁,和线程的等待时间无关了

注意:

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

synchronized属于非公平锁

互斥锁 vs 读写锁

synchronized是互斥锁,有加锁和解锁两个操作。而读写锁有加读锁,加写锁,解锁三个操作。

Java的读写锁是这样设定的:

  1. 读锁和读锁之间不会产生互斥
  2. 写锁和写锁之间会产生互斥
  3. 读锁和写锁之间会产生互斥

读写锁有利于降低锁冲突的概率,提高并发能力

和MySQL中的事务不一样

  • 给读操作加锁:读的时候不能写
  • 给写操作加锁:写的时候不能读

降低了并发能力

读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的)

相关面试题

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

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

介绍下读写锁

读写锁就是把读操作和写操作分别进行加锁.

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

读写锁最主要用在 “频繁读, 不频繁写” 的场景中.

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

如果获取锁失败, 就会立即再尝试获取锁, 无限循环, 直到获取到锁为止. ⼀旦锁被其他线程释放, 就能第⼀时间获取到锁.

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

synchronized 是可重入锁么?

是可重入锁.

可重入锁指的就是连续两次针对同一个对象加锁不会导致死锁.

可重入锁实现的方式是在锁中记录该锁持有的线程身份, 以及⼀个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增

synchronized的优化策略

基本特点

结合上面的锁策略, 我们就可以总结出, synchronized 具有以下特性(只考虑 JDK 1.8):

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.

  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.

  3. 是⼀种不公平锁

  4. 是⼀种可重入锁

  5. 是互斥锁,不是读写锁

锁升级的过程:

未加锁的状态(无锁) => 偏向锁 => 轻量级锁 => 重量级锁

在这里插入图片描述
上述synchronized的升级过程,针对一个锁对象来说是不可逆的(只能升级不能降级),一旦升级到了重量级锁,不会回退到轻量级锁(目前是这样的)

偏向锁

首次使用synchronized对对象进行加锁的时候不是真的加锁,而只是做一个"标记",记录这个锁属于哪个线程(非常轻量非常快,几乎没有开销),如果没有别的线程尝试对这个对象加锁,就只用一直保持这个状态直到解锁

偏向锁的解锁也就是修改一下"标记",也几乎没有开销
如果在偏向锁的状态下,别的线程尝试获取锁对象,立刻把偏向锁升级成轻量级锁,会产生互斥了

锁消除

代码里有加锁操作,编译器就会对当前的代码进行判定,看这个地方是不是真的需要加锁,如果不需要加锁,就会把加锁操作给优化掉
最典型的是只在一个线程里面使用synchronized
由于编译器优化需要保证优化前后的逻辑等价,所以起到的作用有限

锁粗化

加锁范围内包含的代码越多,就认为是锁的粒度越粗;反之,锁的粒度就越细

有些逻辑中,需要频繁加锁解锁,编译器就会把多个细粒度的锁合并成一个粗粒度的锁

举个栗子理解锁粗化:

领导给下属交代工作任务:

  • 方式一:

打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话.

  • 方式二:

打电话, 交代任务1, 任务2, 任务3, 挂电话

显然, 方式二是更高效的方案

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值