一、悲观锁
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。
悲观锁是一种利用数据库内部机制提供的锁的方法,也就是对更新的数据加锁,这样在并发期间一旦一个事务持有了数据库记录的锁,其他的线程将不能再对数据进行更新了,这就是悲观锁的实现逻辑。
对于悲观锁来说,只能有一个事务占据资源,其他事务被挂起等待持有资源的事务提交并释放资源。CPU就会将这些得不到资源的线程挂起,挂起的线程也会消耗CPU的资源,尤其是在高井发的请求中。
一旦该线程提交了事务,那么锁就会被释放,这个时候被挂起的线程就会开始竞争资源,那么竞争到的线程就会被 CPU 恢复到运行状态,继续运行。
在高并发的过程中,使用悲观锁就会造成大量的线程被挂起和恢复,这将十分消耗资源,这就是为什么使用悲观锁性能不佳的原因。
有些时候,我们也会把悲观锁称为独占锁,毕竟只有一个线程可以独占这个资源,或者称为阻塞锁,因为它会造成其他线程的阻塞。无论如何它都会造成并发能力的下降,从而导致 CPU 频繁切换线程上下文,造成性能低下。为了克服这个问题,提高并发的能力,避免大量线程因为阻塞导致 CPU 进行大量的上下文切换,程序设计大师们提出了乐观锁机制,乐观锁已经在企业中被大量应用了。
二、乐观锁
乐观锁是一种思想,总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。
乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现,它的设计里面由于不阻塞其他线程,所以并不会引发线程频繁挂起和恢复,这样便能够提高并发能力,所以也有人把它称为非阻塞锁。
乐观锁使用的是 CAS 原理,所以我们先来讨论 CAS 原理的内容。
2.1 CAS 原理概述
2.2 ABA 问题解决
2.3 乐观锁思想
可以说乐观锁是由CAS机制+版本机制来实现的。
乐观锁假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
(1)CAS机制:当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败。CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可“。
(2)版本机制:CAS机制保证了在更新数据的时候没有被修改为其他数据的同步机制,版本机制就保证了没有被修改过的同步机制,解决了ABA问题。
2.4 乐观锁的代码实例
2.4.1 线程不安全实例
先看一个不使用锁,然后多线程访问的实例:
要争夺的资源
public class Number {
int num = 0;
public int getNum() {
return this.num;
}
public void setNum(int num) {
this.num = num;
}
public void add() {
num += 1;
}
public void dec() {
num -= 1;
}
}
线程一:
/**
* 线程一 数据做加操作的线程
*/
public class ThreadOne extends Thread {
Number num;
public ThreadOne(Number num) {
this.num = num;
}
@Override
public void run() {
for (int i = 0; i < Test.LOOP; ++i) {
num.add();
}
}
}
线程二:
/**
* 线程二 数据做减法操作的线程
*/
public class ThreadTwo extends Thread {
Number num;
public ThreadTwo(Number num) {
this.num = num;
}
@Override
public void run() {
for (int j = 0; j < Test.LOOP; j++) {
num.dec();
}
}
}
测试类:
public class Test {
final static int LOOP = 1000;
public static void main(String[] args) throws InterruptedException {
Number num = new Number();
Thread addThread = new ThreadOne(num);
Thread decThread = new ThreadTwo(num);
addThread.start();
decThread.start();
addThread.join();
decThread.join();
System.out.println(num.getNum());
}
}
我们运行3次,结果:
-16
8
293
对同一个数据0执行1000加法,再执行1000次减法,最后数据应该还是0才对,但三次执行结果都不是0,且三次结果都不一样。
也就是多线程更新数据时,数据没有朝着我们期望的结果进行,产生这个结果的原因就是加线程和减线程并没有均匀的抢占资源,若果是均匀抢占那么意味着加和减的操作次数是一样的,最终结果肯定是0;若加线程抢占的次数多了,那结果就是正数;若减操作抢占的次数多,结果就会是负数。
线程安全可以认为是多线程访问同一代码(数据/资源)时,不会产生不确定的结果,那么上面的情况就可以认为是线程不安全的例子,解决的办法就是加锁,加锁就会涉及到悲观锁和乐观锁两种加锁方式。
2.4.2 悲观锁实例
先看悲观锁的处理方式:
public class Number {
int num = 0;
public synchronized void add() {
num += 1;
}
public synchronized void dec() {
num -= 1;
}
public int getNum() {
return this.num;
}
public void setNum(int num) {
this.num = num;
}
}
以上共享资源的加和减操作都加上了锁,相当于Number这个资源被锁定,只有当释放锁以后另一个线程才能访问。
public class ThreadOne extends Thread {
Number num;
public ThreadOne(Number num) {
this.num = num;
}
@Override
public void run() {
for (int i = 0; i < Test.LOOP; ++i) {
num.add();
}
}
}
public class ThreadTwo extends Thread {
Number num;
public ThreadTwo(Number num) {
this.num = num;
}
@Override
public void run() {
for (int j = 0; j < Test.LOOP; j++) {
num.dec();
}
}
}
public class Test {
final static int LOOP = 1000;
public static void main(String[] args) throws InterruptedException {
System.out.println("开始时间:" + new Date().getTime());
Number num = new Number();
Thread addThread = new ThreadOne(num);
Thread decThread = new ThreadTwo(num);
addThread.start();
decThread.start();
addThread.join();
decThread.join();
System.out.println(num.getNum());
System.out.println("结束时间:" + new Date().getTime());
}
}
结果:
开始时间:1592102861797
0
结束时间:1592102861799
每次执行都是0。
2.4.3 乐观锁实例
下面用乐观锁思想实现一下,
public class Number {
// int num = 0;
//使用AtomicInteger代替基本数据类型
AtomicInteger num = new AtomicInteger(0);
public void add() {
// num += 1;
num.addAndGet(1);
}
public void dec() {
// num -= 1;
num.decrementAndGet();
}
public AtomicInteger getNum() {
return this.num;
}
}
上面用AtomicInteger代替基本数据类型,其它代码不变:
public class ThreadOne extends Thread {
Number num;
public ThreadOne(Number num) {
this.num = num;
}
@Override
public void run() {
for (int i = 0; i < Test.LOOP; ++i) {
num.add();
}
}
}
public class ThreadTwo extends Thread {
Number num;
public ThreadTwo(Number num) {
this.num = num;
}
@Override
public void run() {
for (int j = 0; j < Test.LOOP; j++) {
num.dec();
}
}
}
public class Test {
final static int LOOP = 1000;
public static void main(String[] args) throws InterruptedException {
System.out.println("开始时间:" + new Date().getTime());
Number num = new Number();
Thread addThread = new ThreadOne(num);
Thread decThread = new ThreadTwo(num);
addThread.start();
decThread.start();
addThread.join();
decThread.join();
System.out.println(num.getNum());
System.out.println("结束时间:" + new Date().getTime());
}
}
开始时间:1592103599077
0
结束时间:1592103599079
这里最值得探究的就是AtomicInteger,为什么改个数据类型就能实现乐观锁的功能,打开源码:
compareAndSwapInt不是就是CAS么!返回true就可以执行更新操作。
那么你会问CAS有了,ABA在哪呢?Java提供了AtomicStampedReference工具类。通过为引用建立类似版本号(stamp)的方式,来保证CAS的正确性。AtomicStampedReference它内部不仅维护了对象值,还维护了一个时间戳(我这里把它称为时间戳,实际上它可以使任何一个整数,它使用整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。当AtomicStampedReference设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。
AtomicStampedReference主要的方法入下:
我们大致演示一下这个类的使用方法:
public static void main(String[] args) {
String str1 = "aaa";
String str2 = "bbb";
String str3 = "ccc";
// 某个位置设置初始值为str1,版本号为1
AtomicStampedReference<String> reference = new AtomicStampedReference<String>(str1, 1);
System.out.println("reference.getReference() = " + reference.getReference());
System.out.println("reference.getStamp() = " + reference.getStamp());
System.out.println("-----------------------------");
// 用str2替換str1,替換成功返回true,同时更新版本号到2
boolean flag = reference.compareAndSet(str1, str2, reference.getStamp(), reference.getStamp() + 1);
System.out.println("flag: " + flag);
System.out.println("reference.getReference() = " + reference.getReference());
System.out.println("reference.getStamp() = " + reference.getStamp());
System.out.println("-----------------------------");
// 设置版本号到3
boolean b = reference.attemptStamp(str2, reference.getStamp() + 1);
System.out.println("b: " + b);
System.out.println("reference.getReference() = " + reference.getReference());
System.out.println("reference.getStamp() = " + reference.getStamp());
System.out.println("-----------------------------");
// // 再次用str3替代str2,预期原始版本号为3,新版号为 原版本号+1 成功!
// boolean c = reference.weakCompareAndSet(str2, str3, 3, reference.getStamp() + 1);
// System.out.println("c = " + c);
// System.out.println("reference.getReference() = " + reference.getReference());
// System.out.println("reference.getStamp() = " + reference.getStamp());
// System.out.println("-----------------------------");
//
// // 再次用str1替代str2,预期原始版本号为3,新版号为 原版本号+1 失败!
// boolean d = reference.weakCompareAndSet(str2, str1, 3, reference.getStamp() + 1);
// System.out.println("d = " + d);
// System.out.println("reference.getReference() = " + reference.getReference());
// System.out.println("reference.getStamp() = " + reference.getStamp());
// System.out.println("-----------------------------");
// 再次用str1替代str2,预期原始版本号为4,新版号为 原版本号+1 失败!
boolean f = reference.weakCompareAndSet(str2, str1, 3, reference.getStamp() + 1);
System.out.println("f = " + f);
System.out.println("reference.getReference() = " + reference.getReference());
System.out.println("reference.getStamp() = " + reference.getStamp());
}
reference.getReference() = aaa
reference.getStamp() = 1
-----------------------------
flag: true
reference.getReference() = bbb
reference.getStamp() = 2
-----------------------------
b: true
reference.getReference() = bbb
reference.getStamp() = 3
-----------------------------
f = true
reference.getReference() = aaa
reference.getStamp() = 4
可以看到,虽然这个位置的值开始到最终的值都是“aaa”,但是版本号已经由1变成了4,这就是CAS中解决ABA问题的点。