前言

小孩子才做选择,我全都要,今天写一下面试必问的内容:乐观锁与悲观锁。主要从以下几方面来说:

  • 何为乐观锁

  • 何为悲观锁

  • 乐观锁常用实现方式

  • 悲观锁常用实现方式

  • 乐观锁的缺点

  • 悲观锁的缺点

写文章的时候突然收到朋友发来的消息,说乌兹退役了,LPL0006号选手断开连接。愿你鲜衣怒马,一日看尽长安花,历尽山河万里,归来仍是曾经那个少年。来,跟我一起喊一句:大道至简-唯我自豪

1、何为乐观锁

乐观锁总是假设事情向着好的方向发展,就比如有些人天生乐观,向阳而生!

乐观锁总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于多读的应用类型,因为乐观锁在读取数据的时候不会去加锁,这样可以省去了锁的开销,加大了系统的整个吞吐量。即时偶尔有冲突,这也无伤大雅,要么重新尝试提交要么返回给用户说跟新失败,当然,前提是偶尔发生冲突,但如果经常产生冲突,上层应用会不断的进行自旋重试,这样反倒是降低了性能,得不偿失。

2、何为悲观锁

悲观锁总是假设事情向着坏的方向发展,就比如有些人经历了某些事情,可能不太相信别人,只信任自己,身在黑暗,脚踩光明!

悲观锁每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞住,直到我释放了锁,别人才能拿到锁,这样的话,数据只有本身一个线程在修改,就确保了数据的准确性。因此,悲观锁适用于多写的应用类型。

3、乐观锁常用实现方式

3.1 版本号机制

版本号机制就是在表中增加一个字段,version,在修改记录的时候,先查询出记录,再每次修改的时候给这个字段值加1,判断条件就是你刚才查询出来的值。看下面流程就明白了:

  • 3.1.1 新增用户信息表

CREATE TABLE `user_info` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `user_name` varchar(64) DEFAULT NULL COMMENT '用户姓名',
  `money` decimal(15,0) DEFAULT '0' COMMENT '剩余金额(分)',
  `version` bigint(20) DEFAULT '1' COMMENT '版本号',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB COMMENT='用户信息表';
复制代码
  • 3.1.2 新增一条数据

INSERT INTO `user_info` (`user_name`, `money`, `version`) VALUES ('张三', 1000, 1);
复制代码
  • 3.1.3 操作步骤

步骤 线程A 线程B
1 查询张三数据,获得版本号为1(SELECT * FROM user_info WHERE user_name = '张三';)  
2   查询张三数据,获得版本号为1(SELECT * FROM user_info WHERE user_name = '张三';)
3 修改张三金额,增加100,版本号+1(UPDATE user_info SET money = money + 100, version = version + 1 WHERE user_name = '张三' AND version = 1;),返回修改条数为1  
4   修改张三金额,增加200,版本号+1(UPDATE user_info SET money = money + 200, version = version + 1 WHERE user_name = '张三' AND version = 1;),返回修改条数为0
5 判断修改条数为是否为0,是返回失败,否则返回成功  
6   判断修改条数为是否为0,是返回失败,否则返回成功
7 返回成功  
8   返回失败

3.2 CAS算法

CAS即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS 算法涉及到三个操作数:

  1. 需要读写的内存值 V(主内存中的变量值)

  2. 进行比较的值 A(克隆下来线程本地内存中的变量值)

  3. 拟写入的新值 B(要更新的新值)

当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个 native 原子操作)。一般情况下,这是一个自旋操作,即不断的重试,看下面流程:

  • 3.2.1 CAS算法模拟数据库更新数据(表还是刚才那个表,用户张三的金额初始值为1000),给用户张三的金额增加100:

