一、概括
优点
-
乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。
-
悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
适用场景
-
乐观锁(多读少写场景)
即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。
-
悲观锁(多写少读场景)
多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能。
二、悲观锁
基础
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁
。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
比如synchronized关键字、Lock接口、ReentrantLock(AQS框架下的锁则是先尝试CAS乐观锁去获取,获取不到,才会转为悲观锁)等独占锁就是悲观锁思想的实现。
说明
在进入同步方法的时候都会获取当前同步锁对象,直到退出同步方法时才会释放同步锁对象。如果有线程A和线程B,它们都会访问obj对象的同步方法。当线程A获取到obj对象的同步锁,在执行某些方法的时候,这时候线程B企图获取 obj对象的同步锁就会失败,这个时候必须等待(阻塞),直到线程A释放掉 obj对象的同步锁,线程B才能执行obj的同步方法。
缺点
-
在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
-
一个线程持有锁会导致其它所有需要此锁的线程挂起(
阻塞
)。 -
如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
三、乐观锁
3.1 基础
基础
顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据
,可以使用版本号等机制。
乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。
说明
每次不加锁而是假设没有并发冲突而去完成某项操作,如果因为并发冲突失败就重试,直到成功为止。
相对悲观锁而言,乐观锁假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。冲突检测和数据更新
实现技术
-
版本号机制
-
CAS(ABA问题 -- 外加版本号或时间戳)
在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
3.2 版本号机制
说明
给每个共享变量加上version字段,表示数据被修改的次数
,当数据被修改时,version值会加1。
当线程A操作数据值时,在读取数据时先会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
3.3 CAS
3.3.1 基础
基础
在java内存模型中所有的共享变量是放在共享内存中,不同的线程都会拷贝这些共享变量到自己的内存中,更新的时候先更新线程本地的值,再push到共享内存当中,读取的时候默认也是先读取本地的。
CAS:CAS有三个参考值
-
V:变量在共享内存中的值
-
A:线程本地内存的值
-
B:线程模拟修改变量后的值
CAS机制在更新一个变量的时候,只有当变量在线程本地内存的值A和共享内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
3.3.2 示例
举例
-
在共享内存地址V当中,存储着值为10的变量。
-
此时线程1想要把变量的值增加1,它会先在自己的内存中对变量进行+1操作。对线程1来说,旧的预期值A=10,修改后的新值B=11,共享内存中的实际值是10。
-
在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。
-
线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。
-
线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为
自旋操作
(先将本地的值更新成共享内存中最新的值,然后重新对变量修改,再提交到共享内存,直到提交成功)。 -
线程1再次提交,没有其他线程改变地址V的值。线程1进行A和V的比较,发现A和地址V的实际值是相等的。
-
线程1提交成功,把地址V的值替换为B,也就是12。
缺点
-
CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
-
不能保证
代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
3.3.3 ABA
ABA问题
假设有个共享变量的值为A,此时线程1去修改这个变量,在线程1修改的时候,线程2把这个变量修改成了B,然后线程3又把变量修改成了A。当线程1修改完毕时根据旧的预期值和共享内存的实际值进行比较得到的结果是相等的,则线程1认为变量没被修改过,则线程1提交成功。但其实变量已经被修改,此时的A非彼A。这就是ABA问题。
解决ABA问题
当每次修改共享变量提交时,不仅提交新值,还应给变量添加一个版本号或者一个时间戳
。每次提交之前先判断旧的预期值是否和共享内存的实际值相等,相等时再比较版本号或者时间戳是否对应。
示例
原子引用示例代码:
import java.util.concurrent.atomic.AtomicReference;
public class User {
private String userName;
private Integer age;
public User(String userName, Integer age) {
this.userName = userName;
this.age = age;
}
@Override
public String toString() {
return "User{" +
"userName='" + userName + '\'' +
", age=" + age +
'}';
}
public static void main(String[] args) {
User z3 = new User("z3",22);
User l4 = new User("l4",33);
AtomicReference<User> atomicReference = new AtomicReference<>();
atomicReference.set(z3);
// true User{userName='l4', age=33}
System.out.println(atomicReference.compareAndSet(z3,l4)+"\t"+atomicReference.get().toString());
// false User{userName='l4', age=33}
System.out.println(atomicReference.compareAndSet(z3,l4)+"\t"+atomicReference.get().toString());
}
}
使用AtomicStampedReference类,利用版本号来解决ABA问题。
示例代码:
package com.company.project;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;
/**
* ABA问题解决 AtomicStampedReference
* 利用版本号来解决ABA问题
*/
public class ABADemo {
// 当前引用为100,初始化版本号为1
static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100,1);
public static void main(String[] args) {
// 开启的第一个转账线程t1,期望值100,更新值为50
new Thread(() -> {
// 休眠1秒,让第二个转账线程t2可以获取到当前的版本号
try {
TimeUnit.SECONDS.sleep(1);
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("线程一付款:"+atomicStampedReference.compareAndSet(100, 50, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1)+
"\t版本号:"+atomicStampedReference.getStamp()+"\t余额:"+atomicStampedReference.getReference());
},"t1").start();
// 开启的第二个转账线程t2,期望值100,更新值为50
new Thread(() -> {
// 获取版本号
int stamp = atomicStampedReference.getStamp();
// 休眠3秒,等待线程t1和t3都执行结束,
try {
TimeUnit.SECONDS.sleep(3);
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("线程二付款:"+atomicStampedReference.compareAndSet(100, 50, stamp, stamp + 1)+
"\t版本号:"+atomicStampedReference.getStamp()+"\t余额:"+atomicStampedReference.getReference());
},"t2").start();
//开启线程t3,收钱线程,小王妈妈给小王转账50
new Thread(() -> {
//暂停2秒钟,等到线程t1执行结束
try {
TimeUnit.SECONDS.sleep(2);
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("线程三收钱:"+atomicStampedReference.compareAndSet(50, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1)+
"\t版本号:"+atomicStampedReference.getStamp()+"\t余额:"+atomicStampedReference.getReference());
},"t3").start();
}
}