1,概念梳理
我相信大家经常在网上看到各种各样的关于锁文章,想必有些人心里已经乱成一锅粥了,我们先来梳理一下各种锁的区别及分类,先搭建一个框架然后逐一击破!
1-1,首先我们看为什么需要锁
首先,我们要知道,每个线程都有自己的私有空间,当修改一个参数的时候,线程会首先将其值从内存拷贝到自己的私有空间,然后进行修改,最后再将新的值写入到内存
现在,我们假设一个场景:
内存中有一个参数 V 值为 0,我们开启 100000 个线程对其累加,我们想要的结果是 V 的值变为 100000,但是实际结果往往难以预料:
public class Main {
public int value = 0;
private void addValue() {
value++;
}
public static void main(String[] args) throws InterruptedException {
Main main = new Main();
for (int i = 0; i < 100000; i++) {
new Thread(() -> main.addValue()).start();
}
//加 10s 延时是为了保证 10w 个线程全部执行完成后再去读取结果
Thread.sleep(10000);
System.out.println(main.value);
}
}
结果:
可以看到结果多次不一致,至于上面为什么用 start 而不用 run,原因是 start 才是真正开辟线程去执行,run 还是在当前线程执行的,所以用 run 的话结果还会是 10000;
我们可以发现问题已经出现了,Why?原因就是每个线程交错执行导致的数据不一致,让我们来看下图
这是一个线程的情况下,如果多个线程呢?那么会发生如下情况
结果就是 Value 的值被更新为 1,但是我们想要的结果是 Value 为 2
1-2,怎么解决这些问题?
我们可以发现,问题的根本在于线程 B 没有拷贝到正确的值,所以我们只需要让线程 B 等线程 A 执行完毕后再去执行,这样的话线程 B 拷贝的值就是 1,那么执行自增后就是 2,最后更新后 Value 的值也就是 2,这就是 悲观锁
那还有一种解决方式,就是线程 B 不用等 A 执行完毕,只需要在最后更新 Value 时检查 Value 的值是否还是拷贝时的值即可,如果不是那么说明有别的线程对 Value 进行了操作,我们可以选择让线程 B 失败或者再次重复一次执行即可,这就是 乐观锁
1-3,具体该怎么做?
这里我们要区分出程序内的锁和数据库内的锁,具体的情形有不同的方案
我先给大家放一个锁的概览
- 乐观锁和悲观锁只是一种概念,而不是具体的锁!
2,乐观锁(Optimistic Locking)
顾名思义,乐观锁就是保持乐观,不对程序或数据进行加锁控制,认为别人不会修改只会读取数据,那么,问题来了,如果真的别人修改了数据怎么办,按照我们之前的思想,比较一下现在的值和之前拷贝的一不一样然后在做操作就行了
2-1,程序的乐观锁
在 Java 中,使用 CAS(Compare And Set)和自旋来实现乐观锁,CAS 顾名思义就是比较并且设置值,自旋就是不断的循环判断是否可以继续执行,CAS 操作有三个值:
- V(数据地址)
- A(数据的旧值)
- B(数据的新值) 所以整个执行流程为:
- 线程拷贝数据地址(V)并获取数据备份(A)
- 线程内部修改数据备份
- 执行完毕后判断数据地址(V)是否还和拷贝的旧值(A)一样
- 如果一样说明数据没有变动,则直接将数据新值(B)更新
- 如果不一样说明有变动,那么重新拷贝数据,再次执行操作,再做对比,直到可以更新为止 比如我们要使用 int 的 CAS 操作,就需要使用 AtomicInteger(原子性的 int)
public static void main(String[] args) throws InterruptedException {
AtomicInteger value = new AtomicInteger(0);
for (int i = 0; i < 10000; i++) {
new Thread(() -> value.incrementAndGet()).start();
}
//加 1s 延时是为了保证 1w 个线程全部执行完成后再去读取结果
Thread.sleep(1000);
System.out.println(value);
}
结果:
那么 AtomicInteger 的 incrementAndGet 是怎么保证原子性的呢?原理就是调用了 CPU 的指令!
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
自旋的代码:
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;
}
C语言 层面的代码
//native 就表示是 C语言 层面了,具体 C语言 也是调用 CPU 指令
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
2-2,数据库的乐观锁
数据库的乐观锁实现不需要借助数据库锁,而是多采用 version 版本对比,即在每行数据后面加一个 version 标识,数据每次更新 version 都会变化,所以当更新数据时只需要判断数据现在的 version 和获取时的一不一样即可,原理和 CAS 一致
3,悲观锁(Pessimistic Lock)
3-1,程序里的悲观锁
3-1-1,synchronized
private synchronized void addValue() {
value++;
}
结果:
Synchronized 的缺陷:
- Synchronized 代码块只有在执行完毕时才会释放锁,当代码块内有耗时的操作,比如 IO 时,别的线程将会被阻塞很久,降低性能
- 读操作之间是不冲突的,如果每个线程或者大部分线程都是读操作,那么 Synchronized 将会大大降低性能,因为加了 Synchronized,同一时间只有一个线程可以使用代码块
- Synchronized 释放锁是程序自己释放的,程序员无法操控,也就无法得知锁的状态
3-1-2:Lock 接口
首先我们看一下 Lock 的方法和类关系图
- lock():上锁,如果锁不是空闲,则等待
- lockInterruptibly():如果当前线程未被中断,则上锁,可以响应中断
- tryLock():仅在调用时锁为空闲状态才上锁,可以响应中断
- tryLock(long time,TimeUnit unit):如果锁在给定的等待时间内空闲,并且当前线程未被中断,则上锁
- unlock():释放锁
- newCondition():返回绑定到此 Lock 实例的新 Condition 实例
Lock 的主要实现类 ReentranLock(可重入锁),什么叫可重入锁呢?简而言之: 当外部方法已经获得锁的时候,内部方法可以获取相同的锁,而不会产生死锁,可重入锁最常见的应用场景就是递归! ReentranLock 有两种模式:公平锁和非公平锁: - 公平锁:谁等待的时间长,谁就优先获得锁
private ReentrantLock lock = new ReentrantLock(true);
private void addValue() {
lock.lock();
System.out.println(Thread.currentThread().getId()+"获取锁");
value++;
lock.unlock();
}
- 非公平锁(默认):随机分配,谁运气好,谁获得锁
private ReentrantLock lock = new ReentrantLock(false);
3-1-3:ReadWriteLock 接口
先看一下 ReadWriteLock 的方法和类关系图
- readLock():只有一个线程可以写数据,其他的只能读
- writeLock():只有一个线程可以读写数据,其他线程不得操作
ReadWriteLock 的主要实现类 ReentrantReadWriteLock,其也有公平锁和非公平锁 - readLock()
private ReadWriteLock lock = new ReentrantReadWriteLock();
private void addValue() {
lock.readLock().lock();
try {
System.out.println(new Date().toString() + " "+ Thread.currentThread().getId() + "获取锁");
Thread.sleep(1000);
value++;
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.readLock().unlock();
}
}
经过测试,同一时间所有线程都获取了锁
- writeLock()
private void addValue() {
lock.writeLock().lock();
try {
System.out.println(new Date().toString() + " "+ Thread.currentThread().getId() + "获取锁");
Thread.sleep(1000);
value++;
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.writeLock().unlock();
}
}
经过测试,每个线程都是等待上个线程释放写锁之后才可获得写锁
3-2,数据库的悲观锁
3-2-1,共享锁(Shared Locks)
共享锁,也叫读锁,也就是共享查询锁,即当一个事务正在执行时,对数据加共享锁,这时只允许别的事务进行查询而不能进行修改,知道本事务完成后再释放共享锁。
- 一个会话给表加读锁并进行查询和修改
- 其他的会话进行查询和读取
3-2-1,排他锁(Exclusive Locks)
排他锁顾名思义就是排斥其他的事务,也叫写锁,即本事务执行时对数据行加锁,这时不允许其他的事务对该数据进行访问和修改
- 一个会话给表加写锁并进行查询和修改
- 其他的会话进行查询和读取