private void updateMoney(String userName){
     // 死循环
     for (;;){
         // 获取张三的金额
         BigDecimal money = this.userMapper.getMoneyByName(userName);
         User user = new User();
         user.setMoney(money);
         user.setUserName(userName);
         // 根据用户名和金额进行更新(金额+100)
         Integer updateCount = this.userMapper.updateMoneyByNameAndMoney(user);
         if (updateCount != null && updateCount.equals(1)){
             // 如果更新成功就跳出循环
             break;
         }
     }
 }
复制代码
  • 3.2.2 流程图如下:

步骤 线程A 线程B
1 从表中查询出张三的money=1000,设置进行比较的值为1000,要写入的新值为money + 100 = 1100(V:1000--A:1000--B:1100)  
2   从表中查询出张三的money=1000,设置进行比较的值为1000,要写入的新值为money + 100 = 1100(V:1000--A:1000--B:1100)
3 更新张三的金额(UPDATE user_info SET money = money + 100 WHERE user_name = '张三' AND money = 1000;),返回更新条数为1  
4   更新张三的金额(UPDATE user_info SET money = money + 100 WHERE user_name = '张三' AND money = 1000;),返回更新条数为0
5 跳出循环,返回更新成功  
6   自旋再次从表中查询出张三的money=1100,设置进行比较的值为1100,要写入的新值为money + 100 = 1200(V:1100--A:1100--B:1200)
7   更新张三的金额(UPDATE user_info SET money = money + 100 WHERE user_name = '张三' AND money = 1100;),返回更新条数为1
8   跳出循环,返回更新成功

看到这里,明眼人都发现了一些CAS更新的小问题,至于是什么问题呢、怎么解决呢,放在下面来讲,要不然下面几条就没得写了。。。。。。

注意,这里的加版本号机制和CAS出现ABA问题加版本号解决机制不是同一个。

4、悲观锁常用实现方式

4.1 ReentrantLock

可重入锁就是悲观锁的一种,如果你看过前两篇文章,对可重入锁的原理就很清楚了,不清楚的话就看下如下的流程:

  • 假设同步状态值为0表示未加锁,为1加锁成功

步骤 线程A 线程B
1 从主内存中克隆出同步状态值为0,设置进行比较的值为0,要写入的新值为1(V:0--A:0--B:1)  
2   从主内存中克隆出同步状态值为0,设置进行比较的值为0,要写入的新值为1(V:0--A:0--B:1)
3 更新主内存,用A和主内存的值比较,0 = 0,加锁成功,此时主内存值为1  
4   更新主内存,用A和主内存的值比较,0 != 1,加锁失败。
5 返回加锁成功  
6 执行业务逻辑 自旋再次尝试更新主内存,用A和主内存的值比较,0 != 1,加锁失败
7   自旋再次尝试更新主内存,用A和主内存的值比较,0 != 1,加锁失败
8   调用parkAndCheckInterrupt()方法,阻塞线程
9 释放锁,设置同步状态值为0  
10   前驱节点出队,唤醒之后再次尝试更新主内存,用A和主内存的值比较,0 = 0,加锁成功,此时主内存值为1

可以看到,只要线程A获取了锁,还没释放的话,线程B是无法获取锁的,除非A释放了锁,B才能获取到锁,加锁的方式都是通过CAS去比较再交换,B会尝试自旋去设值,在尝试几次之后,就会阻塞线程,等到前驱节点出队通知之后再次尝试获取锁,这也就说明了为啥悲观锁比起乐观锁来说更加消耗性能。

4.2 synchronized

其实和上面差不多的,只不过上面自身维护了一个volatile int类型的变量,用来描述获取锁与释放锁,而synchronized是靠指令判断加锁与释放锁的,如下代码:

public class synchronizedTest {
  
    。。。。。。

    public void synchronizedTest(){
        synchronized (this){
            mapper.updateMoneyByName("张三");
        }
    }
}
复制代码

上面代码对应的流程图如下:

