前言
小孩子才做选择,我全都要
,今天写一下面试必问的内容:乐观锁与悲观锁。主要从以下几方面来说:
-
何为乐观锁
-
何为悲观锁
-
乐观锁常用实现方式
-
悲观锁常用实现方式
-
乐观锁的缺点
-
悲观锁的缺点
写文章的时候突然收到朋友发来的消息,说乌兹退役了,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 算法涉及到三个操作数:
-
需要读写的内存值 V(主内存中的变量值)
-
进行比较的值 A(克隆下来线程本地内存中的变量值)
-
拟写入的新值 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可以减少嵌套;