如何根据不同的业务场景选择合适的锁?

326 阅读6分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

前言

在我们平时编程的过程中遇到的锁其实有很多,我这边按照Java锁,分布式锁和数据库锁三个部分来分别说说在不同的业务场景下如何选择合适的锁。

1. Java锁

Java锁,为什么需要java锁呢?Java锁其实主要是指JVM级别的锁,前面Java并发编程我们介绍了,Java并发面临的三大难题,如何保证原子性,可见性和有序性, JVM锁就是保证线程安全的。

image.png 上面的图截取的是 不可不说的Java“锁”事 总结的比较全面了。

1.1 悲观锁VS乐观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。

  • 乐观锁,常见的CAS, JUC并发编程里面大量的运用了CAS。
  • 悲观锁, 比如java的synchronized关键字和Lock实现类都是悲观锁。

1.2 自旋锁 VS 适应性自旋锁

为什么需要自旋锁?

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁

image.png

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger 中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

1.3 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

这四种锁是指锁的状态,专门针对synchronized的。

1.4 公平锁 VS 非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。

2. 数据库锁

数据库锁以Mysql为例,说下数据库中的锁

2.1 按照锁的级别和粒度分

  • 表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最 高,并发度最低。
  • 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最 低,并发度也最高。
  • 页面锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表 锁和行锁之间,并发度一般。

2.1.1 MyISAM 表锁

  • 表共享读锁 (Table Read Lock):不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;
  • 表独占写锁 (Table Write Lock):会阻塞其他用户对同一表的读和写操作;

2.1.2 InnoDB行级锁和表级锁

InnoDB 实现了以下两种类型的行锁

  • 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
  • 排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。

InnoDB 还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁

  • 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。
  • 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。

2.2 乐观锁和悲观锁

乐观锁和悲观锁也知识广义上的一个概念, 乐观锁可能我们业务代码开发中会经常使用. 比如更新某个字段的时候加上版本号或者某个字段的限制条件, 更新失败有一个重试CAS策略.

比如mysql的排他锁,select .... for update来实现悲观锁。 乐观锁, 通常会用版本号version CAS + 自旋的方式去实现, 比如说库存扣减, CAS 扣减不成功, 继续去CAS扣减直到成功为止.

3. 分布式锁

常见实现分布式锁的方式有3种,1. 基于zk实现的分布式锁 2. 基于Redis实现的分布式锁 3. 基于数据库实现分布式锁

使用场景: 分布式锁,在电商的业务种运用非常广泛比如说库存扣减, 比如用户下单防并发操作等等.

最后

其实我们业务中常用的也主要是这3种锁, java锁, 数据库锁和分布式锁, 在了解了每种锁的定义和使用场景之后还是比较容易去做选型, 锁的最终目的都是解决资源竞争的问题, 保证线程安全和最终数据的一致性的,

参考

MySQL锁总结
MySQL实战45讲
不可不说的Java“锁”事