步骤 线程A 线程B
1 调用synchronizedTest()方法  
2   调用synchronizedTest()方法
3 插入monitorenter指令  
4 执行业务逻辑 尝试获取monitorenter指令的所有权
5 执行业务逻辑 尝试获取monitorenter指令的所有权
6 执行业务逻辑 尝试获取monitorenter指令的所有权
7 业务逻辑执行完毕,插入monitorexit指令 尝试获取monitorenter指令的所有权,获取成功,插入monitorenter指令
8   执行业务逻辑
9   执行业务逻辑
10   业务逻辑执行完毕,插入monitorexit指令

如果在某个线程执行synchronizedTest()方法的过程中出现了异常,monitorexit指令会插入在异常处,ReentrantLock需要你手动去加锁与释放锁,而synchronized是JVM来帮你加锁和释放锁。

5、乐观锁的缺点

5.1.1 ABA 问题

上面在说乐观锁用CAS方式实现的时候有个问题,明眼人能发现的,不知道各位有没有发现,问题如下:

步骤 线程A 线程B
1 从表中查询出张三的money=1000,设置进行比较的值为1000,要写入的新值为money + 100 = 1100(V:1000--A:1000--B:1100)  
2   从表中查询出张三的money=1000,设置进行比较的值为1000,要写入的新值为money + 100 = 1100(V:1000--A:1000--B:1100)
3 更新张三的金额(UPDATE user_info SET money = money + 100 WHERE user_name = '张三' AND money = 1000;),返回更新条数为1  
4   更新张三的金额(UPDATE user_info SET money = money + 100 WHERE user_name = '张三' AND money = 1000;),返回更新条数为0
5 跳出循环,返回更新成功  
6   自旋再次从表中查询出张三的money=1100,设置进行比较的值为1100,要写入的新值为money + 100 = 1200(V:1100--A:1100--B:1200)
7   更新张三的金额(UPDATE user_info SET money = money + 100 WHERE user_name = '张三' AND money = 1100;),返回更新条数为1(注意,问题在这里,在步骤6,我们查询到money=1100,而我们在这里判断的时候,能确定money没有被别的线程修改过吗?答案是并不能,有可线程能C加了100,线程D减了100,而这里的money值仍然是1100,这个问题被称为CAS操作的 "ABA"问题
8   跳出循环,返回更新成功
  • 解决方案:

给表增加一个version字段,每修改一次值加1,这样就能在写入的时候判断获取到的值有没有被修改过,流程图如下:

步骤 线程A 线程B
1 从表中查询出张三的money=1000,version=1  
2   从表中查询出张三的money=1000,version=1
3 更新张三的金额(UPDATE user_info SET money = money + 100, version = version + 1 WHERE user_name = '张三' AND money = 1000 AND version = 1;),返回更新条数为1  
4   更新张三的金额(UPDATE user_info SET money = money + 100, version = version + 1 WHERE user_name = '张三' AND money = 1000 AND version = 1;),返回更新条数为0
5 跳出循环,返回更新成功  
6   自旋再次从表中查询出张三的money=1100,version = 2
7   更新张三的金额(UPDATE user_info SET money = money + 100, version = version + 1 WHERE user_name = '张三' AND money = 1100 AND version = 2;),返回更新条数为1
8   跳出循环,返回更新成功

5.1.2 循环时间长开销大

自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。个人想法是在死循环添加尝试次数,达到尝试次数还没成功的话就返回失败。不确定有没有什么问题,欢迎指出。

5.1.3 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

6、悲观锁的缺点

6.1 synchronized

  • 锁的释放情况少,只在程序正常执行完成和抛出异常时释放锁;

  • 试图获得锁是不能设置超时;

  • 不能中断一个正在试图获得锁的线程;

  • 无法知道是否成功获取到锁;

6.2 ReentrantLock

  • 需要使用import 引入相关的Class;

  • 不能忘记在finally 模块释放锁,这个看起来比synchronized 丑陋;

  • synchronized可以放在方法的定义里面, 而reentrantlock只能放在块里面. 比较起来, synchronized可以减少嵌套;